Merge "NamespaceInfo service to replace MWNamespace"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Wed, 10 Apr 2019 05:46:24 +0000 (05:46 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Wed, 10 Apr 2019 05:46:24 +0000 (05:46 +0000)
72 files changed:
RELEASE-NOTES-1.33
includes/DefaultSettings.php
includes/actions/HistoryAction.php
includes/export/XmlDumpWriter.php
includes/htmlform/HTMLForm.php
includes/htmlform/OOUIHTMLForm.php
includes/installer/DatabaseUpdater.php
includes/parser/DateFormatter.php
includes/search/SearchPostgres.php
includes/specials/SpecialUploadStash.php
languages/Language.php
resources/Resources.php
resources/src/jquery/jquery.makeCollapsible.styles.less
resources/src/mediawiki.action/mediawiki.action.history.styles.less
resources/src/mediawiki.cookie/.eslintrc.json [new file with mode: 0644]
resources/src/mediawiki.cookie/index.js
resources/src/mediawiki.jqueryMsg/.eslintrc.json [new file with mode: 0644]
resources/src/mediawiki.jqueryMsg/mediawiki.jqueryMsg.js
resources/src/mediawiki.rcfilters/.eslintrc.json [new file with mode: 0644]
resources/src/mediawiki.rcfilters/Controller.js
resources/src/mediawiki.rcfilters/HighlightColors.js
resources/src/mediawiki.rcfilters/UriProcessor.js
resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js
resources/src/mediawiki.rcfilters/dm/FilterGroup.js
resources/src/mediawiki.rcfilters/dm/FilterItem.js
resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js
resources/src/mediawiki.rcfilters/dm/ItemModel.js
resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js
resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
resources/src/mediawiki.rcfilters/mw.rcfilters.js
resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js
resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js
resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js
resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js
resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js
resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js
resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js
resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js
resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js
resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js
resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/GroupWidget.js
resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js
resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js
resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js
resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js
resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js
resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js
resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js
resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js
resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js
resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js
resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js
resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js
resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js
resources/src/mediawiki.rcfilters/ui/TagItemWidget.js
resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js
resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js
resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js
resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.styles.less
resources/src/startup/mediawiki.js
resources/src/startup/startup.js
tests/phpunit/MediaWikiTestCase.php
tests/phpunit/includes/cache/MessageCacheTest.php
tests/phpunit/includes/page/WikiPageDbTestBase.php
tests/phpunit/maintenance/DumpAsserter.php
tests/phpunit/maintenance/DumpTestCase.php
tests/phpunit/maintenance/backupTextPassTest.php

index 80b0d2e..fd316c4 100644 (file)
@@ -58,6 +58,10 @@ For notes on 1.32.x and older releases, see HISTORY.
   argon2 to be used, by default, it will automatically choose the best available
   algorithm depending on which version of PHP you have available. To use this,
   you can set `$wgPasswordDefault = 'argon2';`.
+* $wgActorTableSchemaMigrationStage now defaults to reading the new schema.
+  update.php will back-populate the new database fields due to the changed
+  setting, which may take some time on large wikis. You can avoid downtime by
+  following a process like that described in T188327.
 
 ==== Removed configuration ====
 * $wgTagStatisticsNewTable (T199334) â€” This temporary setting, added in
index 6f64e7f..44e0310 100644 (file)
@@ -8971,7 +8971,7 @@ $wgXmlDumpSchemaVersion = XML_DUMP_SCHEMA_VERSION_10;
  * @since 1.32 changed allowed flags
  * @var int An appropriate combination of SCHEMA_COMPAT_XXX flags.
  */
-$wgActorTableSchemaMigrationStage = SCHEMA_COMPAT_OLD;
+$wgActorTableSchemaMigrationStage = SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW;
 
 /**
  * Flag to enable Partial Blocks. This allows an admin to prevent a user from editing specific pages
index 06e214f..bc1d351 100644 (file)
@@ -268,6 +268,7 @@ class HistoryAction extends FormlessAction {
                $htmlForm
                        ->setMethod( 'get' )
                        ->setAction( wfScript() )
+                       ->setCollapsible( true )
                        ->setId( 'mw-history-searchform' )
                        ->setSubmitText( $this->msg( 'historyaction-submit' )->text() )
                        ->setWrapperAttributes( [ 'id' => 'mw-history-search' ] )
index 9d14442..54249a8 100644 (file)
@@ -207,6 +207,12 @@ class XmlDumpWriter {
         * @return string
         */
        function closePage() {
+               if ( $this->currentTitle !== null ) {
+                       $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+                       // In rare cases, link cache has the same key for some pages which
+                       // might be read as part of the same batch. T220424 and T220316
+                       $linkCache->clearLink( $this->currentTitle );
+               }
                return "  </page>\n";
        }
 
index e5330b6..aeeb934 100644 (file)
@@ -221,6 +221,20 @@ class HTMLForm extends ContextSource {
         */
        protected $mAction = false;
 
+       /**
+        * Whether the HTML form can be collapsed
+        * @since 1.33
+        * @var bool
+        */
+       protected $mCollapsible = false;
+
+       /**
+        * Whether the HTML form IS collapsed by default
+        * @since 1.33
+        * @var bool
+        */
+       protected $mCollapsed = false;
+
        /**
         * Form attribute autocomplete. A typical value is "off". null does not set the attribute
         * @since 1.27
@@ -1047,6 +1061,18 @@ class HTMLForm extends ContextSource {
                return '' . $this->mPre . $html . $this->mPost;
        }
 
+       /**
+        * Make the form collapsible
+        * @since 1.33
+        * @param bool $collapsed whether it should be by default
+        * @return HTMLForm $this for chaining calls (since 1.20)
+        */
+       public function setCollapsible( $collapsed = false ) {
+               $this->mCollapsible = true;
+               $this->mCollapsed = $collapsed;
+               return $this;
+       }
+
        /**
         * Get HTML attributes for the `<form>` tag.
         * @return array
index e21d783..22ece4c 100644 (file)
@@ -281,20 +281,30 @@ class OOUIHTMLForm extends HTMLForm {
 
        public function wrapForm( $html ) {
                if ( is_string( $this->mWrapperLegend ) ) {
+                       $classes = $this->mCollapsible ? [ 'mw-collapsible' ] : [];
+                       if ( $this->mCollapsed ) {
+                               $classes[] = 'mw-collapsed';
+                       }
                        $content = new OOUI\FieldsetLayout( [
                                'label' => $this->mWrapperLegend,
-                               'items' => [
-                                       new OOUI\Widget( [
-                                               'content' => new OOUI\HtmlSnippet( $html )
-                                       ] ),
-                               ],
+                               'classes' => $classes,
+                               'group' => new OOUI\StackLayout( [
+                                       'expanded' => false,
+                                       'classes' => [ 'oo-ui-fieldsetLayout-group mw-collapsible-content' ],
+                                       'items' => [
+                                               new OOUI\Widget( [
+                                                       'content' => new OOUI\HtmlSnippet( $html )
+                                               ] ),
+                                       ],
+                               ] ),
                        ] + OOUI\Element::configFromHtmlAttributes( $this->mWrapperAttributes ) );
                } else {
                        $content = new OOUI\HtmlSnippet( $html );
                }
 
+               $classes = [ 'mw-htmlform', 'mw-htmlform-ooui' ];
                $form = new OOUI\FormLayout( $this->getFormAttributes() + [
-                       'classes' => [ 'mw-htmlform', 'mw-htmlform-ooui' ],
+                       'classes' => $classes,
                        'content' => $content,
                ] );
 
index 5add0a8..54ffa5a 100644 (file)
@@ -20,7 +20,6 @@
  * @file
  * @ingroup Deployment
  */
-use Wikimedia\Rdbms\Database;
 use Wikimedia\Rdbms\IDatabase;
 use Wikimedia\Rdbms\IMaintainableDatabase;
 use MediaWiki\MediaWikiServices;
@@ -60,7 +59,7 @@ abstract class DatabaseUpdater {
        /**
         * Handle to the database subclass
         *
-        * @var Database
+        * @var IMaintainableDatabase
         */
        protected $db;
 
@@ -111,11 +110,15 @@ abstract class DatabaseUpdater {
        protected $holdContentHandlerUseDB = true;
 
        /**
-        * @param Database &$db To perform updates on
+        * @param IMaintainableDatabase &$db To perform updates on
         * @param bool $shared Whether to perform updates on shared tables
         * @param Maintenance|null $maintenance Maintenance object which created us
         */
-       protected function __construct( Database &$db, $shared, Maintenance $maintenance = null ) {
+       protected function __construct(
+               IMaintainableDatabase &$db,
+               $shared,
+               Maintenance $maintenance = null
+       ) {
                $this->db = $db;
                $this->db->setFlag( DBO_DDLMODE ); // For Oracle's handling of schema files
                $this->shared = $shared;
@@ -203,7 +206,7 @@ abstract class DatabaseUpdater {
        /**
         * Get a database connection to run updates
         *
-        * @return Database
+        * @return IMaintainableDatabase
         */
        public function getDB() {
                return $this->db;
index fb79442..c9bbc43 100644 (file)
@@ -258,7 +258,7 @@ class DateFormatter {
 
                if ( !isset( $bits['m'] ) ) {
                        $m = $this->makeIsoMonth( $bits['F'] );
-                       if ( !$m || $m == '00' ) {
+                       if ( $m === false ) {
                                $fail = true;
                        } else {
                                $bits['m'] = $m;
@@ -311,7 +311,7 @@ class DateFormatter {
                if ( $fail ) {
                        // This occurs when parsing a date with day or month outside the bounds
                        // of possibilities.
-                       $text = $orig;
+                       return $orig;
                }
 
                $isoBits = [];
@@ -336,8 +336,8 @@ class DateFormatter {
        private function getMonthRegex() {
                $names = [];
                for ( $i = 1; $i <= 12; $i++ ) {
-                       $names[] = $this->lang->getMonthName( $i );
-                       $names[] = $this->lang->getMonthAbbreviation( $i );
+                       $names[] = preg_quote( $this->lang->getMonthName( $i ), '/' );
+                       $names[] = preg_quote( $this->lang->getMonthAbbreviation( $i ), '/' );
                }
                return implode( '|', $names );
        }
@@ -345,11 +345,14 @@ class DateFormatter {
        /**
         * Makes an ISO month, e.g. 02, from a month name
         * @param string $monthName Month name
-        * @return string ISO month name
+        * @return string|false ISO month name, or false if the input was invalid
         */
        private function makeIsoMonth( $monthName ) {
-               $n = $this->xMonths[$this->lang->lc( $monthName )];
-               return sprintf( '%02d', $n );
+               $isoMonth = $this->xMonths[$this->lang->lc( $monthName )] ?? false;
+               if ( $isoMonth === false ) {
+                       return false;
+               }
+               return sprintf( '%02d', $isoMonth );
        }
 
        /**
index 6d5f117..74ee552 100644 (file)
@@ -23,6 +23,8 @@
  * @file
  * @ingroup Search
  */
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\SlotRecord;
 
 /**
  * Search engine hook base class for Postgres
@@ -137,10 +139,16 @@ class SearchPostgres extends SearchDatabase {
                $top = $res->fetchRow()[0];
 
                $this->searchTerms = [];
+               $slotRoleStore = MediaWikiServices::getInstance()->getSlotRoleStore();
                if ( $top === "" ) { # # e.g. if only stopwords are used XXX return something better
                        $query = "SELECT page_id, page_namespace, page_title, 0 AS score " .
-                               "FROM page p, revision r, pagecontent c WHERE p.page_latest = r.rev_id " .
-                               "AND r.rev_text_id = c.old_id AND 1=0";
+                               "FROM page p, revision r, slots s, content c, pagecontent pc " .
+                               "WHERE p.page_latest = r.rev_id " .
+                               "AND s.slot_revision_id = r.rev_id " .
+                               "AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " .
+                               "AND c.content_id = s.slot_content_id " .
+                               "AND pc.old_id = substring( c.content_address from '^tt:([0-9]+)$' )::int " .
+                               "AND 1=0";
                } else {
                        $m = [];
                        if ( preg_match_all( "/'([^']+)'/", $top, $m, PREG_SET_ORDER ) ) {
@@ -151,10 +159,14 @@ class SearchPostgres extends SearchDatabase {
 
                        $query = "SELECT page_id, page_namespace, page_title, " .
                                "ts_rank($fulltext, to_tsquery($searchstring), 5) AS score " .
-                               "FROM page p, revision r, pagecontent c WHERE p.page_latest = r.rev_id " .
-                               "AND r.rev_text_id = c.old_id AND $fulltext @@ to_tsquery($searchstring)";
+                               "FROM page p, revision r, slots s, content c, pagecontent pc " .
+                               "WHERE p.page_latest = r.rev_id " .
+                               "AND s.slot_revision_id = r.rev_id " .
+                               "AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " .
+                               "AND c.content_id = s.slot_content_id " .
+                               "AND pc.old_id = substring( c.content_address from '^tt:([0-9]+)$' )::int " .
+                               "AND $fulltext @@ to_tsquery($searchstring)";
                }
-
                # # Namespaces - defaults to 0
                if ( !is_null( $this->namespaces ) ) { // null -> search all
                        if ( count( $this->namespaces ) < 1 ) {
@@ -178,9 +190,17 @@ class SearchPostgres extends SearchDatabase {
 
        function update( $pageid, $title, $text ) {
                # # We don't want to index older revisions
-               $sql = "UPDATE pagecontent SET textvector = NULL WHERE textvector IS NOT NULL and old_id IN " .
-                               "(SELECT DISTINCT rev_text_id FROM revision WHERE rev_page = " . intval( $pageid ) .
-                               " ORDER BY rev_text_id DESC OFFSET 1)";
+               $slotRoleStore = MediaWikiServices::getInstance()->getSlotRoleStore();
+               $sql = "UPDATE pagecontent SET textvector = NULL " .
+                       "WHERE textvector IS NOT NULL " .
+                       "AND old_id IN " .
+                       "(SELECT DISTINCT substring( c.content_address from '^tt:([0-9]+)$' )::int AS old_rev_text_id " .
+                       " FROM content c, slots s, revision r " .
+                       " WHERE r.rev_page = $pageid " .
+                       " AND s.slot_revision_id = r.rev_id " .
+                       " AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " .
+                       " AND c.content_id = s.slot_content_id " .
+                       " ORDER BY old_rev_text_id DESC OFFSET 1)";
                $this->db->query( $sql );
                return true;
        }
index fe55d94..24d58c8 100644 (file)
@@ -261,6 +261,15 @@ class SpecialUploadStash extends UnlistedSpecialPage {
                $scalerThumbUrl = $scalerBaseUrl . '/' . $file->getUrlRel() .
                        '/' . rawurlencode( $scalerThumbName );
 
+               // If a thumb proxy is set up for the repo, we favor that, as that will
+               // keep the request internal
+               $thumbProxyUrl = $file->getRepo()->getThumbProxyUrl();
+
+               if ( strlen( $thumbProxyUrl ) ) {
+                       $scalerThumbUrl = $thumbProxyUrl . 'temp/' . $file->getUrlRel() .
+                       '/' . rawurlencode( $scalerThumbName );
+               }
+
                // make an http request based on wgUploadStashScalerBaseUrl to lazy-create
                // a thumbnail
                $httpOptions = [
@@ -268,6 +277,14 @@ class SpecialUploadStash extends UnlistedSpecialPage {
                        'timeout' => 5 // T90599 attempt to time out cleanly
                ];
                $req = MWHttpRequest::factory( $scalerThumbUrl, $httpOptions, __METHOD__ );
+
+               $secret = $file->getRepo()->getThumbProxySecret();
+
+               // Pass a secret key shared with the proxied service if any
+               if ( strlen( $secret ) ) {
+                       $req->setHeader( 'X-Swift-Secret', $secret );
+               }
+
                $status = $req->execute();
                if ( !$status->isOK() ) {
                        $errors = $status->getErrorsArray();
index 52cb231..fe94704 100644 (file)
@@ -2854,7 +2854,7 @@ class Language {
        }
 
        /**
-        * @return array
+        * @return string
         */
        function fallback8bitEncoding() {
                return self::$dataCache->getItem( $this->mCode, 'fallback8bitEncoding' );
index a63b19b..ba61488 100644 (file)
@@ -1435,6 +1435,7 @@ return [
                ],
        ],
        'mediawiki.action.history' => [
+               'dependencies' => [ 'jquery.makeCollapsible' ],
                'scripts' => 'resources/src/mediawiki.action/mediawiki.action.history.js',
                'styles' => 'resources/src/mediawiki.action/mediawiki.action.history.css',
        ],
index ec96cb6..0f922c9 100644 (file)
@@ -124,6 +124,12 @@ li,
        }
 }
 
+fieldset.mw-collapsible .mw-collapsible-toggle {
+       position: absolute;
+       right: 0;
+       z-index: 1;
+}
+
 // special treatment for list items to match above
 // !important necessary to override overly-specific float left and right above.
 ol.mw-collapsible:not( @{exclude} ):before,
index 7e88f7c..257f153 100644 (file)
@@ -1,10 +1,4 @@
 /* Basic styles for the history page */
-@import 'mediawiki.mixins';
-
-/* Visually hide repeating text, but leave in for better form navigation on screen readers. */
-.action-history .mw-htmlform-ooui .oo-ui-fieldsetLayout:first-child .oo-ui-fieldsetLayout-header {
-       .mixin-screen-reader-text();
-}
 
 #pagehistory .history-user {
        margin-left: 0.4em;
diff --git a/resources/src/mediawiki.cookie/.eslintrc.json b/resources/src/mediawiki.cookie/.eslintrc.json
new file mode 100644 (file)
index 0000000..ad8dbb3
--- /dev/null
@@ -0,0 +1,5 @@
+{
+       "parserOptions": {
+               "sourceType": "module"
+       }
+}
index 61379ae..b04b57a 100644 (file)
-( function () {
-       'use strict';
+'use strict';
 
-       var config = require( './config.json' ),
-               defaults = {
-                       prefix: config.prefix,
-                       domain: config.domain,
-                       path: config.path,
-                       expires: config.expires,
-                       secure: false
-               };
+var config = require( './config.json' ),
+       defaults = {
+               prefix: config.prefix,
+               domain: config.domain,
+               path: config.path,
+               expires: config.expires,
+               secure: false
+       };
+
+/**
+ * Manage cookies in a way that is syntactically and functionally similar
+ * to the `WebRequest#getCookie` and `WebResponse#setcookie` methods in PHP.
+ *
+ * @author Sam Smith <samsmith@wikimedia.org>
+ * @author Matthew Flaschen <mflaschen@wikimedia.org>
+ *
+ * @class mw.cookie
+ * @singleton
+ */
+mw.cookie = {
 
        /**
-        * Manage cookies in a way that is syntactically and functionally similar
-        * to the `WebRequest#getCookie` and `WebResponse#setcookie` methods in PHP.
+        * Set or delete a cookie.
         *
-        * @author Sam Smith <samsmith@wikimedia.org>
-        * @author Matthew Flaschen <mflaschen@wikimedia.org>
+        * **Note:** If explicitly passing `null` or `undefined` for an options key,
+        * that will override the default. This is natural in JavaScript, but noted
+        * here because it is contrary to MediaWiki's `WebResponse#setcookie()` method
+        * in PHP.
         *
-        * @class mw.cookie
-        * @singleton
+        * @param {string} key
+        * @param {string|null} value Value of cookie. If `value` is `null` then this method will
+        *   instead remove a cookie by name of `key`.
+        * @param {Object|Date|number} [options] Options object, or expiry date
+        * @param {Date|number|null} [options.expires=wgCookieExpiration] The expiry date of the cookie,
+        *  or lifetime in seconds. If `options.expires` is null or 0, then a session cookie is set.
+        * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key
+        * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie
+        * @param {string} [options.path=wgCookiePath] The path attribute of the cookie
+        * @param {boolean} [options.secure=false] Whether or not to include the secure attribute.
+        *   (Does **not** use the wgCookieSecure configuration variable)
         */
-       mw.cookie = {
+       set: function ( key, value, options ) {
+               var date;
 
-               /**
-                * Set or delete a cookie.
-                *
-                * **Note:** If explicitly passing `null` or `undefined` for an options key,
-                * that will override the default. This is natural in JavaScript, but noted
-                * here because it is contrary to MediaWiki's `WebResponse#setcookie()` method
-                * in PHP.
-                *
-                * @param {string} key
-                * @param {string|null} value Value of cookie. If `value` is `null` then this method will
-                *   instead remove a cookie by name of `key`.
-                * @param {Object|Date|number} [options] Options object, or expiry date
-                * @param {Date|number|null} [options.expires=wgCookieExpiration] The expiry date of the cookie,
-                *  or lifetime in seconds. If `options.expires` is null or 0, then a session cookie is set.
-                * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key
-                * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie
-                * @param {string} [options.path=wgCookiePath] The path attribute of the cookie
-                * @param {boolean} [options.secure=false] Whether or not to include the secure attribute.
-                *   (Does **not** use the wgCookieSecure configuration variable)
-                */
-               set: function ( key, value, options ) {
-                       var date;
+               // The 'options' parameter may be a shortcut for the expiry.
+               if ( arguments.length > 2 && ( !options || options instanceof Date || typeof options === 'number' ) ) {
+                       options = { expires: options };
+               }
+               // Apply defaults
+               options = $.extend( {}, defaults, options );
 
-                       // The 'options' parameter may be a shortcut for the expiry.
-                       if ( arguments.length > 2 && ( !options || options instanceof Date || typeof options === 'number' ) ) {
-                               options = { expires: options };
-                       }
-                       // Apply defaults
-                       options = $.extend( {}, defaults, options );
+               // Handle prefix
+               key = options.prefix + key;
+               // Don't pass invalid option to $.cookie
+               delete options.prefix;
 
-                       // Handle prefix
-                       key = options.prefix + key;
-                       // Don't pass invalid option to $.cookie
-                       delete options.prefix;
+               if ( !options.expires ) {
+                       // Session cookie (null or zero)
+                       // Normalize to absent (undefined) for $.cookie.
+                       delete options.expires;
+               } else if ( typeof options.expires === 'number' ) {
+                       // Lifetime in seconds
+                       date = new Date();
+                       date.setTime( Number( date ) + ( options.expires * 1000 ) );
+                       options.expires = date;
+               }
 
-                       if ( !options.expires ) {
-                               // Session cookie (null or zero)
-                               // Normalize to absent (undefined) for $.cookie.
-                               delete options.expires;
-                       } else if ( typeof options.expires === 'number' ) {
-                               // Lifetime in seconds
-                               date = new Date();
-                               date.setTime( Number( date ) + ( options.expires * 1000 ) );
-                               options.expires = date;
-                       }
+               if ( value !== null ) {
+                       value = String( value );
+               }
 
-                       if ( value !== null ) {
-                               value = String( value );
-                       }
+               $.cookie( key, value, options );
+       },
 
-                       $.cookie( key, value, options );
-               },
+       /**
+        * Get the value of a cookie.
+        *
+        * @param {string} key
+        * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is
+        *   `undefined` or `null`, then `wgCookiePrefix` is used
+        * @param {Mixed} [defaultValue=null]
+        * @return {string|null|Mixed} If the cookie exists, then the value of the
+        *   cookie, otherwise `defaultValue`
+        */
+       get: function ( key, prefix, defaultValue ) {
+               var result;
 
-               /**
-                * Get the value of a cookie.
-                *
-                * @param {string} key
-                * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is
-                *   `undefined` or `null`, then `wgCookiePrefix` is used
-                * @param {Mixed} [defaultValue=null]
-                * @return {string|null|Mixed} If the cookie exists, then the value of the
-                *   cookie, otherwise `defaultValue`
-                */
-               get: function ( key, prefix, defaultValue ) {
-                       var result;
+               if ( prefix === undefined || prefix === null ) {
+                       prefix = defaults.prefix;
+               }
 
-                       if ( prefix === undefined || prefix === null ) {
-                               prefix = defaults.prefix;
-                       }
+               // Was defaultValue omitted?
+               if ( arguments.length < 3 ) {
+                       defaultValue = null;
+               }
 
-                       // Was defaultValue omitted?
-                       if ( arguments.length < 3 ) {
-                               defaultValue = null;
-                       }
+               result = $.cookie( prefix + key );
 
-                       result = $.cookie( prefix + key );
+               return result !== null ? result : defaultValue;
+       }
+};
 
-                       return result !== null ? result : defaultValue;
+if ( window.QUnit ) {
+       module.exports = {
+               setDefaults: function ( value ) {
+                       var prev = defaults;
+                       defaults = value;
+                       return prev;
                }
        };
-
-       if ( window.QUnit ) {
-               module.exports = {
-                       setDefaults: function ( value ) {
-                               var prev = defaults;
-                               defaults = value;
-                               return prev;
-                       }
-               };
-       }
-}() );
+}
diff --git a/resources/src/mediawiki.jqueryMsg/.eslintrc.json b/resources/src/mediawiki.jqueryMsg/.eslintrc.json
new file mode 100644 (file)
index 0000000..ad8dbb3
--- /dev/null
@@ -0,0 +1,5 @@
+{
+       "parserOptions": {
+               "sourceType": "module"
+       }
+}
index 3b89a74..6416612 100644 (file)
 * @author neilk@wikimedia.org
 * @author mflaschen@wikimedia.org
 */
-( function () {
-       /**
-        * @class mw.jqueryMsg
-        * @singleton
-        */
 
-       var oldParser,
-               slice = Array.prototype.slice,
-               parserDefaults = {
-                       // Magic words and their expansions. Server-side data is added to this below.
-                       magic: {
-                               PAGENAME: mw.config.get( 'wgPageName' ),
-                               PAGENAMEE: mw.util.wikiUrlencode( mw.config.get( 'wgPageName' ) )
-                       },
-                       // Whitelist for allowed HTML elements in wikitext.
-                       // Self-closing tags are not currently supported.
-                       // Filled in with server-side data below
-                       allowedHtmlElements: [],
-                       // Key tag name, value allowed attributes for that tag.
-                       // See Sanitizer::setupAttributeWhitelist
-                       allowedHtmlCommonAttributes: [
-                               // HTML
-                               'id',
-                               'class',
-                               'style',
-                               'lang',
-                               'dir',
-                               'title',
-
-                               // WAI-ARIA
-                               'role'
-                       ],
-
-                       // Attributes allowed for specific elements.
-                       // Key is element name in lower case
-                       // Value is array of allowed attributes for that element
-                       allowedHtmlAttributesByElement: {},
-                       messages: mw.messages,
-                       language: mw.language,
-
-                       // Same meaning as in mediawiki.js.
-                       //
-                       // Only 'text', 'parse', and 'escaped' are supported, and the
-                       // actual escaping for 'escaped' is done by other code (generally
-                       // through mediawiki.js).
-                       //
-                       // However, note that this default only
-                       // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
-                       // is 'text', including when it uses jqueryMsg.
-                       format: 'parse'
-               };
-
-       // Add in server-side data (allowedHtmlElements and magic words)
-       $.extend( true, parserDefaults, require( './parserDefaults.json' ) );
+/**
+ * @class mw.jqueryMsg
+ * @singleton
+ */
+
+var oldParser,
+       slice = Array.prototype.slice,
+       parserDefaults = {
+               // Magic words and their expansions. Server-side data is added to this below.
+               magic: {
+                       PAGENAME: mw.config.get( 'wgPageName' ),
+                       PAGENAMEE: mw.util.wikiUrlencode( mw.config.get( 'wgPageName' ) )
+               },
+               // Whitelist for allowed HTML elements in wikitext.
+               // Self-closing tags are not currently supported.
+               // Filled in with server-side data below
+               allowedHtmlElements: [],
+               // Key tag name, value allowed attributes for that tag.
+               // See Sanitizer::setupAttributeWhitelist
+               allowedHtmlCommonAttributes: [
+                       // HTML
+                       'id',
+                       'class',
+                       'style',
+                       'lang',
+                       'dir',
+                       'title',
+
+                       // WAI-ARIA
+                       'role'
+               ],
+
+               // Attributes allowed for specific elements.
+               // Key is element name in lower case
+               // Value is array of allowed attributes for that element
+               allowedHtmlAttributesByElement: {},
+               messages: mw.messages,
+               language: mw.language,
+
+               // Same meaning as in mediawiki.js.
+               //
+               // Only 'text', 'parse', and 'escaped' are supported, and the
+               // actual escaping for 'escaped' is done by other code (generally
+               // through mediawiki.js).
+               //
+               // However, note that this default only
+               // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
+               // is 'text', including when it uses jqueryMsg.
+               format: 'parse'
+       };
 
-       /**
-        * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
-        * convert what it detects as an htmlString to an element.
-        *
-        * If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to
-        * new parent.
-        *
-        * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
-        *
-        * @private
-        * @param {jQuery} $parent Parent node wrapped by jQuery
-        * @param {Object|string|Array} children What to append, with the same possible types as jQuery
-        * @return {jQuery} $parent
-        */
-       function appendWithoutParsing( $parent, children ) {
-               var i, len;
+// Add in server-side data (allowedHtmlElements and magic words)
+$.extend( true, parserDefaults, require( './parserDefaults.json' ) );
+
+/**
+ * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
+ * convert what it detects as an htmlString to an element.
+ *
+ * If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to
+ * new parent.
+ *
+ * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
+ *
+ * @private
+ * @param {jQuery} $parent Parent node wrapped by jQuery
+ * @param {Object|string|Array} children What to append, with the same possible types as jQuery
+ * @return {jQuery} $parent
+ */
+function appendWithoutParsing( $parent, children ) {
+       var i, len;
+
+       if ( !Array.isArray( children ) ) {
+               children = [ children ];
+       }
 
-               if ( !Array.isArray( children ) ) {
-                       children = [ children ];
+       for ( i = 0, len = children.length; i < len; i++ ) {
+               if ( typeof children[ i ] !== 'object' ) {
+                       children[ i ] = document.createTextNode( children[ i ] );
                }
-
-               for ( i = 0, len = children.length; i < len; i++ ) {
-                       if ( typeof children[ i ] !== 'object' ) {
-                               children[ i ] = document.createTextNode( children[ i ] );
-                       }
-                       if ( children[ i ] instanceof $ && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
-                               children[ i ] = children[ i ].contents();
-                       }
+               if ( children[ i ] instanceof $ && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
+                       children[ i ] = children[ i ].contents();
                }
-
-               return $parent.append( children );
        }
 
-       /**
-        * Decodes the main HTML entities, those encoded by mw.html.escape.
-        *
-        * @private
-        * @param {string} encoded Encoded string
-        * @return {string} String with those entities decoded
-        */
-       function decodePrimaryHtmlEntities( encoded ) {
-               return encoded
-                       .replace( /&#039;/g, '\'' )
-                       .replace( /&quot;/g, '"' )
-                       .replace( /&lt;/g, '<' )
-                       .replace( /&gt;/g, '>' )
-                       .replace( /&amp;/g, '&' );
+       return $parent.append( children );
+}
+
+/**
+ * Decodes the main HTML entities, those encoded by mw.html.escape.
+ *
+ * @private
+ * @param {string} encoded Encoded string
+ * @return {string} String with those entities decoded
+ */
+function decodePrimaryHtmlEntities( encoded ) {
+       return encoded
+               .replace( /&#039;/g, '\'' )
+               .replace( /&quot;/g, '"' )
+               .replace( /&lt;/g, '<' )
+               .replace( /&gt;/g, '>' )
+               .replace( /&amp;/g, '&' );
+}
+
+/**
+ * Turn input into a string.
+ *
+ * @private
+ * @param {string|jQuery} input
+ * @return {string} Textual value of input
+ */
+function textify( input ) {
+       if ( input instanceof $ ) {
+               input = input.text();
        }
-
-       /**
-        * Turn input into a string.
-        *
-        * @private
-        * @param {string|jQuery} input
-        * @return {string} Textual value of input
-        */
-       function textify( input ) {
-               if ( input instanceof $ ) {
-                       input = input.text();
+       return String( input );
+}
+
+/**
+ * Given parser options, return a function that parses a key and replacements, returning jQuery object
+ *
+ * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
+ * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
+ * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
+ *
+ * @private
+ * @param {Object} options Parser options
+ * @return {Function}
+ * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
+ * @return {jQuery} return.return
+ */
+function getFailableParserFn( options ) {
+       return function ( args ) {
+               var fallback,
+                       parser = new mw.jqueryMsg.Parser( options ),
+                       key = args[ 0 ],
+                       argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 );
+               try {
+                       return parser.parse( key, argsArray );
+               } catch ( e ) {
+                       fallback = parser.settings.messages.get( key );
+                       mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
+                       mw.track( 'mediawiki.jqueryMsg.error', {
+                               messageKey: key,
+                               errorMessage: e.message
+                       } );
+                       return $( '<span>' ).text( fallback );
                }
-               return String( input );
+       };
+}
+
+mw.jqueryMsg = {};
+
+/**
+ * Initialize parser defaults.
+ *
+ * ResourceLoaderJqueryMsgModule calls this to provide default values from
+ * Sanitizer.php for allowed HTML elements. To override this data for individual
+ * parsers, pass the relevant options to mw.jqueryMsg.Parser.
+ *
+ * @private
+ * @param {Object} data New data to extend parser defaults with
+ * @param {boolean} [deep=false] Whether the extend is done recursively (deep)
+ */
+mw.jqueryMsg.setParserDefaults = function ( data, deep ) {
+       if ( deep ) {
+               $.extend( true, parserDefaults, data );
+       } else {
+               $.extend( parserDefaults, data );
        }
-
-       /**
-        * Given parser options, return a function that parses a key and replacements, returning jQuery object
-        *
-        * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
-        * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
-        * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
-        *
-        * @private
-        * @param {Object} options Parser options
-        * @return {Function}
-        * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
-        * @return {jQuery} return.return
-        */
-       function getFailableParserFn( options ) {
-               return function ( args ) {
-                       var fallback,
-                               parser = new mw.jqueryMsg.Parser( options ),
-                               key = args[ 0 ],
-                               argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 );
-                       try {
-                               return parser.parse( key, argsArray );
-                       } catch ( e ) {
-                               fallback = parser.settings.messages.get( key );
-                               mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
-                               mw.track( 'mediawiki.jqueryMsg.error', {
-                                       messageKey: key,
-                                       errorMessage: e.message
-                               } );
-                               return $( '<span>' ).text( fallback );
-                       }
-               };
+};
+
+/**
+ * Get current parser defaults.
+ *
+ * Primarily used for the unit test. Returns a copy.
+ *
+ * @private
+ * @return {Object}
+ */
+mw.jqueryMsg.getParserDefaults = function () {
+       return $.extend( {}, parserDefaults );
+};
+
+/**
+ * Returns a function suitable for static use, to construct strings from a message key (and optional replacements).
+ *
+ * Example:
+ *
+ *       var format = mediaWiki.jqueryMsg.getMessageFunction( options );
+ *       $( '#example' ).text( format( 'hello-user', username ) );
+ *
+ * Tthis returns only strings, so it destroys any bindings. If you want to preserve bindings, use the
+ * jQuery plugin version instead. This was originally created to ease migration from `window.gM()`,
+ * from a time when the parser used by `mw.message` was not extendable.
+ *
+ * N.B. replacements are variadic arguments or an array in second parameter. In other words:
+ *    somefunction( a, b, c, d )
+ * is equivalent to
+ *    somefunction( a, [b, c, d] )
+ *
+ * @param {Object} options parser options
+ * @return {Function} Function The message formatter
+ * @return {string} return.key Message key.
+ * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
+ * @return {string} return.return Rendered HTML.
+ */
+mw.jqueryMsg.getMessageFunction = function ( options ) {
+       var failableParserFn, format;
+
+       if ( options && options.format !== undefined ) {
+               format = options.format;
+       } else {
+               format = parserDefaults.format;
        }
 
-       mw.jqueryMsg = {};
-
-       /**
-        * Initialize parser defaults.
-        *
-        * ResourceLoaderJqueryMsgModule calls this to provide default values from
-        * Sanitizer.php for allowed HTML elements. To override this data for individual
-        * parsers, pass the relevant options to mw.jqueryMsg.Parser.
-        *
-        * @private
-        * @param {Object} data New data to extend parser defaults with
-        * @param {boolean} [deep=false] Whether the extend is done recursively (deep)
-        */
-       mw.jqueryMsg.setParserDefaults = function ( data, deep ) {
-               if ( deep ) {
-                       $.extend( true, parserDefaults, data );
+       return function () {
+               var failableResult;
+               if ( !failableParserFn ) {
+                       failableParserFn = getFailableParserFn( options );
+               }
+               failableResult = failableParserFn( arguments );
+               if ( format === 'text' || format === 'escaped' ) {
+                       return failableResult.text();
                } else {
-                       $.extend( parserDefaults, data );
+                       return failableResult.html();
                }
        };
-
+};
+
+/**
+ * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
+ * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
+ * e.g.
+ *
+ *        $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options );
+ *        var $userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
+ *        $( 'p#headline' ).msg( 'hello-user', $userlink );
+ *
+ * N.B. replacements are variadic arguments or an array in second parameter. In other words:
+ *    somefunction( a, b, c, d )
+ * is equivalent to
+ *    somefunction( a, [b, c, d] )
+ *
+ * We append to 'this', which in a jQuery plugin context will be the selected elements.
+ *
+ * @param {Object} options Parser options
+ * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
+ * @return {string} return.key Message key.
+ * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
+ * @return {jQuery} return.return
+ */
+mw.jqueryMsg.getPlugin = function ( options ) {
+       var failableParserFn;
+
+       return function () {
+               var $target;
+               if ( !failableParserFn ) {
+                       failableParserFn = getFailableParserFn( options );
+               }
+               $target = this.empty();
+               appendWithoutParsing( $target, failableParserFn( arguments ) );
+               return $target;
+       };
+};
+
+/**
+ * The parser itself.
+ * Describes an object, whose primary duty is to .parse() message keys.
+ *
+ * @class
+ * @private
+ * @param {Object} options
+ */
+mw.jqueryMsg.Parser = function ( options ) {
+       this.settings = $.extend( {}, parserDefaults, options );
+       this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
+       this.astCache = {};
+
+       this.emitter = new mw.jqueryMsg.HtmlEmitter( this.settings.language, this.settings.magic );
+};
+// Backwards-compatible alias
+// @deprecated since 1.31
+mw.jqueryMsg.parser = mw.jqueryMsg.Parser;
+
+mw.jqueryMsg.Parser.prototype = {
        /**
-        * Get current parser defaults.
-        *
-        * Primarily used for the unit test. Returns a copy.
+        * Where the magic happens.
+        * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
+        * If an error is thrown, returns original key, and logs the error
         *
-        * @private
-        * @return {Object}
+        * @param {string} key Message key.
+        * @param {Array} replacements Variable replacements for $1, $2... $n
+        * @return {jQuery}
         */
-       mw.jqueryMsg.getParserDefaults = function () {
-               return $.extend( {}, parserDefaults );
-       };
+       parse: function ( key, replacements ) {
+               var ast = this.getAst( key, replacements );
+               return this.emitter.emit( ast, replacements );
+       },
 
        /**
-        * Returns a function suitable for static use, to construct strings from a message key (and optional replacements).
-        *
-        * Example:
-        *
-        *       var format = mediaWiki.jqueryMsg.getMessageFunction( options );
-        *       $( '#example' ).text( format( 'hello-user', username ) );
-        *
-        * Tthis returns only strings, so it destroys any bindings. If you want to preserve bindings, use the
-        * jQuery plugin version instead. This was originally created to ease migration from `window.gM()`,
-        * from a time when the parser used by `mw.message` was not extendable.
+        * Fetch the message string associated with a key, return parsed structure. Memoized.
+        * Note that we pass '⧼' + key + '⧽' back for a missing message here.
         *
-        * N.B. replacements are variadic arguments or an array in second parameter. In other words:
-        *    somefunction( a, b, c, d )
-        * is equivalent to
-        *    somefunction( a, [b, c, d] )
-        *
-        * @param {Object} options parser options
-        * @return {Function} Function The message formatter
-        * @return {string} return.key Message key.
-        * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
-        * @return {string} return.return Rendered HTML.
+        * @param {string} key
+        * @param {Array} replacements Variable replacements for $1, $2... $n
+        * @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing
         */
-       mw.jqueryMsg.getMessageFunction = function ( options ) {
-               var failableParserFn, format;
-
-               if ( options && options.format !== undefined ) {
-                       format = options.format;
-               } else {
-                       format = parserDefaults.format;
-               }
+       getAst: function ( key, replacements ) {
+               var wikiText;
 
-               return function () {
-                       var failableResult;
-                       if ( !failableParserFn ) {
-                               failableParserFn = getFailableParserFn( options );
-                       }
-                       failableResult = failableParserFn( arguments );
-                       if ( format === 'text' || format === 'escaped' ) {
-                               return failableResult.text();
+               if ( !Object.prototype.hasOwnProperty.call( this.astCache, key ) ) {
+                       if ( mw.config.get( 'wgUserLanguage' ) === 'qqx' ) {
+                               wikiText = '(' + key + '$*)';
                        } else {
-                               return failableResult.html();
+                               wikiText = this.settings.messages.get( key );
+                               if ( typeof wikiText !== 'string' ) {
+                                       wikiText = '⧼' + key + '⧽';
+                               }
                        }
-               };
-       };
+                       wikiText = mw.internalDoTransformFormatForQqx( wikiText, replacements );
+                       this.astCache[ key ] = this.wikiTextToAst( wikiText );
+               }
+               return this.astCache[ key ];
+       },
 
        /**
-        * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
-        * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
-        * e.g.
-        *
-        *        $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options );
-        *        var $userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
-        *        $( 'p#headline' ).msg( 'hello-user', $userlink );
-        *
-        * N.B. replacements are variadic arguments or an array in second parameter. In other words:
-        *    somefunction( a, b, c, d )
-        * is equivalent to
-        *    somefunction( a, [b, c, d] )
+        * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
         *
-        * We append to 'this', which in a jQuery plugin context will be the selected elements.
-        *
-        * @param {Object} options Parser options
-        * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
-        * @return {string} return.key Message key.
-        * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
-        * @return {jQuery} return.return
-        */
-       mw.jqueryMsg.getPlugin = function ( options ) {
-               var failableParserFn;
-
-               return function () {
-                       var $target;
-                       if ( !failableParserFn ) {
-                               failableParserFn = getFailableParserFn( options );
-                       }
-                       $target = this.empty();
-                       appendWithoutParsing( $target, failableParserFn( arguments ) );
-                       return $target;
-               };
-       };
-
-       /**
-        * The parser itself.
-        * Describes an object, whose primary duty is to .parse() message keys.
+        * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
+        * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
         *
-        * @class
-        * @private
-        * @param {Object} options
+        * @param {string} input Message string wikitext
+        * @throws Error
+        * @return {Mixed} abstract syntax tree
         */
-       mw.jqueryMsg.Parser = function ( options ) {
-               this.settings = $.extend( {}, parserDefaults, options );
-               this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
-               this.astCache = {};
-
-               this.emitter = new mw.jqueryMsg.HtmlEmitter( this.settings.language, this.settings.magic );
-       };
-       // Backwards-compatible alias
-       // @deprecated since 1.31
-       mw.jqueryMsg.parser = mw.jqueryMsg.Parser;
+       wikiTextToAst: function ( input ) {
+               var pos,
+                       regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
+                       doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
+                       escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
+                       whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
+                       htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
+                       openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
+                       templateContents, openTemplate, closeTemplate,
+                       nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result,
+                       settings = this.settings,
+                       concat = Array.prototype.concat;
+
+               // Indicates current position in input as we parse through it.
+               // Shared among all parsing functions below.
+               pos = 0;
+
+               // =========================================================
+               // parsing combinators - could be a library on its own
+               // =========================================================
 
-       mw.jqueryMsg.Parser.prototype = {
                /**
-                * Where the magic happens.
-                * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
-                * If an error is thrown, returns original key, and logs the error
+                * Try parsers until one works, if none work return null
                 *
-                * @param {string} key Message key.
-                * @param {Array} replacements Variable replacements for $1, $2... $n
-                * @return {jQuery}
+                * @private
+                * @param {Function[]} ps
+                * @return {string|null}
                 */
-               parse: function ( key, replacements ) {
-                       var ast = this.getAst( key, replacements );
-                       return this.emitter.emit( ast, replacements );
-               },
+               function choice( ps ) {
+                       return function () {
+                               var i, result;
+                               for ( i = 0; i < ps.length; i++ ) {
+                                       result = ps[ i ]();
+                                       if ( result !== null ) {
+                                               return result;
+                                       }
+                               }
+                               return null;
+                       };
+               }
 
                /**
-                * Fetch the message string associated with a key, return parsed structure. Memoized.
-                * Note that we pass '⧼' + key + '⧽' back for a missing message here.
+                * Try several ps in a row, all must succeed or return null.
+                * This is the only eager one.
                 *
-                * @param {string} key
-                * @param {Array} replacements Variable replacements for $1, $2... $n
-                * @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing
+                * @private
+                * @param {Function[]} ps
+                * @return {string|null}
                 */
-               getAst: function ( key, replacements ) {
-                       var wikiText;
-
-                       if ( !Object.prototype.hasOwnProperty.call( this.astCache, key ) ) {
-                               if ( mw.config.get( 'wgUserLanguage' ) === 'qqx' ) {
-                                       wikiText = '(' + key + '$*)';
-                               } else {
-                                       wikiText = this.settings.messages.get( key );
-                                       if ( typeof wikiText !== 'string' ) {
-                                               wikiText = '⧼' + key + '⧽';
-                                       }
+               function sequence( ps ) {
+                       var i, res,
+                               originalPos = pos,
+                               result = [];
+                       for ( i = 0; i < ps.length; i++ ) {
+                               res = ps[ i ]();
+                               if ( res === null ) {
+                                       pos = originalPos;
+                                       return null;
                                }
-                               wikiText = mw.internalDoTransformFormatForQqx( wikiText, replacements );
-                               this.astCache[ key ] = this.wikiTextToAst( wikiText );
+                               result.push( res );
                        }
-                       return this.astCache[ key ];
-               },
+                       return result;
+               }
 
                /**
-                * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
-                *
-                * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
-                * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
+                * Run the same parser over and over until it fails.
+                * Must succeed a minimum of n times or return null.
                 *
-                * @param {string} input Message string wikitext
-                * @throws Error
-                * @return {Mixed} abstract syntax tree
+                * @private
+                * @param {number} n
+                * @param {Function} p
+                * @return {string|null}
                 */
-               wikiTextToAst: function ( input ) {
-                       var pos,
-                               regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
-                               doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
-                               escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
-                               whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
-                               htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
-                               openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
-                               templateContents, openTemplate, closeTemplate,
-                               nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result,
-                               settings = this.settings,
-                               concat = Array.prototype.concat;
-
-                       // Indicates current position in input as we parse through it.
-                       // Shared among all parsing functions below.
-                       pos = 0;
-
-                       // =========================================================
-                       // parsing combinators - could be a library on its own
-                       // =========================================================
-
-                       /**
-                        * Try parsers until one works, if none work return null
-                        *
-                        * @private
-                        * @param {Function[]} ps
-                        * @return {string|null}
-                        */
-                       function choice( ps ) {
-                               return function () {
-                                       var i, result;
-                                       for ( i = 0; i < ps.length; i++ ) {
-                                               result = ps[ i ]();
-                                               if ( result !== null ) {
-                                                       return result;
-                                               }
-                                       }
+               function nOrMore( n, p ) {
+                       return function () {
+                               var originalPos = pos,
+                                       result = [],
+                                       parsed = p();
+                               while ( parsed !== null ) {
+                                       result.push( parsed );
+                                       parsed = p();
+                               }
+                               if ( result.length < n ) {
+                                       pos = originalPos;
                                        return null;
-                               };
-                       }
-
-                       /**
-                        * Try several ps in a row, all must succeed or return null.
-                        * This is the only eager one.
-                        *
-                        * @private
-                        * @param {Function[]} ps
-                        * @return {string|null}
-                        */
-                       function sequence( ps ) {
-                               var i, res,
-                                       originalPos = pos,
-                                       result = [];
-                               for ( i = 0; i < ps.length; i++ ) {
-                                       res = ps[ i ]();
-                                       if ( res === null ) {
-                                               pos = originalPos;
-                                               return null;
-                                       }
-                                       result.push( res );
                                }
                                return result;
-                       }
-
-                       /**
-                        * Run the same parser over and over until it fails.
-                        * Must succeed a minimum of n times or return null.
-                        *
-                        * @private
-                        * @param {number} n
-                        * @param {Function} p
-                        * @return {string|null}
-                        */
-                       function nOrMore( n, p ) {
-                               return function () {
-                                       var originalPos = pos,
-                                               result = [],
-                                               parsed = p();
-                                       while ( parsed !== null ) {
-                                               result.push( parsed );
-                                               parsed = p();
-                                       }
-                                       if ( result.length < n ) {
-                                               pos = originalPos;
-                                               return null;
-                                       }
-                                       return result;
-                               };
-                       }
+                       };
+               }
 
-                       /**
-                        * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
-                        *
-                        * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
-                        * May be some scoping issue
-                        *
-                        * @private
-                        * @param {Function} p
-                        * @param {Function} fn
-                        * @return {string|null}
-                        */
-                       function transform( p, fn ) {
-                               return function () {
-                                       var result = p();
-                                       return result === null ? null : fn( result );
-                               };
-                       }
+               /**
+                * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
+                *
+                * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
+                * May be some scoping issue
+                *
+                * @private
+                * @param {Function} p
+                * @param {Function} fn
+                * @return {string|null}
+                */
+               function transform( p, fn ) {
+                       return function () {
+                               var result = p();
+                               return result === null ? null : fn( result );
+                       };
+               }
 
-                       /**
-                        * Just make parsers out of simpler JS builtin types
-                        *
-                        * @private
-                        * @param {string} s
-                        * @return {Function}
-                        * @return {string} return.return
-                        */
-                       function makeStringParser( s ) {
-                               var len = s.length;
-                               return function () {
-                                       var result = null;
-                                       if ( input.substr( pos, len ) === s ) {
-                                               result = s;
-                                               pos += len;
-                                       }
-                                       return result;
-                               };
-                       }
+               /**
+                * Just make parsers out of simpler JS builtin types
+                *
+                * @private
+                * @param {string} s
+                * @return {Function}
+                * @return {string} return.return
+                */
+               function makeStringParser( s ) {
+                       var len = s.length;
+                       return function () {
+                               var result = null;
+                               if ( input.substr( pos, len ) === s ) {
+                                       result = s;
+                                       pos += len;
+                               }
+                               return result;
+                       };
+               }
 
-                       /**
-                        * Makes a regex parser, given a RegExp object.
-                        * The regex being passed in should start with a ^ to anchor it to the start
-                        * of the string.
-                        *
-                        * @private
-                        * @param {RegExp} regex anchored regex
-                        * @return {Function} function to parse input based on the regex
-                        */
-                       function makeRegexParser( regex ) {
-                               return function () {
-                                       var matches = input.slice( pos ).match( regex );
-                                       if ( matches === null ) {
-                                               return null;
-                                       }
-                                       pos += matches[ 0 ].length;
-                                       return matches[ 0 ];
-                               };
-                       }
+               /**
+                * Makes a regex parser, given a RegExp object.
+                * The regex being passed in should start with a ^ to anchor it to the start
+                * of the string.
+                *
+                * @private
+                * @param {RegExp} regex anchored regex
+                * @return {Function} function to parse input based on the regex
+                */
+               function makeRegexParser( regex ) {
+                       return function () {
+                               var matches = input.slice( pos ).match( regex );
+                               if ( matches === null ) {
+                                       return null;
+                               }
+                               pos += matches[ 0 ].length;
+                               return matches[ 0 ];
+                       };
+               }
 
-                       // ===================================================================
-                       // General patterns above this line -- wikitext specific parsers below
-                       // ===================================================================
-
-                       // Parsing functions follow. All parsing functions work like this:
-                       // They don't accept any arguments.
-                       // Instead, they just operate non destructively on the string 'input'
-                       // As they can consume parts of the string, they advance the shared variable pos,
-                       // and return tokens (or whatever else they want to return).
-                       // some things are defined as closures and other things as ordinary functions
-                       // converting everything to a closure makes it a lot harder to debug... errors pop up
-                       // but some debuggers can't tell you exactly where they come from. Also the mutually
-                       // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
-                       // This may be because, to save code, memoization was removed
-
-                       /* eslint-disable no-useless-escape */
-                       regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
-                       regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
-                       regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
-                       regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
-                       /* eslint-enable no-useless-escape */
-
-                       backslash = makeStringParser( '\\' );
-                       doubleQuote = makeStringParser( '"' );
-                       singleQuote = makeStringParser( '\'' );
-                       anyCharacter = makeRegexParser( /^./ );
-
-                       openHtmlStartTag = makeStringParser( '<' );
-                       optionalForwardSlash = makeRegexParser( /^\/?/ );
-                       openHtmlEndTag = makeStringParser( '</' );
-                       htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
-                       closeHtmlTag = makeRegexParser( /^\s*>/ );
-
-                       function escapedLiteral() {
-                               var result = sequence( [
-                                       backslash,
-                                       anyCharacter
-                               ] );
-                               return result === null ? null : result[ 1 ];
-                       }
-                       escapedOrLiteralWithoutSpace = choice( [
-                               escapedLiteral,
-                               regularLiteralWithoutSpace
-                       ] );
-                       escapedOrLiteralWithoutBar = choice( [
-                               escapedLiteral,
-                               regularLiteralWithoutBar
-                       ] );
-                       escapedOrRegularLiteral = choice( [
-                               escapedLiteral,
-                               regularLiteral
+               // ===================================================================
+               // General patterns above this line -- wikitext specific parsers below
+               // ===================================================================
+
+               // Parsing functions follow. All parsing functions work like this:
+               // They don't accept any arguments.
+               // Instead, they just operate non destructively on the string 'input'
+               // As they can consume parts of the string, they advance the shared variable pos,
+               // and return tokens (or whatever else they want to return).
+               // some things are defined as closures and other things as ordinary functions
+               // converting everything to a closure makes it a lot harder to debug... errors pop up
+               // but some debuggers can't tell you exactly where they come from. Also the mutually
+               // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
+               // This may be because, to save code, memoization was removed
+
+               /* eslint-disable no-useless-escape */
+               regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
+               regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
+               regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
+               regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
+               /* eslint-enable no-useless-escape */
+
+               backslash = makeStringParser( '\\' );
+               doubleQuote = makeStringParser( '"' );
+               singleQuote = makeStringParser( '\'' );
+               anyCharacter = makeRegexParser( /^./ );
+
+               openHtmlStartTag = makeStringParser( '<' );
+               optionalForwardSlash = makeRegexParser( /^\/?/ );
+               openHtmlEndTag = makeStringParser( '</' );
+               htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
+               closeHtmlTag = makeRegexParser( /^\s*>/ );
+
+               function escapedLiteral() {
+                       var result = sequence( [
+                               backslash,
+                               anyCharacter
                        ] );
-                       // Used to define "literals" without spaces, in space-delimited situations
-                       function literalWithoutSpace() {
-                               var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
-                               return result === null ? null : result.join( '' );
-                       }
-                       // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
-                       // it is not a literal in the parameter
-                       function literalWithoutBar() {
-                               var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
-                               return result === null ? null : result.join( '' );
-                       }
+                       return result === null ? null : result[ 1 ];
+               }
+               escapedOrLiteralWithoutSpace = choice( [
+                       escapedLiteral,
+                       regularLiteralWithoutSpace
+               ] );
+               escapedOrLiteralWithoutBar = choice( [
+                       escapedLiteral,
+                       regularLiteralWithoutBar
+               ] );
+               escapedOrRegularLiteral = choice( [
+                       escapedLiteral,
+                       regularLiteral
+               ] );
+               // Used to define "literals" without spaces, in space-delimited situations
+               function literalWithoutSpace() {
+                       var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
+                       return result === null ? null : result.join( '' );
+               }
+               // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
+               // it is not a literal in the parameter
+               function literalWithoutBar() {
+                       var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
+                       return result === null ? null : result.join( '' );
+               }
 
-                       function literal() {
-                               var result = nOrMore( 1, escapedOrRegularLiteral )();
-                               return result === null ? null : result.join( '' );
-                       }
+               function literal() {
+                       var result = nOrMore( 1, escapedOrRegularLiteral )();
+                       return result === null ? null : result.join( '' );
+               }
 
-                       function curlyBraceTransformExpressionLiteral() {
-                               var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
-                               return result === null ? null : result.join( '' );
-                       }
+               function curlyBraceTransformExpressionLiteral() {
+                       var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
+                       return result === null ? null : result.join( '' );
+               }
 
-                       asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
-                       htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
-                       htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
+               asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
+               htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
+               htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
 
-                       whitespace = makeRegexParser( /^\s+/ );
-                       dollar = makeStringParser( '$' );
-                       digits = makeRegexParser( /^\d+/ );
+               whitespace = makeRegexParser( /^\s+/ );
+               dollar = makeStringParser( '$' );
+               digits = makeRegexParser( /^\d+/ );
 
-                       function replacement() {
-                               var result = sequence( [
-                                       dollar,
-                                       digits
-                               ] );
-                               if ( result === null ) {
-                                       return null;
-                               }
-                               return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
-                       }
-                       openExtlink = makeStringParser( '[' );
-                       closeExtlink = makeStringParser( ']' );
-                       // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
-                       function extlink() {
-                               var result, parsedResult, target;
-                               result = null;
-                               parsedResult = sequence( [
-                                       openExtlink,
-                                       nOrMore( 1, nonWhitespaceExpression ),
-                                       whitespace,
-                                       nOrMore( 1, expression ),
-                                       closeExtlink
-                               ] );
-                               if ( parsedResult !== null ) {
-                                       // When the entire link target is a single parameter, we can't use CONCAT, as we allow
-                                       // passing fancy parameters (like a whole jQuery object or a function) to use for the
-                                       // link. Check only if it's a single match, since we can either do CONCAT or not for
-                                       // singles with the same effect.
-                                       target = parsedResult[ 1 ].length === 1 ?
-                                               parsedResult[ 1 ][ 0 ] :
-                                               [ 'CONCAT' ].concat( parsedResult[ 1 ] );
-                                       result = [
-                                               'EXTLINK',
-                                               target,
-                                               [ 'CONCAT' ].concat( parsedResult[ 3 ] )
-                                       ];
-                               }
-                               return result;
-                       }
-                       openWikilink = makeStringParser( '[[' );
-                       closeWikilink = makeStringParser( ']]' );
-                       pipe = makeStringParser( '|' );
-
-                       function template() {
-                               var result = sequence( [
-                                       openTemplate,
-                                       templateContents,
-                                       closeTemplate
-                               ] );
-                               return result === null ? null : result[ 1 ];
+               function replacement() {
+                       var result = sequence( [
+                               dollar,
+                               digits
+                       ] );
+                       if ( result === null ) {
+                               return null;
                        }
-
-                       function pipedWikilink() {
-                               var result = sequence( [
-                                       nOrMore( 1, paramExpression ),
-                                       pipe,
-                                       nOrMore( 1, expression )
-                               ] );
-                               return result === null ? null : [
-                                       [ 'CONCAT' ].concat( result[ 0 ] ),
-                                       [ 'CONCAT' ].concat( result[ 2 ] )
+                       return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
+               }
+               openExtlink = makeStringParser( '[' );
+               closeExtlink = makeStringParser( ']' );
+               // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
+               function extlink() {
+                       var result, parsedResult, target;
+                       result = null;
+                       parsedResult = sequence( [
+                               openExtlink,
+                               nOrMore( 1, nonWhitespaceExpression ),
+                               whitespace,
+                               nOrMore( 1, expression ),
+                               closeExtlink
+                       ] );
+                       if ( parsedResult !== null ) {
+                               // When the entire link target is a single parameter, we can't use CONCAT, as we allow
+                               // passing fancy parameters (like a whole jQuery object or a function) to use for the
+                               // link. Check only if it's a single match, since we can either do CONCAT or not for
+                               // singles with the same effect.
+                               target = parsedResult[ 1 ].length === 1 ?
+                                       parsedResult[ 1 ][ 0 ] :
+                                       [ 'CONCAT' ].concat( parsedResult[ 1 ] );
+                               result = [
+                                       'EXTLINK',
+                                       target,
+                                       [ 'CONCAT' ].concat( parsedResult[ 3 ] )
                                ];
                        }
+                       return result;
+               }
+               openWikilink = makeStringParser( '[[' );
+               closeWikilink = makeStringParser( ']]' );
+               pipe = makeStringParser( '|' );
+
+               function template() {
+                       var result = sequence( [
+                               openTemplate,
+                               templateContents,
+                               closeTemplate
+                       ] );
+                       return result === null ? null : result[ 1 ];
+               }
 
-                       function unpipedWikilink() {
-                               var result = sequence( [
-                                       nOrMore( 1, paramExpression )
-                               ] );
-                               return result === null ? null : [
-                                       [ 'CONCAT' ].concat( result[ 0 ] )
-                               ];
-                       }
+               function pipedWikilink() {
+                       var result = sequence( [
+                               nOrMore( 1, paramExpression ),
+                               pipe,
+                               nOrMore( 1, expression )
+                       ] );
+                       return result === null ? null : [
+                               [ 'CONCAT' ].concat( result[ 0 ] ),
+                               [ 'CONCAT' ].concat( result[ 2 ] )
+                       ];
+               }
 
-                       wikilinkContents = choice( [
-                               pipedWikilink,
-                               unpipedWikilink
+               function unpipedWikilink() {
+                       var result = sequence( [
+                               nOrMore( 1, paramExpression )
                        ] );
+                       return result === null ? null : [
+                               [ 'CONCAT' ].concat( result[ 0 ] )
+                       ];
+               }
 
-                       function wikilink() {
-                               var result, parsedResult, parsedLinkContents;
-                               result = null;
+               wikilinkContents = choice( [
+                       pipedWikilink,
+                       unpipedWikilink
+               ] );
 
-                               parsedResult = sequence( [
-                                       openWikilink,
-                                       wikilinkContents,
-                                       closeWikilink
-                               ] );
-                               if ( parsedResult !== null ) {
-                                       parsedLinkContents = parsedResult[ 1 ];
-                                       result = [ 'WIKILINK' ].concat( parsedLinkContents );
-                               }
-                               return result;
-                       }
+               function wikilink() {
+                       var result, parsedResult, parsedLinkContents;
+                       result = null;
 
-                       // TODO: Support data- if appropriate
-                       function doubleQuotedHtmlAttributeValue() {
-                               var parsedResult = sequence( [
-                                       doubleQuote,
-                                       htmlDoubleQuoteAttributeValue,
-                                       doubleQuote
-                               ] );
-                               return parsedResult === null ? null : parsedResult[ 1 ];
+                       parsedResult = sequence( [
+                               openWikilink,
+                               wikilinkContents,
+                               closeWikilink
+                       ] );
+                       if ( parsedResult !== null ) {
+                               parsedLinkContents = parsedResult[ 1 ];
+                               result = [ 'WIKILINK' ].concat( parsedLinkContents );
                        }
+                       return result;
+               }
 
-                       function singleQuotedHtmlAttributeValue() {
-                               var parsedResult = sequence( [
-                                       singleQuote,
-                                       htmlSingleQuoteAttributeValue,
-                                       singleQuote
-                               ] );
-                               return parsedResult === null ? null : parsedResult[ 1 ];
-                       }
+               // TODO: Support data- if appropriate
+               function doubleQuotedHtmlAttributeValue() {
+                       var parsedResult = sequence( [
+                               doubleQuote,
+                               htmlDoubleQuoteAttributeValue,
+                               doubleQuote
+                       ] );
+                       return parsedResult === null ? null : parsedResult[ 1 ];
+               }
 
-                       function htmlAttribute() {
-                               var parsedResult = sequence( [
-                                       whitespace,
-                                       asciiAlphabetLiteral,
-                                       htmlAttributeEquals,
-                                       choice( [
-                                               doubleQuotedHtmlAttributeValue,
-                                               singleQuotedHtmlAttributeValue
-                                       ] )
-                               ] );
-                               return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
-                       }
+               function singleQuotedHtmlAttributeValue() {
+                       var parsedResult = sequence( [
+                               singleQuote,
+                               htmlSingleQuoteAttributeValue,
+                               singleQuote
+                       ] );
+                       return parsedResult === null ? null : parsedResult[ 1 ];
+               }
 
-                       /**
-                        * Checks if HTML is allowed
-                        *
-                        * @param {string} startTagName HTML start tag name
-                        * @param {string} endTagName HTML start tag name
-                        * @param {Object} attributes array of consecutive key value pairs,
-                        *  with index 2 * n being a name and 2 * n + 1 the associated value
-                        * @return {boolean} true if this is HTML is allowed, false otherwise
-                        */
-                       function isAllowedHtml( startTagName, endTagName, attributes ) {
-                               var i, len, attributeName;
-
-                               startTagName = startTagName.toLowerCase();
-                               endTagName = endTagName.toLowerCase();
-                               if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) {
-                                       return false;
-                               }
+               function htmlAttribute() {
+                       var parsedResult = sequence( [
+                               whitespace,
+                               asciiAlphabetLiteral,
+                               htmlAttributeEquals,
+                               choice( [
+                                       doubleQuotedHtmlAttributeValue,
+                                       singleQuotedHtmlAttributeValue
+                               ] )
+                       ] );
+                       return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
+               }
 
-                               for ( i = 0, len = attributes.length; i < len; i += 2 ) {
-                                       attributeName = attributes[ i ];
-                                       if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 &&
-                                               ( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) {
-                                               return false;
-                                       }
-                               }
+               /**
+                * Checks if HTML is allowed
+                *
+                * @param {string} startTagName HTML start tag name
+                * @param {string} endTagName HTML start tag name
+                * @param {Object} attributes array of consecutive key value pairs,
+                *  with index 2 * n being a name and 2 * n + 1 the associated value
+                * @return {boolean} true if this is HTML is allowed, false otherwise
+                */
+               function isAllowedHtml( startTagName, endTagName, attributes ) {
+                       var i, len, attributeName;
 
-                               return true;
+                       startTagName = startTagName.toLowerCase();
+                       endTagName = endTagName.toLowerCase();
+                       if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) {
+                               return false;
                        }
 
-                       function htmlAttributes() {
-                               var parsedResult = nOrMore( 0, htmlAttribute )();
-                               // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
-                               return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult );
+                       for ( i = 0, len = attributes.length; i < len; i += 2 ) {
+                               attributeName = attributes[ i ];
+                               if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 &&
+                                       ( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) {
+                                       return false;
+                               }
                        }
 
-                       // Subset of allowed HTML markup.
-                       // Most elements and many attributes allowed on the server are not supported yet.
-                       function html() {
-                               var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult,
-                                       wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos,
-                                       startCloseTagPos, endOpenTagPos, endCloseTagPos,
-                                       result = null;
-
-                               // Break into three sequence calls.  That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
-                               // 1. open through closeHtmlTag
-                               // 2. expression
-                               // 3. openHtmlEnd through close
-                               // This will allow recording the positions to reconstruct if HTML is to be treated as text.
-
-                               startOpenTagPos = pos;
-                               parsedOpenTagResult = sequence( [
-                                       openHtmlStartTag,
-                                       asciiAlphabetLiteral,
-                                       htmlAttributes,
-                                       optionalForwardSlash,
-                                       closeHtmlTag
-                               ] );
+                       return true;
+               }
 
-                               if ( parsedOpenTagResult === null ) {
-                                       return null;
-                               }
+               function htmlAttributes() {
+                       var parsedResult = nOrMore( 0, htmlAttribute )();
+                       // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
+                       return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult );
+               }
 
-                               endOpenTagPos = pos;
-                               startTagName = parsedOpenTagResult[ 1 ];
+               // Subset of allowed HTML markup.
+               // Most elements and many attributes allowed on the server are not supported yet.
+               function html() {
+                       var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult,
+                               wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos,
+                               startCloseTagPos, endOpenTagPos, endCloseTagPos,
+                               result = null;
 
-                               parsedHtmlContents = nOrMore( 0, expression )();
+                       // Break into three sequence calls.  That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
+                       // 1. open through closeHtmlTag
+                       // 2. expression
+                       // 3. openHtmlEnd through close
+                       // This will allow recording the positions to reconstruct if HTML is to be treated as text.
+
+                       startOpenTagPos = pos;
+                       parsedOpenTagResult = sequence( [
+                               openHtmlStartTag,
+                               asciiAlphabetLiteral,
+                               htmlAttributes,
+                               optionalForwardSlash,
+                               closeHtmlTag
+                       ] );
 
-                               startCloseTagPos = pos;
-                               parsedCloseTagResult = sequence( [
-                                       openHtmlEndTag,
-                                       asciiAlphabetLiteral,
-                                       closeHtmlTag
-                               ] );
+                       if ( parsedOpenTagResult === null ) {
+                               return null;
+                       }
 
-                               if ( parsedCloseTagResult === null ) {
-                                       // Closing tag failed.  Return the start tag and contents.
-                                       return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
-                                               .concat( parsedHtmlContents );
-                               }
+                       endOpenTagPos = pos;
+                       startTagName = parsedOpenTagResult[ 1 ];
 
-                               endCloseTagPos = pos;
-                               endTagName = parsedCloseTagResult[ 1 ];
-                               wrappedAttributes = parsedOpenTagResult[ 2 ];
-                               attributes = wrappedAttributes.slice( 1 );
-                               if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
-                                       result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
-                                               .concat( parsedHtmlContents );
-                               } else {
-                                       // HTML is not allowed, so contents will remain how
-                                       // it was, while HTML markup at this level will be
-                                       // treated as text
-                                       // E.g. assuming script tags are not allowed:
-                                       //
-                                       // <script>[[Foo|bar]]</script>
-                                       //
-                                       // results in '&lt;script&gt;' and '&lt;/script&gt;'
-                                       // (not treated as an HTML tag), surrounding a fully
-                                       // parsed HTML link.
-                                       //
-                                       // Concatenate everything from the tag, flattening the contents.
-                                       result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
-                                               .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
-                               }
+                       parsedHtmlContents = nOrMore( 0, expression )();
 
-                               return result;
+                       startCloseTagPos = pos;
+                       parsedCloseTagResult = sequence( [
+                               openHtmlEndTag,
+                               asciiAlphabetLiteral,
+                               closeHtmlTag
+                       ] );
+
+                       if ( parsedCloseTagResult === null ) {
+                               // Closing tag failed.  Return the start tag and contents.
+                               return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
+                                       .concat( parsedHtmlContents );
                        }
 
-                       // <nowiki>...</nowiki> tag. The tags are stripped and the contents are returned unparsed.
-                       function nowiki() {
-                               var parsedResult, plainText,
-                                       result = null;
+                       endCloseTagPos = pos;
+                       endTagName = parsedCloseTagResult[ 1 ];
+                       wrappedAttributes = parsedOpenTagResult[ 2 ];
+                       attributes = wrappedAttributes.slice( 1 );
+                       if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
+                               result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
+                                       .concat( parsedHtmlContents );
+                       } else {
+                               // HTML is not allowed, so contents will remain how
+                               // it was, while HTML markup at this level will be
+                               // treated as text
+                               // E.g. assuming script tags are not allowed:
+                               //
+                               // <script>[[Foo|bar]]</script>
+                               //
+                               // results in '&lt;script&gt;' and '&lt;/script&gt;'
+                               // (not treated as an HTML tag), surrounding a fully
+                               // parsed HTML link.
+                               //
+                               // Concatenate everything from the tag, flattening the contents.
+                               result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
+                                       .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
+                       }
 
-                               parsedResult = sequence( [
-                                       makeStringParser( '<nowiki>' ),
-                                       // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much
-                                       makeRegexParser( /^.*?(?=<\/nowiki>)/ ),
-                                       makeStringParser( '</nowiki>' )
-                               ] );
-                               if ( parsedResult !== null ) {
-                                       plainText = parsedResult[ 1 ];
-                                       result = [ 'CONCAT' ].concat( plainText );
-                               }
+                       return result;
+               }
 
-                               return result;
+               // <nowiki>...</nowiki> tag. The tags are stripped and the contents are returned unparsed.
+               function nowiki() {
+                       var parsedResult, plainText,
+                               result = null;
+
+                       parsedResult = sequence( [
+                               makeStringParser( '<nowiki>' ),
+                               // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much
+                               makeRegexParser( /^.*?(?=<\/nowiki>)/ ),
+                               makeStringParser( '</nowiki>' )
+                       ] );
+                       if ( parsedResult !== null ) {
+                               plainText = parsedResult[ 1 ];
+                               result = [ 'CONCAT' ].concat( plainText );
                        }
 
-                       templateName = transform(
-                               // see $wgLegalTitleChars
-                               // not allowing : due to the need to catch "PLURAL:$1"
-                               makeRegexParser( /^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ ),
-                               function ( result ) { return result.toString(); }
-                       );
-                       function templateParam() {
-                               var expr, result;
-                               result = sequence( [
-                                       pipe,
-                                       nOrMore( 0, paramExpression )
-                               ] );
-                               if ( result === null ) {
-                                       return null;
-                               }
-                               expr = result[ 1 ];
-                               // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
-                               return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ];
+                       return result;
+               }
+
+               templateName = transform(
+                       // see $wgLegalTitleChars
+                       // not allowing : due to the need to catch "PLURAL:$1"
+                       makeRegexParser( /^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ ),
+                       function ( result ) { return result.toString(); }
+               );
+               function templateParam() {
+                       var expr, result;
+                       result = sequence( [
+                               pipe,
+                               nOrMore( 0, paramExpression )
+                       ] );
+                       if ( result === null ) {
+                               return null;
                        }
+                       expr = result[ 1 ];
+                       // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
+                       return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ];
+               }
 
-                       function templateWithReplacement() {
-                               var result = sequence( [
-                                       templateName,
-                                       colon,
-                                       replacement
+               function templateWithReplacement() {
+                       var result = sequence( [
+                               templateName,
+                               colon,
+                               replacement
+                       ] );
+                       return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+               }
+               function templateWithOutReplacement() {
+                       var result = sequence( [
+                               templateName,
+                               colon,
+                               paramExpression
+                       ] );
+                       return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+               }
+               function templateWithOutFirstParameter() {
+                       var result = sequence( [
+                               templateName,
+                               colon
+                       ] );
+                       return result === null ? null : [ result[ 0 ], '' ];
+               }
+               colon = makeStringParser( ':' );
+               templateContents = choice( [
+                       function () {
+                               var res = sequence( [
+                                       // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
+                                       // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
+                                       choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
+                                       nOrMore( 0, templateParam )
                                ] );
-                               return result === null ? null : [ result[ 0 ], result[ 2 ] ];
-                       }
-                       function templateWithOutReplacement() {
-                               var result = sequence( [
+                               return res === null ? null : res[ 0 ].concat( res[ 1 ] );
+                       },
+                       function () {
+                               var res = sequence( [
                                        templateName,
-                                       colon,
-                                       paramExpression
+                                       nOrMore( 0, templateParam )
                                ] );
-                               return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+                               if ( res === null ) {
+                                       return null;
+                               }
+                               return [ res[ 0 ] ].concat( res[ 1 ] );
                        }
-                       function templateWithOutFirstParameter() {
-                               var result = sequence( [
-                                       templateName,
-                                       colon
-                               ] );
-                               return result === null ? null : [ result[ 0 ], '' ];
+               ] );
+               openTemplate = makeStringParser( '{{' );
+               closeTemplate = makeStringParser( '}}' );
+               nonWhitespaceExpression = choice( [
+                       template,
+                       wikilink,
+                       extlink,
+                       replacement,
+                       literalWithoutSpace
+               ] );
+               paramExpression = choice( [
+                       template,
+                       wikilink,
+                       extlink,
+                       replacement,
+                       literalWithoutBar
+               ] );
+
+               expression = choice( [
+                       template,
+                       wikilink,
+                       extlink,
+                       replacement,
+                       nowiki,
+                       html,
+                       literal
+               ] );
+
+               // Used when only {{-transformation is wanted, for 'text'
+               // or 'escaped' formats
+               curlyBraceTransformExpression = choice( [
+                       template,
+                       replacement,
+                       curlyBraceTransformExpressionLiteral
+               ] );
+
+               /**
+                * Starts the parse
+                *
+                * @param {Function} rootExpression Root parse function
+                * @return {Array|null}
+                */
+               function start( rootExpression ) {
+                       var result = nOrMore( 0, rootExpression )();
+                       if ( result === null ) {
+                               return null;
                        }
-                       colon = makeStringParser( ':' );
-                       templateContents = choice( [
-                               function () {
-                                       var res = sequence( [
-                                               // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
-                                               // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
-                                               choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
-                                               nOrMore( 0, templateParam )
-                                       ] );
-                                       return res === null ? null : res[ 0 ].concat( res[ 1 ] );
-                               },
-                               function () {
-                                       var res = sequence( [
-                                               templateName,
-                                               nOrMore( 0, templateParam )
-                                       ] );
-                                       if ( res === null ) {
-                                               return null;
-                                       }
-                                       return [ res[ 0 ] ].concat( res[ 1 ] );
-                               }
-                       ] );
-                       openTemplate = makeStringParser( '{{' );
-                       closeTemplate = makeStringParser( '}}' );
-                       nonWhitespaceExpression = choice( [
-                               template,
-                               wikilink,
-                               extlink,
-                               replacement,
-                               literalWithoutSpace
-                       ] );
-                       paramExpression = choice( [
-                               template,
-                               wikilink,
-                               extlink,
-                               replacement,
-                               literalWithoutBar
-                       ] );
+                       return [ 'CONCAT' ].concat( result );
+               }
+               // everything above this point is supposed to be stateless/static, but
+               // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
+               // finally let's do some actual work...
 
-                       expression = choice( [
-                               template,
-                               wikilink,
-                               extlink,
-                               replacement,
-                               nowiki,
-                               html,
-                               literal
-                       ] );
+               result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
 
-                       // Used when only {{-transformation is wanted, for 'text'
-                       // or 'escaped' formats
-                       curlyBraceTransformExpression = choice( [
-                               template,
-                               replacement,
-                               curlyBraceTransformExpressionLiteral
-                       ] );
+               /*
+                * For success, the p must have gotten to the end of the input
+                * and returned a non-null.
+                * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
+                */
+               if ( result === null || pos !== input.length ) {
+                       throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
+               }
+               return result;
+       }
 
-                       /**
-                        * Starts the parse
-                        *
-                        * @param {Function} rootExpression Root parse function
-                        * @return {Array|null}
-                        */
-                       function start( rootExpression ) {
-                               var result = nOrMore( 0, rootExpression )();
-                               if ( result === null ) {
-                                       return null;
+};
+
+/**
+ * Class that primarily exists to emit HTML from parser ASTs.
+ *
+ * @private
+ * @class
+ * @param {Object} language
+ * @param {Object} magic
+ */
+mw.jqueryMsg.HtmlEmitter = function ( language, magic ) {
+       var jmsg = this;
+       this.language = language;
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( magic, function ( key, val ) {
+               jmsg[ key.toLowerCase() ] = function () {
+                       return val;
+               };
+       } );
+
+       /**
+        * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
+        * Walk entire node structure, applying replacements and template functions when appropriate
+        *
+        * @param {Mixed} node Abstract syntax tree (top node or subnode)
+        * @param {Array} replacements for $1, $2, ... $n
+        * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
+        */
+       this.emit = function ( node, replacements ) {
+               var ret, subnodes, operation,
+                       jmsg = this;
+               switch ( typeof node ) {
+                       case 'string':
+                       case 'number':
+                               ret = node;
+                               break;
+                       // typeof returns object for arrays
+                       case 'object':
+                               // node is an array of nodes
+                               // eslint-disable-next-line no-jquery/no-map-util
+                               subnodes = $.map( node.slice( 1 ), function ( n ) {
+                                       return jmsg.emit( n, replacements );
+                               } );
+                               operation = node[ 0 ].toLowerCase();
+                               if ( typeof jmsg[ operation ] === 'function' ) {
+                                       ret = jmsg[ operation ]( subnodes, replacements );
+                               } else {
+                                       throw new Error( 'Unknown operation "' + operation + '"' );
                                }
-                               return [ 'CONCAT' ].concat( result );
-                       }
-                       // everything above this point is supposed to be stateless/static, but
-                       // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
-                       // finally let's do some actual work...
-
-                       result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
-
-                       /*
-                        * For success, the p must have gotten to the end of the input
-                        * and returned a non-null.
-                        * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
-                        */
-                       if ( result === null || pos !== input.length ) {
-                               throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
-                       }
-                       return result;
+                               break;
+                       case 'undefined':
+                               // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
+                               // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
+                               // The logical thing is probably to return the empty string here when we encounter undefined.
+                               ret = '';
+                               break;
+                       default:
+                               throw new Error( 'Unexpected type in AST: ' + typeof node );
                }
-
+               return ret;
        };
-
+};
+
+// For everything in input that follows double-open-curly braces, there should be an equivalent parser
+// function. For instance {{PLURAL ... }} will be processed by 'plural'.
+// If you have 'magic words' then configure the parser to have them upon creation.
+//
+// An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
+// Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
+mw.jqueryMsg.HtmlEmitter.prototype = {
        /**
-        * Class that primarily exists to emit HTML from parser ASTs.
+        * Parsing has been applied depth-first we can assume that all nodes here are single nodes
+        * Must return a single node to parents -- a jQuery with synthetic span
+        * However, unwrap any other synthetic spans in our children and pass them upwards
         *
-        * @private
-        * @class
-        * @param {Object} language
-        * @param {Object} magic
+        * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
+        * @return {jQuery}
         */
-       mw.jqueryMsg.HtmlEmitter = function ( language, magic ) {
-               var jmsg = this;
-               this.language = language;
+       concat: function ( nodes ) {
+               var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
                // eslint-disable-next-line no-jquery/no-each-util
-               $.each( magic, function ( key, val ) {
-                       jmsg[ key.toLowerCase() ] = function () {
-                               return val;
-                       };
+               $.each( nodes, function ( i, node ) {
+                       // Let jQuery append nodes, arrays of nodes and jQuery objects
+                       // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
+                       appendWithoutParsing( $span, node );
                } );
+               return $span;
+       },
 
-               /**
-                * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
-                * Walk entire node structure, applying replacements and template functions when appropriate
-                *
-                * @param {Mixed} node Abstract syntax tree (top node or subnode)
-                * @param {Array} replacements for $1, $2, ... $n
-                * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
-                */
-               this.emit = function ( node, replacements ) {
-                       var ret, subnodes, operation,
-                               jmsg = this;
-                       switch ( typeof node ) {
-                               case 'string':
-                               case 'number':
-                                       ret = node;
-                                       break;
-                               // typeof returns object for arrays
-                               case 'object':
-                                       // node is an array of nodes
-                                       // eslint-disable-next-line no-jquery/no-map-util
-                                       subnodes = $.map( node.slice( 1 ), function ( n ) {
-                                               return jmsg.emit( n, replacements );
-                                       } );
-                                       operation = node[ 0 ].toLowerCase();
-                                       if ( typeof jmsg[ operation ] === 'function' ) {
-                                               ret = jmsg[ operation ]( subnodes, replacements );
-                                       } else {
-                                               throw new Error( 'Unknown operation "' + operation + '"' );
-                                       }
-                                       break;
-                               case 'undefined':
-                                       // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
-                                       // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
-                                       // The logical thing is probably to return the empty string here when we encounter undefined.
-                                       ret = '';
-                                       break;
-                               default:
-                                       throw new Error( 'Unexpected type in AST: ' + typeof node );
-                       }
-                       return ret;
-               };
-       };
-
-       // For everything in input that follows double-open-curly braces, there should be an equivalent parser
-       // function. For instance {{PLURAL ... }} will be processed by 'plural'.
-       // If you have 'magic words' then configure the parser to have them upon creation.
-       //
-       // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
-       // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
-       mw.jqueryMsg.HtmlEmitter.prototype = {
-               /**
-                * Parsing has been applied depth-first we can assume that all nodes here are single nodes
-                * Must return a single node to parents -- a jQuery with synthetic span
-                * However, unwrap any other synthetic spans in our children and pass them upwards
-                *
-                * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
-                * @return {jQuery}
-                */
-               concat: function ( nodes ) {
-                       var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
-                       // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( nodes, function ( i, node ) {
-                               // Let jQuery append nodes, arrays of nodes and jQuery objects
-                               // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
-                               appendWithoutParsing( $span, node );
-                       } );
-                       return $span;
-               },
+       /**
+        * Return escaped replacement of correct index, or string if unavailable.
+        * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
+        * if the specified parameter is not found return the same string
+        * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
+        *
+        * TODO: Throw error if nodes.length > 1 ?
+        *
+        * @param {Array} nodes List of one element, integer, n >= 0
+        * @param {Array} replacements List of at least n strings
+        * @return {string|jQuery} replacement
+        */
+       replace: function ( nodes, replacements ) {
+               var index = parseInt( nodes[ 0 ], 10 );
 
-               /**
-                * Return escaped replacement of correct index, or string if unavailable.
-                * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
-                * if the specified parameter is not found return the same string
-                * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
-                *
-                * TODO: Throw error if nodes.length > 1 ?
-                *
-                * @param {Array} nodes List of one element, integer, n >= 0
-                * @param {Array} replacements List of at least n strings
-                * @return {string|jQuery} replacement
-                */
-               replace: function ( nodes, replacements ) {
-                       var index = parseInt( nodes[ 0 ], 10 );
+               if ( index < replacements.length ) {
+                       return replacements[ index ];
+               } else {
+                       // index not found, fallback to displaying variable
+                       return '$' + ( index + 1 );
+               }
+       },
 
-                       if ( index < replacements.length ) {
-                               return replacements[ index ];
-                       } else {
-                               // index not found, fallback to displaying variable
-                               return '$' + ( index + 1 );
-                       }
-               },
+       /**
+        * Transform wiki-link
+        *
+        * TODO:
+        * It only handles basic cases, either no pipe, or a pipe with an explicit
+        * anchor.
+        *
+        * It does not attempt to handle features like the pipe trick.
+        * However, the pipe trick should usually not be present in wikitext retrieved
+        * from the server, since the replacement is done at save time.
+        * It may, though, if the wikitext appears in extension-controlled content.
+        *
+        * @param {string[]} nodes
+        * @return {jQuery}
+        */
+       wikilink: function ( nodes ) {
+               var page, anchor, url, $el;
+
+               page = textify( nodes[ 0 ] );
+               // Strip leading ':', which is used to suppress special behavior in wikitext links,
+               // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
+               if ( page.charAt( 0 ) === ':' ) {
+                       page = page.slice( 1 );
+               }
+               url = mw.util.getUrl( page );
 
-               /**
-                * Transform wiki-link
-                *
-                * TODO:
-                * It only handles basic cases, either no pipe, or a pipe with an explicit
-                * anchor.
-                *
-                * It does not attempt to handle features like the pipe trick.
-                * However, the pipe trick should usually not be present in wikitext retrieved
-                * from the server, since the replacement is done at save time.
-                * It may, though, if the wikitext appears in extension-controlled content.
-                *
-                * @param {string[]} nodes
-                * @return {jQuery}
-                */
-               wikilink: function ( nodes ) {
-                       var page, anchor, url, $el;
-
-                       page = textify( nodes[ 0 ] );
-                       // Strip leading ':', which is used to suppress special behavior in wikitext links,
-                       // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
-                       if ( page.charAt( 0 ) === ':' ) {
-                               page = page.slice( 1 );
-                       }
-                       url = mw.util.getUrl( page );
+               if ( nodes.length === 1 ) {
+                       // [[Some Page]] or [[Namespace:Some Page]]
+                       anchor = page;
+               } else {
+                       // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
+                       anchor = nodes[ 1 ];
+               }
 
-                       if ( nodes.length === 1 ) {
-                               // [[Some Page]] or [[Namespace:Some Page]]
-                               anchor = page;
-                       } else {
-                               // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
-                               anchor = nodes[ 1 ];
-                       }
+               $el = $( '<a>' ).attr( {
+                       title: page,
+                       href: url
+               } );
+               return appendWithoutParsing( $el, anchor );
+       },
 
-                       $el = $( '<a>' ).attr( {
-                               title: page,
-                               href: url
-                       } );
-                       return appendWithoutParsing( $el, anchor );
-               },
+       /**
+        * Converts array of HTML element key value pairs to object
+        *
+        * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
+        *  name and 2 * n + 1 the associated value
+        * @return {Object} Object mapping attribute name to attribute value
+        */
+       htmlattributes: function ( nodes ) {
+               var i, len, mapping = {};
+               for ( i = 0, len = nodes.length; i < len; i += 2 ) {
+                       mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
+               }
+               return mapping;
+       },
 
-               /**
-                * Converts array of HTML element key value pairs to object
-                *
-                * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
-                *  name and 2 * n + 1 the associated value
-                * @return {Object} Object mapping attribute name to attribute value
-                */
-               htmlattributes: function ( nodes ) {
-                       var i, len, mapping = {};
-                       for ( i = 0, len = nodes.length; i < len; i += 2 ) {
-                               mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
-                       }
-                       return mapping;
-               },
+       /**
+        * Handles an (already-validated) HTML element.
+        *
+        * @param {Array} nodes Nodes to process when creating element
+        * @return {jQuery}
+        */
+       htmlelement: function ( nodes ) {
+               var tagName, attributes, contents, $element;
 
-               /**
-                * Handles an (already-validated) HTML element.
-                *
-                * @param {Array} nodes Nodes to process when creating element
-                * @return {jQuery}
-                */
-               htmlelement: function ( nodes ) {
-                       var tagName, attributes, contents, $element;
-
-                       tagName = nodes.shift();
-                       attributes = nodes.shift();
-                       contents = nodes;
-                       $element = $( document.createElement( tagName ) ).attr( attributes );
-                       return appendWithoutParsing( $element, contents );
-               },
+               tagName = nodes.shift();
+               attributes = nodes.shift();
+               contents = nodes;
+               $element = $( document.createElement( tagName ) ).attr( attributes );
+               return appendWithoutParsing( $element, contents );
+       },
 
-               /**
-                * Transform parsed structure into external link.
-                *
-                * The "href" can be:
-                * - a jQuery object, treat it as "enclosing" the link text.
-                * - a function, treat it as the click handler.
-                * - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying.
-                *
-                * TODO: throw an error if nodes.length > 2 ?
-                *
-                * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
-                * @return {jQuery}
-                */
-               extlink: function ( nodes ) {
-                       var $el,
-                               arg = nodes[ 0 ],
-                               contents = nodes[ 1 ];
-                       if ( arg instanceof $ && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
-                               $el = arg;
+       /**
+        * Transform parsed structure into external link.
+        *
+        * The "href" can be:
+        * - a jQuery object, treat it as "enclosing" the link text.
+        * - a function, treat it as the click handler.
+        * - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying.
+        *
+        * TODO: throw an error if nodes.length > 2 ?
+        *
+        * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
+        * @return {jQuery}
+        */
+       extlink: function ( nodes ) {
+               var $el,
+                       arg = nodes[ 0 ],
+                       contents = nodes[ 1 ];
+               if ( arg instanceof $ && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+                       $el = arg;
+               } else {
+                       $el = $( '<a>' );
+                       if ( typeof arg === 'function' ) {
+                               $el.attr( {
+                                       role: 'button',
+                                       tabindex: 0
+                               } ).on( 'click keypress', function ( e ) {
+                                       if (
+                                               e.type === 'click' ||
+                                               e.type === 'keypress' && e.which === 13
+                                       ) {
+                                               arg.call( this, e );
+                                       }
+                               } );
                        } else {
-                               $el = $( '<a>' );
-                               if ( typeof arg === 'function' ) {
-                                       $el.attr( {
-                                               role: 'button',
-                                               tabindex: 0
-                                       } ).on( 'click keypress', function ( e ) {
-                                               if (
-                                                       e.type === 'click' ||
-                                                       e.type === 'keypress' && e.which === 13
-                                               ) {
-                                                       arg.call( this, e );
-                                               }
-                                       } );
-                               } else {
-                                       $el.attr( 'href', textify( arg ) );
-                               }
+                               $el.attr( 'href', textify( arg ) );
                        }
-                       return appendWithoutParsing( $el.empty(), contents );
-               },
-
-               /**
-                * Transform parsed structure into pluralization
-                * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
-                * So convert it back with the current language's convertNumber.
-                *
-                * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
-                * @return {string|jQuery} selected pluralized form according to current language
-                */
-               plural: function ( nodes ) {
-                       var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count,
-                               explicitPluralForms = {};
+               }
+               return appendWithoutParsing( $el.empty(), contents );
+       },
 
-                       count = parseFloat( this.language.convertNumber( textify( nodes[ 0 ] ), true ) );
-                       forms = nodes.slice( 1 );
-                       for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
-                               form = forms[ formIndex ];
-
-                               if ( form instanceof $ && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
-                                       // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
-                                       firstChild = form.contents().get( 0 );
-                                       if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
-                                               firstChildText = firstChild.textContent;
-                                               if ( /^\d+=/.test( firstChildText ) ) {
-                                                       explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
-                                                       // Use the digit part as key and rest of first text node and
-                                                       // rest of child nodes as value.
-                                                       firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
-                                                       explicitPluralForms[ explicitPluralFormNumber ] = form;
-                                                       forms[ formIndex ] = undefined;
-                                               }
+       /**
+        * Transform parsed structure into pluralization
+        * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
+        * So convert it back with the current language's convertNumber.
+        *
+        * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
+        * @return {string|jQuery} selected pluralized form according to current language
+        */
+       plural: function ( nodes ) {
+               var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count,
+                       explicitPluralForms = {};
+
+               count = parseFloat( this.language.convertNumber( textify( nodes[ 0 ] ), true ) );
+               forms = nodes.slice( 1 );
+               for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
+                       form = forms[ formIndex ];
+
+                       if ( form instanceof $ && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+                               // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
+                               firstChild = form.contents().get( 0 );
+                               if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
+                                       firstChildText = firstChild.textContent;
+                                       if ( /^\d+=/.test( firstChildText ) ) {
+                                               explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
+                                               // Use the digit part as key and rest of first text node and
+                                               // rest of child nodes as value.
+                                               firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
+                                               explicitPluralForms[ explicitPluralFormNumber ] = form;
+                                               forms[ formIndex ] = undefined;
                                        }
-                               } else if ( /^\d+=/.test( form ) ) {
-                                       // Simple explicit plural forms like 12=a dozen
-                                       explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
-                                       explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
-                                       forms[ formIndex ] = undefined;
                                }
+                       } else if ( /^\d+=/.test( form ) ) {
+                               // Simple explicit plural forms like 12=a dozen
+                               explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
+                               explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
+                               forms[ formIndex ] = undefined;
                        }
+               }
 
-                       // Remove explicit plural forms from the forms. They were set undefined in the above loop.
-                       // eslint-disable-next-line no-jquery/no-map-util
-                       forms = $.map( forms, function ( form ) {
-                               return form;
-                       } );
-
-                       return this.language.convertPlural( count, forms, explicitPluralForms );
-               },
-
-               /**
-                * Transform parsed structure according to gender.
-                *
-                * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
-                *
-                * The first node must be one of:
-                * - the mw.user object (or a compatible one)
-                * - an empty string - indicating the current user, same effect as passing the mw.user object
-                * - a gender string ('male', 'female' or 'unknown')
-                *
-                * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
-                * @return {string|jQuery} Selected gender form according to current language
-                */
-               gender: function ( nodes ) {
-                       var gender,
-                               maybeUser = nodes[ 0 ],
-                               forms = nodes.slice( 1 );
-
-                       if ( maybeUser === '' ) {
-                               maybeUser = mw.user;
-                       }
-
-                       // If we are passed a mw.user-like object, check their gender.
-                       // Otherwise, assume the gender string itself was passed .
-                       if ( maybeUser && maybeUser.options instanceof mw.Map ) {
-                               gender = maybeUser.options.get( 'gender' );
-                       } else {
-                               gender = textify( maybeUser );
-                       }
-
-                       return this.language.gender( gender, forms );
-               },
+               // Remove explicit plural forms from the forms. They were set undefined in the above loop.
+               // eslint-disable-next-line no-jquery/no-map-util
+               forms = $.map( forms, function ( form ) {
+                       return form;
+               } );
 
-               /**
-                * Transform parsed structure into grammar conversion.
-                * Invoked by putting `{{grammar:form|word}}` in a message
-                *
-                * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
-                * @return {string|jQuery} selected grammatical form according to current language
-                */
-               grammar: function ( nodes ) {
-                       var form = nodes[ 0 ],
-                               word = nodes[ 1 ];
-                       // These could be jQuery objects (passed as message parameters),
-                       // in which case we can't transform them (like rawParams() in PHP).
-                       if ( typeof form === 'string' && typeof word === 'string' ) {
-                               return this.language.convertGrammar( word, form );
-                       }
-                       return word;
-               },
+               return this.language.convertPlural( count, forms, explicitPluralForms );
+       },
 
-               /**
-                * Tranform parsed structure into a int: (interface language) message include
-                * Invoked by putting `{{int:othermessage}}` into a message
-                *
-                * TODO Syntax in the included message is not parsed, this seems like a bug?
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} Other message
-                */
-               int: function ( nodes ) {
-                       var msg = textify( nodes[ 0 ] );
-                       return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) );
-               },
+       /**
+        * Transform parsed structure according to gender.
+        *
+        * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
+        *
+        * The first node must be one of:
+        * - the mw.user object (or a compatible one)
+        * - an empty string - indicating the current user, same effect as passing the mw.user object
+        * - a gender string ('male', 'female' or 'unknown')
+        *
+        * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
+        * @return {string|jQuery} Selected gender form according to current language
+        */
+       gender: function ( nodes ) {
+               var gender,
+                       maybeUser = nodes[ 0 ],
+                       forms = nodes.slice( 1 );
 
-               /**
-                * Get localized namespace name from canonical name or namespace number.
-                * Invoked by putting `{{ns:foo}}` into a message
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} Localized namespace name
-                */
-               ns: function ( nodes ) {
-                       var ns = textify( nodes[ 0 ] ).trim();
-                       if ( !/^\d+$/.test( ns ) ) {
-                               ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
-                       }
-                       ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
-                       return ns || '';
-               },
+               if ( maybeUser === '' ) {
+                       maybeUser = mw.user;
+               }
 
-               /**
-                * Takes an unformatted number (arab, no group separators and . as decimal separator)
-                * and outputs it in the localized digit script and formatted with decimal
-                * separator, according to the current language.
-                *
-                * @param {Array} nodes List of nodes
-                * @return {number|string|jQuery} Formatted number
-                */
-               formatnum: function ( nodes ) {
-                       var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R',
-                               number = nodes[ 0 ];
-
-                       // These could be jQuery objects (passed as message parameters),
-                       // in which case we can't transform them (like rawParams() in PHP).
-                       if ( typeof number === 'string' || typeof number === 'number' ) {
-                               return this.language.convertNumber( number, isInteger );
-                       }
-                       return number;
-               },
+               // If we are passed a mw.user-like object, check their gender.
+               // Otherwise, assume the gender string itself was passed .
+               if ( maybeUser && maybeUser.options instanceof mw.Map ) {
+                       gender = maybeUser.options.get( 'gender' );
+               } else {
+                       gender = textify( maybeUser );
+               }
 
-               /**
-                * Lowercase text
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} The given text, all in lowercase
-                */
-               lc: function ( nodes ) {
-                       return textify( nodes[ 0 ] ).toLowerCase();
-               },
+               return this.language.gender( gender, forms );
+       },
 
-               /**
-                * Uppercase text
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} The given text, all in uppercase
-                */
-               uc: function ( nodes ) {
-                       return textify( nodes[ 0 ] ).toUpperCase();
-               },
+       /**
+        * Transform parsed structure into grammar conversion.
+        * Invoked by putting `{{grammar:form|word}}` in a message
+        *
+        * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
+        * @return {string|jQuery} selected grammatical form according to current language
+        */
+       grammar: function ( nodes ) {
+               var form = nodes[ 0 ],
+                       word = nodes[ 1 ];
+               // These could be jQuery objects (passed as message parameters),
+               // in which case we can't transform them (like rawParams() in PHP).
+               if ( typeof form === 'string' && typeof word === 'string' ) {
+                       return this.language.convertGrammar( word, form );
+               }
+               return word;
+       },
 
-               /**
-                * Lowercase first letter of input, leaving the rest unchanged
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} The given text, with the first character in lowercase
-                */
-               lcfirst: function ( nodes ) {
-                       var text = textify( nodes[ 0 ] );
-                       return text.charAt( 0 ).toLowerCase() + text.slice( 1 );
-               },
+       /**
+        * Tranform parsed structure into a int: (interface language) message include
+        * Invoked by putting `{{int:othermessage}}` into a message
+        *
+        * TODO Syntax in the included message is not parsed, this seems like a bug?
+        *
+        * @param {Array} nodes List of nodes
+        * @return {string} Other message
+        */
+       int: function ( nodes ) {
+               var msg = textify( nodes[ 0 ] );
+               return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) );
+       },
 
-               /**
-                * Uppercase first letter of input, leaving the rest unchanged
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} The given text, with the first character in uppercase
-                */
-               ucfirst: function ( nodes ) {
-                       var text = textify( nodes[ 0 ] );
-                       return text.charAt( 0 ).toUpperCase() + text.slice( 1 );
+       /**
+        * Get localized namespace name from canonical name or namespace number.
+        * Invoked by putting `{{ns:foo}}` into a message
+        *
+        * @param {Array} nodes List of nodes
+        * @return {string} Localized namespace name
+        */
+       ns: function ( nodes ) {
+               var ns = textify( nodes[ 0 ] ).trim();
+               if ( !/^\d+$/.test( ns ) ) {
+                       ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
                }
-       };
+               ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
+               return ns || '';
+       },
 
        /**
-        * @method
-        * @member jQuery
-        * @see mw.jqueryMsg#getPlugin
+        * Takes an unformatted number (arab, no group separators and . as decimal separator)
+        * and outputs it in the localized digit script and formatted with decimal
+        * separator, according to the current language.
+        *
+        * @param {Array} nodes List of nodes
+        * @return {number|string|jQuery} Formatted number
         */
-       $.fn.msg = mw.jqueryMsg.getPlugin();
-
-       // Replace the default message parser with jqueryMsg
-       oldParser = mw.Message.prototype.parser;
-       mw.Message.prototype.parser = function () {
-               // Fall back to mw.msg's simple parser where possible
-               if (
-                       // Plain text output always uses the simple parser
-                       this.format === 'plain' ||
-                       (
-                               // jqueryMsg parser is needed for messages containing wikitext
-                               !/\{\{|[<>[&]/.test( this.map.get( this.key ) ) &&
-                               // jqueryMsg parser is needed when jQuery objects or DOM nodes are passed in as parameters
-                               !this.parameters.some( function ( param ) {
-                                       return param instanceof $ || ( param && param.nodeType !== undefined );
-                               } )
-                       )
-               ) {
-                       return oldParser.apply( this );
+       formatnum: function ( nodes ) {
+               var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R',
+                       number = nodes[ 0 ];
+
+               // These could be jQuery objects (passed as message parameters),
+               // in which case we can't transform them (like rawParams() in PHP).
+               if ( typeof number === 'string' || typeof number === 'number' ) {
+                       return this.language.convertNumber( number, isInteger );
                }
+               return number;
+       },
 
-               if ( !Object.prototype.hasOwnProperty.call( this.map, this.format ) ) {
-                       this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( {
-                               messages: this.map,
-                               // For format 'escaped', escaping part is handled by mediawiki.js
-                               format: this.format
-                       } );
-               }
-               return this.map[ this.format ]( this.key, this.parameters );
-       };
+       /**
+        * Lowercase text
+        *
+        * @param {Array} nodes List of nodes
+        * @return {string} The given text, all in lowercase
+        */
+       lc: function ( nodes ) {
+               return textify( nodes[ 0 ] ).toLowerCase();
+       },
 
        /**
-        * Parse the message to DOM nodes, rather than HTML string like #parse.
+        * Uppercase text
         *
-        * This method is only available when jqueryMsg is loaded.
+        * @param {Array} nodes List of nodes
+        * @return {string} The given text, all in uppercase
+        */
+       uc: function ( nodes ) {
+               return textify( nodes[ 0 ] ).toUpperCase();
+       },
+
+       /**
+        * Lowercase first letter of input, leaving the rest unchanged
         *
-        * @since 1.27
-        * @method parseDom
-        * @member mw.Message
-        * @return {jQuery}
+        * @param {Array} nodes List of nodes
+        * @return {string} The given text, with the first character in lowercase
         */
-       mw.Message.prototype.parseDom = ( function () {
-               var $wrapper = $( '<div>' );
-               return function () {
-                       return $wrapper.msg( this.key, this.parameters ).contents().detach();
-               };
-       }() );
+       lcfirst: function ( nodes ) {
+               var text = textify( nodes[ 0 ] );
+               return text.charAt( 0 ).toLowerCase() + text.slice( 1 );
+       },
+
+       /**
+        * Uppercase first letter of input, leaving the rest unchanged
+        *
+        * @param {Array} nodes List of nodes
+        * @return {string} The given text, with the first character in uppercase
+        */
+       ucfirst: function ( nodes ) {
+               var text = textify( nodes[ 0 ] );
+               return text.charAt( 0 ).toUpperCase() + text.slice( 1 );
+       }
+};
+
+/**
+ * @method
+ * @member jQuery
+ * @see mw.jqueryMsg#getPlugin
+ */
+$.fn.msg = mw.jqueryMsg.getPlugin();
+
+// Replace the default message parser with jqueryMsg
+oldParser = mw.Message.prototype.parser;
+mw.Message.prototype.parser = function () {
+       // Fall back to mw.msg's simple parser where possible
+       if (
+               // Plain text output always uses the simple parser
+               this.format === 'plain' ||
+               (
+                       // jqueryMsg parser is needed for messages containing wikitext
+                       !/\{\{|[<>[&]/.test( this.map.get( this.key ) ) &&
+                       // jqueryMsg parser is needed when jQuery objects or DOM nodes are passed in as parameters
+                       !this.parameters.some( function ( param ) {
+                               return param instanceof $ || ( param && param.nodeType !== undefined );
+                       } )
+               )
+       ) {
+               return oldParser.apply( this );
+       }
 
+       if ( !Object.prototype.hasOwnProperty.call( this.map, this.format ) ) {
+               this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( {
+                       messages: this.map,
+                       // For format 'escaped', escaping part is handled by mediawiki.js
+                       format: this.format
+               } );
+       }
+       return this.map[ this.format ]( this.key, this.parameters );
+};
+
+/**
+ * Parse the message to DOM nodes, rather than HTML string like #parse.
+ *
+ * This method is only available when jqueryMsg is loaded.
+ *
+ * @since 1.27
+ * @method parseDom
+ * @member mw.Message
+ * @return {jQuery}
+ */
+mw.Message.prototype.parseDom = ( function () {
+       var $wrapper = $( '<div>' );
+       return function () {
+               return $wrapper.msg( this.key, this.parameters ).contents().detach();
+       };
 }() );
diff --git a/resources/src/mediawiki.rcfilters/.eslintrc.json b/resources/src/mediawiki.rcfilters/.eslintrc.json
new file mode 100644 (file)
index 0000000..ad8dbb3
--- /dev/null
@@ -0,0 +1,5 @@
+{
+       "parserOptions": {
+               "sourceType": "module"
+       }
+}
index ce5d407..b6284fb 100644 (file)
-( function () {
-
-       var byteLength = require( 'mediawiki.String' ).byteLength,
-               UriProcessor = require( './UriProcessor.js' ),
-               Controller;
-
-       /* eslint no-underscore-dangle: "off" */
-       /**
-        * Controller for the filters in Recent Changes
-        * @class mw.rcfilters.Controller
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {Object} config Additional configuration
-        * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
-        * @cfg {string} daysPreferenceName Preference name for the days filter
-        * @cfg {string} limitPreferenceName Preference name for the limit filter
-        * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
-        *  the active filters area
-        * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
-        *  title normalization to separate title subpage/parts into the target= url
-        *  parameter
-        */
-       Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
-               this.filtersModel = filtersModel;
-               this.changesListModel = changesListModel;
-               this.savedQueriesModel = savedQueriesModel;
-               this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
-               this.daysPreferenceName = config.daysPreferenceName;
-               this.limitPreferenceName = config.limitPreferenceName;
-               this.collapsedPreferenceName = config.collapsedPreferenceName;
-               this.normalizeTarget = !!config.normalizeTarget;
-
-               this.pollingRate = require( './config.json' ).StructuredChangeFiltersLiveUpdatePollingRate;
-
-               this.requestCounter = {};
-               this.baseFilterState = {};
-               this.uriProcessor = null;
-               this.initialized = false;
-               this.wereSavedQueriesSaved = false;
-
-               this.prevLoggedItems = [];
-
-               this.FILTER_CHANGE = 'filterChange';
-               this.SHOW_NEW_CHANGES = 'showNewChanges';
-               this.LIVE_UPDATE = 'liveUpdate';
-       };
-
-       /* Initialization */
-       OO.initClass( Controller );
-
-       /**
-        * Initialize the filter and parameter states
-        *
-        * @param {Array} filterStructure Filter definition and structure for the model
-        * @param {Object} [namespaceStructure] Namespace definition
-        * @param {Object} [tagList] Tag definition
-        * @param {Object} [conditionalViews] Conditional view definition
-        */
-       Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
-               var parsedSavedQueries, pieces,
-                       displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
-                       defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
-                       controller = this,
-                       views = $.extend( true, {}, conditionalViews ),
-                       items = [],
-                       uri = new mw.Uri();
-
-               // Prepare views
-               if ( namespaceStructure ) {
-                       items = [];
-                       // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( namespaceStructure, function ( namespaceID, label ) {
-                               // Build and clean up the individual namespace items definition
-                               items.push( {
-                                       name: namespaceID,
-                                       label: label || mw.msg( 'blanknamespace' ),
-                                       description: '',
-                                       identifiers: [
-                                               mw.Title.isTalkNamespace( namespaceID ) ?
-                                                       'talk' : 'subject'
-                                       ],
-                                       cssClass: 'mw-changeslist-ns-' + namespaceID
-                               } );
+var byteLength = require( 'mediawiki.String' ).byteLength,
+       UriProcessor = require( './UriProcessor.js' ),
+       Controller;
+
+/* eslint no-underscore-dangle: "off" */
+/**
+ * Controller for the filters in Recent Changes
+ * @class mw.rcfilters.Controller
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {Object} config Additional configuration
+ * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
+ * @cfg {string} daysPreferenceName Preference name for the days filter
+ * @cfg {string} limitPreferenceName Preference name for the limit filter
+ * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
+ *  the active filters area
+ * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
+ *  title normalization to separate title subpage/parts into the target= url
+ *  parameter
+ */
+Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
+       this.filtersModel = filtersModel;
+       this.changesListModel = changesListModel;
+       this.savedQueriesModel = savedQueriesModel;
+       this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
+       this.daysPreferenceName = config.daysPreferenceName;
+       this.limitPreferenceName = config.limitPreferenceName;
+       this.collapsedPreferenceName = config.collapsedPreferenceName;
+       this.normalizeTarget = !!config.normalizeTarget;
+
+       this.pollingRate = require( './config.json' ).StructuredChangeFiltersLiveUpdatePollingRate;
+
+       this.requestCounter = {};
+       this.baseFilterState = {};
+       this.uriProcessor = null;
+       this.initialized = false;
+       this.wereSavedQueriesSaved = false;
+
+       this.prevLoggedItems = [];
+
+       this.FILTER_CHANGE = 'filterChange';
+       this.SHOW_NEW_CHANGES = 'showNewChanges';
+       this.LIVE_UPDATE = 'liveUpdate';
+};
+
+/* Initialization */
+OO.initClass( Controller );
+
+/**
+ * Initialize the filter and parameter states
+ *
+ * @param {Array} filterStructure Filter definition and structure for the model
+ * @param {Object} [namespaceStructure] Namespace definition
+ * @param {Object} [tagList] Tag definition
+ * @param {Object} [conditionalViews] Conditional view definition
+ */
+Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
+       var parsedSavedQueries, pieces,
+               displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
+               defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
+               controller = this,
+               views = $.extend( true, {}, conditionalViews ),
+               items = [],
+               uri = new mw.Uri();
+
+       // Prepare views
+       if ( namespaceStructure ) {
+               items = [];
+               // eslint-disable-next-line no-jquery/no-each-util
+               $.each( namespaceStructure, function ( namespaceID, label ) {
+                       // Build and clean up the individual namespace items definition
+                       items.push( {
+                               name: namespaceID,
+                               label: label || mw.msg( 'blanknamespace' ),
+                               description: '',
+                               identifiers: [
+                                       mw.Title.isTalkNamespace( namespaceID ) ?
+                                               'talk' : 'subject'
+                               ],
+                               cssClass: 'mw-changeslist-ns-' + namespaceID
                        } );
+               } );
 
-                       views.namespaces = {
+               views.namespaces = {
+                       title: mw.msg( 'namespaces' ),
+                       trigger: ':',
+                       groups: [ {
+                               // Group definition (single group)
+                               name: 'namespace', // parameter name is singular
+                               type: 'string_options',
                                title: mw.msg( 'namespaces' ),
-                               trigger: ':',
-                               groups: [ {
-                                       // Group definition (single group)
-                                       name: 'namespace', // parameter name is singular
-                                       type: 'string_options',
-                                       title: mw.msg( 'namespaces' ),
-                                       labelPrefixKey: { default: 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
-                                       separator: ';',
-                                       fullCoverage: true,
-                                       filters: items
-                               } ]
-                       };
-                       views.invert = {
-                               groups: [
-                                       {
-                                               name: 'invertGroup',
-                                               type: 'boolean',
-                                               hidden: true,
-                                               filters: [ {
-                                                       name: 'invert',
-                                                       default: '0'
-                                               } ]
-                                       } ]
-                       };
-               }
-               if ( tagList ) {
-                       views.tags = {
-                               title: mw.msg( 'rcfilters-view-tags' ),
-                               trigger: '#',
-                               groups: [ {
-                                       // Group definition (single group)
-                                       name: 'tagfilter', // Parameter name
-                                       type: 'string_options',
-                                       title: 'rcfilters-view-tags', // Message key
-                                       labelPrefixKey: 'rcfilters-tag-prefix-tags',
-                                       separator: '|',
-                                       fullCoverage: false,
-                                       filters: tagList
-                               } ]
-                       };
-               }
-
-               // Add parameter range operations
-               views.range = {
-                       groups: [
-                               {
-                                       name: 'limit',
-                                       type: 'single_option',
-                                       title: '', // Because it's a hidden group, this title actually appears nowhere
-                                       hidden: true,
-                                       allowArbitrary: true,
-                                       // FIXME: $.isNumeric is deprecated
-                                       validate: $.isNumeric,
-                                       range: {
-                                               min: 0, // The server normalizes negative numbers to 0 results
-                                               max: 1000
-                                       },
-                                       sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
-                                       default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
-                                       sticky: true,
-                                       filters: displayConfig.limitArray.map( function ( num ) {
-                                               return controller._createFilterDataFromNumber( num, num );
-                                       } )
-                               },
-                               {
-                                       name: 'days',
-                                       type: 'single_option',
-                                       title: '', // Because it's a hidden group, this title actually appears nowhere
-                                       hidden: true,
-                                       allowArbitrary: true,
-                                       // FIXME: $.isNumeric is deprecated
-                                       validate: $.isNumeric,
-                                       range: {
-                                               min: 0,
-                                               max: displayConfig.maxDays
-                                       },
-                                       sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
-                                       numToLabelFunc: function ( i ) {
-                                               return Number( i ) < 1 ?
-                                                       ( Number( i ) * 24 ).toFixed( 2 ) :
-                                                       Number( i );
-                                       },
-                                       default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
-                                       sticky: true,
-                                       filters: [
-                                               // Hours (1, 2, 6, 12)
-                                               0.04166, 0.0833, 0.25, 0.5
-                                       // Days
-                                       ].concat( displayConfig.daysArray )
-                                               .map( function ( num ) {
-                                                       return controller._createFilterDataFromNumber(
-                                                               num,
-                                                               // Convert fractions of days to number of hours for the labels
-                                                               num < 1 ? Math.round( num * 24 ) : num
-                                                       );
-                                               } )
-                               }
-                       ]
+                               labelPrefixKey: { default: 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+                               separator: ';',
+                               fullCoverage: true,
+                               filters: items
+                       } ]
                };
-
-               views.display = {
+               views.invert = {
                        groups: [
                                {
-                                       name: 'display',
+                                       name: 'invertGroup',
                                        type: 'boolean',
-                                       title: '', // Because it's a hidden group, this title actually appears nowhere
                                        hidden: true,
-                                       sticky: true,
-                                       filters: [
-                                               {
-                                                       name: 'enhanced',
-                                                       default: String( mw.user.options.get( 'usenewrc', 0 ) )
-                                               }
-                                       ]
-                               }
-                       ]
+                                       filters: [ {
+                                               name: 'invert',
+                                               default: '0'
+                                       } ]
+                               } ]
                };
-
-               // Before we do anything, we need to see if we require additional items in the
-               // groups that have 'AllowArbitrary'. For the moment, those are only single_option
-               // groups; if we ever expand it, this might need further generalization:
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( views, function ( viewName, viewData ) {
-                       viewData.groups.forEach( function ( groupData ) {
-                               var extraValues = [];
-                               if ( groupData.allowArbitrary ) {
-                                       // If the value in the URI isn't in the group, add it
-                                       if ( uri.query[ groupData.name ] !== undefined ) {
-                                               extraValues.push( uri.query[ groupData.name ] );
-                                       }
-                                       // If the default value isn't in the group, add it
-                                       if ( groupData.default !== undefined ) {
-                                               extraValues.push( String( groupData.default ) );
-                                       }
-                                       controller.addNumberValuesToGroup( groupData, extraValues );
-                               }
-                       } );
-               } );
-
-               // Initialize the model
-               this.filtersModel.initializeFilters( filterStructure, views );
-
-               this.uriProcessor = new UriProcessor(
-                       this.filtersModel,
-                       { normalizeTarget: this.normalizeTarget }
-               );
-
-               if ( !mw.user.isAnon() ) {
-                       try {
-                               parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
-                       } catch ( err ) {
-                               parsedSavedQueries = {};
-                       }
-
-                       // Initialize saved queries
-                       this.savedQueriesModel.initialize( parsedSavedQueries );
-                       if ( this.savedQueriesModel.isConverted() ) {
-                               // Since we know we converted, we're going to re-save
-                               // the queries so they are now migrated to the new format
-                               this._saveSavedQueries();
-                       }
-               }
-
-               if ( defaultSavedQueryExists ) {
-                       // This came from the server, meaning that we have a default
-                       // saved query, but the server could not load it, probably because
-                       // it was pre-conversion to the new format.
-                       // We need to load this query again
-                       this.applySavedQuery( this.savedQueriesModel.getDefault() );
-               } else {
-                       // There are either recognized parameters in the URL
-                       // or there are none, but there is also no default
-                       // saved query (so defaults are from the backend)
-                       // We want to update the state but not fetch results
-                       // again
-                       this.updateStateFromUrl( false );
-
-                       pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
-
-                       // Update the changes list with the existing data
-                       // so it gets processed
-                       this.changesListModel.update(
-                               pieces.changes,
-                               pieces.fieldset,
-                               pieces.noResultsDetails,
-                               true // We're using existing DOM elements
-                       );
-               }
-
-               this.initialized = true;
-               this.switchView( 'default' );
-
-               if ( this.pollingRate ) {
-                       this._scheduleLiveUpdate();
-               }
-       };
-
-       /**
-        * Check if the controller has finished initializing.
-        * @return {boolean} Controller is initialized
-        */
-       Controller.prototype.isInitialized = function () {
-               return this.initialized;
-       };
-
-       /**
-        * Extracts information from the changes list DOM
-        *
-        * @param {jQuery} $root Root DOM to find children from
-        * @param {boolean} [statusCode] Server response status code
-        * @return {Object} Information about changes list
-        * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
-        *   (either normally or as an error)
-        * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
-        *   'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
-        * @return {jQuery} return.fieldset Fieldset
-        */
-       Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
-               var info,
-                       $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
-                       areResults = !!$changesListContents.length,
-                       checkForLogout = !areResults && statusCode === 200;
-
-               // We check if user logged out on different tab/browser or the session has expired.
-               // 205 status code returned from the server, which indicates that we need to reload the page
-               // is not usable on WL page, because we get redirected to login page, which gives 200 OK
-               // status code (if everything else goes well).
-               // Bug: T177717
-               if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
-                       location.reload( false );
-                       return;
-               }
-
-               info = {
-                       changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
-                       fieldset: $root.find( 'fieldset.cloptions' ).first()
+       }
+       if ( tagList ) {
+               views.tags = {
+                       title: mw.msg( 'rcfilters-view-tags' ),
+                       trigger: '#',
+                       groups: [ {
+                               // Group definition (single group)
+                               name: 'tagfilter', // Parameter name
+                               type: 'string_options',
+                               title: 'rcfilters-view-tags', // Message key
+                               labelPrefixKey: 'rcfilters-tag-prefix-tags',
+                               separator: '|',
+                               fullCoverage: false,
+                               filters: tagList
+                       } ]
                };
+       }
 
-               if ( !areResults ) {
-                       if ( $root.find( '.mw-changeslist-timeout' ).length ) {
-                               info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
-                       } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
-                               info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
-                       } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
-                               info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
-                       } else {
-                               info.noResultsDetails = 'NO_RESULTS_NORMAL';
+       // Add parameter range operations
+       views.range = {
+               groups: [
+                       {
+                               name: 'limit',
+                               type: 'single_option',
+                               title: '', // Because it's a hidden group, this title actually appears nowhere
+                               hidden: true,
+                               allowArbitrary: true,
+                               // FIXME: $.isNumeric is deprecated
+                               validate: $.isNumeric,
+                               range: {
+                                       min: 0, // The server normalizes negative numbers to 0 results
+                                       max: 1000
+                               },
+                               sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
+                               default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
+                               sticky: true,
+                               filters: displayConfig.limitArray.map( function ( num ) {
+                                       return controller._createFilterDataFromNumber( num, num );
+                               } )
+                       },
+                       {
+                               name: 'days',
+                               type: 'single_option',
+                               title: '', // Because it's a hidden group, this title actually appears nowhere
+                               hidden: true,
+                               allowArbitrary: true,
+                               // FIXME: $.isNumeric is deprecated
+                               validate: $.isNumeric,
+                               range: {
+                                       min: 0,
+                                       max: displayConfig.maxDays
+                               },
+                               sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
+                               numToLabelFunc: function ( i ) {
+                                       return Number( i ) < 1 ?
+                                               ( Number( i ) * 24 ).toFixed( 2 ) :
+                                               Number( i );
+                               },
+                               default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
+                               sticky: true,
+                               filters: [
+                                       // Hours (1, 2, 6, 12)
+                                       0.04166, 0.0833, 0.25, 0.5
+                               // Days
+                               ].concat( displayConfig.daysArray )
+                                       .map( function ( num ) {
+                                               return controller._createFilterDataFromNumber(
+                                                       num,
+                                                       // Convert fractions of days to number of hours for the labels
+                                                       num < 1 ? Math.round( num * 24 ) : num
+                                               );
+                                       } )
                        }
-               }
-
-               return info;
+               ]
        };
 
-       /**
-        * Create filter data from a number, for the filters that are numerical value
-        *
-        * @param {number} num Number
-        * @param {number} numForDisplay Number for the label
-        * @return {Object} Filter data
-        */
-       Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
-               return {
-                       name: String( num ),
-                       label: mw.language.convertNumber( numForDisplay )
-               };
+       views.display = {
+               groups: [
+                       {
+                               name: 'display',
+                               type: 'boolean',
+                               title: '', // Because it's a hidden group, this title actually appears nowhere
+                               hidden: true,
+                               sticky: true,
+                               filters: [
+                                       {
+                                               name: 'enhanced',
+                                               default: String( mw.user.options.get( 'usenewrc', 0 ) )
+                                       }
+                               ]
+                       }
+               ]
        };
 
-       /**
-        * Add an arbitrary values to groups that allow arbitrary values
-        *
-        * @param {Object} groupData Group data
-        * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
-        */
-       Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
-               var controller = this,
-                       normalizeWithinRange = function ( range, val ) {
-                               if ( val < range.min ) {
-                                       return range.min; // Min
-                               } else if ( val >= range.max ) {
-                                       return range.max; // Max
+       // Before we do anything, we need to see if we require additional items in the
+       // groups that have 'AllowArbitrary'. For the moment, those are only single_option
+       // groups; if we ever expand it, this might need further generalization:
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( views, function ( viewName, viewData ) {
+               viewData.groups.forEach( function ( groupData ) {
+                       var extraValues = [];
+                       if ( groupData.allowArbitrary ) {
+                               // If the value in the URI isn't in the group, add it
+                               if ( uri.query[ groupData.name ] !== undefined ) {
+                                       extraValues.push( uri.query[ groupData.name ] );
                                }
-                               return val;
-                       };
-
-               arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
-
-               // Normalize the arbitrary values and the default value for a range
-               if ( groupData.range ) {
-                       arbitraryValues = arbitraryValues.map( function ( val ) {
-                               return normalizeWithinRange( groupData.range, val );
-                       } );
-
-                       // Normalize the default, since that's user defined
-                       if ( groupData.default !== undefined ) {
-                               groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
-                       }
-               }
-
-               // This is only true for single_option group
-               // We assume these are the only groups that will allow for
-               // arbitrary, since it doesn't make any sense for the other
-               // groups.
-               arbitraryValues.forEach( function ( val ) {
-                       if (
-                               // If the group allows for arbitrary data
-                               groupData.allowArbitrary &&
-                               // and it is single_option (or string_options, but we
-                               // don't have cases of those yet, nor do we plan to)
-                               groupData.type === 'single_option' &&
-                               // and, if there is a validate method and it passes on
-                               // the data
-                               ( !groupData.validate || groupData.validate( val ) ) &&
-                               // but if that value isn't already in the definition
-                               groupData.filters
-                                       .map( function ( filterData ) {
-                                               return String( filterData.name );
-                                       } )
-                                       .indexOf( String( val ) ) === -1
-                       ) {
-                               // Add the filter information
-                               groupData.filters.push( controller._createFilterDataFromNumber(
-                                       val,
-                                       groupData.numToLabelFunc ?
-                                               groupData.numToLabelFunc( val ) :
-                                               val
-                               ) );
-
-                               // If there's a sort function set up, re-sort the values
-                               if ( groupData.sortFunc ) {
-                                       groupData.filters.sort( groupData.sortFunc );
+                               // If the default value isn't in the group, add it
+                               if ( groupData.default !== undefined ) {
+                                       extraValues.push( String( groupData.default ) );
                                }
+                               controller.addNumberValuesToGroup( groupData, extraValues );
                        }
                } );
-       };
+       } );
 
-       /**
-        * Reset to default filters
-        */
-       Controller.prototype.resetToDefaults = function () {
-               var params = this._getDefaultParams();
-               if ( this.applyParamChange( params ) ) {
-                       // Only update the changes list if there was a change to actual filters
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL( params );
-               }
-       };
-
-       /**
-        * Check whether the default values of the filters are all false.
-        *
-        * @return {boolean} Defaults are all false
-        */
-       Controller.prototype.areDefaultsEmpty = function () {
-               return $.isEmptyObject( this._getDefaultParams() );
-       };
+       // Initialize the model
+       this.filtersModel.initializeFilters( filterStructure, views );
 
-       /**
-        * Empty all selected filters
-        */
-       Controller.prototype.emptyFilters = function () {
-               var highlightedFilterNames = this.filtersModel.getHighlightedItems()
-                       .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
+       this.uriProcessor = new UriProcessor(
+               this.filtersModel,
+               { normalizeTarget: this.normalizeTarget }
+       );
 
-               if ( this.applyParamChange( {} ) ) {
-                       // Only update the changes list if there was a change to actual filters
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL();
+       if ( !mw.user.isAnon() ) {
+               try {
+                       parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
+               } catch ( err ) {
+                       parsedSavedQueries = {};
                }
 
-               if ( highlightedFilterNames ) {
-                       this._trackHighlight( 'clearAll', highlightedFilterNames );
+               // Initialize saved queries
+               this.savedQueriesModel.initialize( parsedSavedQueries );
+               if ( this.savedQueriesModel.isConverted() ) {
+                       // Since we know we converted, we're going to re-save
+                       // the queries so they are now migrated to the new format
+                       this._saveSavedQueries();
                }
+       }
+
+       if ( defaultSavedQueryExists ) {
+               // This came from the server, meaning that we have a default
+               // saved query, but the server could not load it, probably because
+               // it was pre-conversion to the new format.
+               // We need to load this query again
+               this.applySavedQuery( this.savedQueriesModel.getDefault() );
+       } else {
+               // There are either recognized parameters in the URL
+               // or there are none, but there is also no default
+               // saved query (so defaults are from the backend)
+               // We want to update the state but not fetch results
+               // again
+               this.updateStateFromUrl( false );
+
+               pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
+
+               // Update the changes list with the existing data
+               // so it gets processed
+               this.changesListModel.update(
+                       pieces.changes,
+                       pieces.fieldset,
+                       pieces.noResultsDetails,
+                       true // We're using existing DOM elements
+               );
+       }
+
+       this.initialized = true;
+       this.switchView( 'default' );
+
+       if ( this.pollingRate ) {
+               this._scheduleLiveUpdate();
+       }
+};
+
+/**
+ * Check if the controller has finished initializing.
+ * @return {boolean} Controller is initialized
+ */
+Controller.prototype.isInitialized = function () {
+       return this.initialized;
+};
+
+/**
+ * Extracts information from the changes list DOM
+ *
+ * @param {jQuery} $root Root DOM to find children from
+ * @param {boolean} [statusCode] Server response status code
+ * @return {Object} Information about changes list
+ * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
+ *   (either normally or as an error)
+ * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
+ *   'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
+ * @return {jQuery} return.fieldset Fieldset
+ */
+Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
+       var info,
+               $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
+               areResults = !!$changesListContents.length,
+               checkForLogout = !areResults && statusCode === 200;
+
+       // We check if user logged out on different tab/browser or the session has expired.
+       // 205 status code returned from the server, which indicates that we need to reload the page
+       // is not usable on WL page, because we get redirected to login page, which gives 200 OK
+       // status code (if everything else goes well).
+       // Bug: T177717
+       if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
+               location.reload( false );
+               return;
+       }
+
+       info = {
+               changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
+               fieldset: $root.find( 'fieldset.cloptions' ).first()
        };
 
-       /**
-        * Update the selected state of a filter
-        *
-        * @param {string} filterName Filter name
-        * @param {boolean} [isSelected] Filter selected state
-        */
-       Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
-               var filterItem = this.filtersModel.getItemByName( filterName );
-
-               if ( !filterItem ) {
-                       // If no filter was found, break
-                       return;
-               }
-
-               isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
-
-               if ( filterItem.isSelected() !== isSelected ) {
-                       this.filtersModel.toggleFilterSelected( filterName, isSelected );
-
-                       this.updateChangesList();
-
-                       // Check filter interactions
-                       this.filtersModel.reassessFilterInteractions( filterItem );
+       if ( !areResults ) {
+               if ( $root.find( '.mw-changeslist-timeout' ).length ) {
+                       info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
+               } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
+                       info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
+               } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
+                       info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
+               } else {
+                       info.noResultsDetails = 'NO_RESULTS_NORMAL';
                }
+       }
+
+       return info;
+};
+
+/**
+ * Create filter data from a number, for the filters that are numerical value
+ *
+ * @param {number} num Number
+ * @param {number} numForDisplay Number for the label
+ * @return {Object} Filter data
+ */
+Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
+       return {
+               name: String( num ),
+               label: mw.language.convertNumber( numForDisplay )
        };
-
-       /**
-        * Clear both highlight and selection of a filter
-        *
-        * @param {string} filterName Name of the filter item
-        */
-       Controller.prototype.clearFilter = function ( filterName ) {
-               var filterItem = this.filtersModel.getItemByName( filterName ),
-                       isHighlighted = filterItem.isHighlighted(),
-                       isSelected = filterItem.isSelected();
-
-               if ( isSelected || isHighlighted ) {
-                       this.filtersModel.clearHighlightColor( filterName );
-                       this.filtersModel.toggleFilterSelected( filterName, false );
-
-                       if ( isSelected ) {
-                               // Only update the changes list if the filter changed
-                               // its selection state. If it only changed its highlight
-                               // then don't reload
-                               this.updateChangesList();
+};
+
+/**
+ * Add an arbitrary values to groups that allow arbitrary values
+ *
+ * @param {Object} groupData Group data
+ * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
+ */
+Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
+       var controller = this,
+               normalizeWithinRange = function ( range, val ) {
+                       if ( val < range.min ) {
+                               return range.min; // Min
+                       } else if ( val >= range.max ) {
+                               return range.max; // Max
                        }
+                       return val;
+               };
 
-                       this.filtersModel.reassessFilterInteractions( filterItem );
-
-                       // Log filter grouping
-                       this.trackFilterGroupings( 'removefilter' );
-               }
-
-               if ( isHighlighted ) {
-                       this._trackHighlight( 'clear', filterName );
-               }
-       };
+       arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
 
-       /**
-        * Toggle the highlight feature on and off
-        */
-       Controller.prototype.toggleHighlight = function () {
-               this.filtersModel.toggleHighlight();
-               this.uriProcessor.updateURL();
+       // Normalize the arbitrary values and the default value for a range
+       if ( groupData.range ) {
+               arbitraryValues = arbitraryValues.map( function ( val ) {
+                       return normalizeWithinRange( groupData.range, val );
+               } );
 
-               if ( this.filtersModel.isHighlightEnabled() ) {
-                       mw.hook( 'RcFilters.highlight.enable' ).fire();
+               // Normalize the default, since that's user defined
+               if ( groupData.default !== undefined ) {
+                       groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
                }
-       };
+       }
 
-       /**
-        * Toggle the namespaces inverted feature on and off
-        */
-       Controller.prototype.toggleInvertedNamespaces = function () {
-               this.filtersModel.toggleInvertedNamespaces();
+       // This is only true for single_option group
+       // We assume these are the only groups that will allow for
+       // arbitrary, since it doesn't make any sense for the other
+       // groups.
+       arbitraryValues.forEach( function ( val ) {
                if (
-                       this.filtersModel.getFiltersByView( 'namespaces' ).filter(
-                               function ( filterItem ) { return filterItem.isSelected(); }
-                       ).length
+                       // If the group allows for arbitrary data
+                       groupData.allowArbitrary &&
+                       // and it is single_option (or string_options, but we
+                       // don't have cases of those yet, nor do we plan to)
+                       groupData.type === 'single_option' &&
+                       // and, if there is a validate method and it passes on
+                       // the data
+                       ( !groupData.validate || groupData.validate( val ) ) &&
+                       // but if that value isn't already in the definition
+                       groupData.filters
+                               .map( function ( filterData ) {
+                                       return String( filterData.name );
+                               } )
+                               .indexOf( String( val ) ) === -1
                ) {
-                       // Only re-fetch results if there are namespace items that are actually selected
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL();
-               }
-       };
-
-       /**
-        * Set the value of the 'showlinkedto' parameter
-        * @param {boolean} value
-        */
-       Controller.prototype.setShowLinkedTo = function ( value ) {
-               var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
-                       showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
-
-               this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
-               this.uriProcessor.updateURL();
-               // reload the results only when target is set
-               if ( targetItem.getValue() ) {
-                       this.updateChangesList();
+                       // Add the filter information
+                       groupData.filters.push( controller._createFilterDataFromNumber(
+                               val,
+                               groupData.numToLabelFunc ?
+                                       groupData.numToLabelFunc( val ) :
+                                       val
+                       ) );
+
+                       // If there's a sort function set up, re-sort the values
+                       if ( groupData.sortFunc ) {
+                               groupData.filters.sort( groupData.sortFunc );
+                       }
                }
-       };
-
-       /**
-        * Set the target page
-        * @param {string} page
       */
-       Controller.prototype.setTargetPage = function ( page ) {
-               var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
-               targetItem.setValue( page );
-               this.uriProcessor.updateURL();
+       } );
+};
+
+/**
+ * Reset to default filters
+ */
+Controller.prototype.resetToDefaults = function () {
+       var params = this._getDefaultParams();
+       if ( this.applyParamChange( params ) ) {
+               // Only update the changes list if there was a change to actual filters
                this.updateChangesList();
-       };
-
-       /**
-        * Set the highlight color for a filter item
-        *
-        * @param {string} filterName Name of the filter item
-        * @param {string} color Selected color
-        */
-       Controller.prototype.setHighlightColor = function ( filterName, color ) {
-               this.filtersModel.setHighlightColor( filterName, color );
-               this.uriProcessor.updateURL();
-               this._trackHighlight( 'set', { name: filterName, color: color } );
-       };
-
-       /**
-        * Clear highlight for a filter item
-        *
-        * @param {string} filterName Name of the filter item
-        */
-       Controller.prototype.clearHighlightColor = function ( filterName ) {
-               this.filtersModel.clearHighlightColor( filterName );
+       } else {
+               this.uriProcessor.updateURL( params );
+       }
+};
+
+/**
+ * Check whether the default values of the filters are all false.
+ *
+ * @return {boolean} Defaults are all false
+ */
+Controller.prototype.areDefaultsEmpty = function () {
+       return $.isEmptyObject( this._getDefaultParams() );
+};
+
+/**
+ * Empty all selected filters
+ */
+Controller.prototype.emptyFilters = function () {
+       var highlightedFilterNames = this.filtersModel.getHighlightedItems()
+               .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
+
+       if ( this.applyParamChange( {} ) ) {
+               // Only update the changes list if there was a change to actual filters
+               this.updateChangesList();
+       } else {
                this.uriProcessor.updateURL();
-               this._trackHighlight( 'clear', filterName );
-       };
+       }
 
-       /**
-        * Enable or disable live updates.
-        * @param {boolean} enable True to enable, false to disable
-        */
-       Controller.prototype.toggleLiveUpdate = function ( enable ) {
-               this.changesListModel.toggleLiveUpdate( enable );
-               if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
-                       this.updateChangesList( null, this.LIVE_UPDATE );
-               }
-       };
+       if ( highlightedFilterNames ) {
+               this._trackHighlight( 'clearAll', highlightedFilterNames );
+       }
+};
 
-       /**
-        * Set a timeout for the next live update.
-        * @private
-        */
-       Controller.prototype._scheduleLiveUpdate = function () {
-               setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
-       };
+/**
+ * Update the selected state of a filter
+ *
+ * @param {string} filterName Filter name
+ * @param {boolean} [isSelected] Filter selected state
+ */
+Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
+       var filterItem = this.filtersModel.getItemByName( filterName );
 
-       /**
-        * Perform a live update.
-        * @private
-        */
-       Controller.prototype._doLiveUpdate = function () {
-               if ( !this._shouldCheckForNewChanges() ) {
-                       // skip this turn and check back later
-                       this._scheduleLiveUpdate();
-                       return;
-               }
+       if ( !filterItem ) {
+               // If no filter was found, break
+               return;
+       }
 
-               this._checkForNewChanges()
-                       .then( function ( statusCode ) {
-                               // no result is 204 with the 'peek' param
-                               // logged out is 205
-                               var newChanges = statusCode === 200;
+       isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
 
-                               if ( !this._shouldCheckForNewChanges() ) {
-                                       // by the time the response is received,
-                                       // it may not be appropriate anymore
-                                       return;
-                               }
+       if ( filterItem.isSelected() !== isSelected ) {
+               this.filtersModel.toggleFilterSelected( filterName, isSelected );
 
-                               // 205 is the status code returned from server when user's logged in/out
-                               // status is not matching while fetching live update changes.
-                               // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
-                               // Bug: T177717
-                               if ( statusCode === 205 ) {
-                                       location.reload( false );
-                                       return;
-                               }
-
-                               if ( newChanges ) {
-                                       if ( this.changesListModel.getLiveUpdate() ) {
-                                               return this.updateChangesList( null, this.LIVE_UPDATE );
-                                       } else {
-                                               this.changesListModel.setNewChangesExist( true );
-                                       }
-                               }
-                       }.bind( this ) )
-                       .always( this._scheduleLiveUpdate.bind( this ) );
-       };
-
-       /**
-        * @return {boolean} It's appropriate to check for new changes now
-        * @private
-        */
-       Controller.prototype._shouldCheckForNewChanges = function () {
-               return !document.hidden &&
-                       !this.filtersModel.hasConflict() &&
-                       !this.changesListModel.getNewChangesExist() &&
-                       !this.updatingChangesList &&
-                       this.changesListModel.getNextFrom();
-       };
-
-       /**
-        * Check if new changes, newer than those currently shown, are available
-        *
-        * @return {jQuery.Promise} Promise object that resolves with a bool
-        *   specifying if there are new changes or not
-        *
-        * @private
-        */
-       Controller.prototype._checkForNewChanges = function () {
-               var params = {
-                       limit: 1,
-                       peek: 1, // bypasses ChangesList specific UI
-                       from: this.changesListModel.getNextFrom(),
-                       isAnon: mw.user.isAnon()
-               };
-               return this._queryChangesList( 'liveUpdate', params ).then(
-                       function ( data ) {
-                               return data.status;
-                       }
-               );
-       };
-
-       /**
-        * Show the new changes
-        *
-        * @return {jQuery.Promise} Promise object that resolves after
-        * fetching and showing the new changes
-        */
-       Controller.prototype.showNewChanges = function () {
-               return this.updateChangesList( null, this.SHOW_NEW_CHANGES );
-       };
-
-       /**
-        * Save the current model state as a saved query
-        *
-        * @param {string} [label] Label of the saved query
-        * @param {boolean} [setAsDefault=false] This query should be set as the default
-        */
-       Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
-               // Add item
-               this.savedQueriesModel.addNewQuery(
-                       label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
-                       this.filtersModel.getCurrentParameterState( true ),
-                       setAsDefault
-               );
-
-               // Save item
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Remove a saved query
-        *
-        * @param {string} queryID Query id
-        */
-       Controller.prototype.removeSavedQuery = function ( queryID ) {
-               this.savedQueriesModel.removeQuery( queryID );
-
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Rename a saved query
-        *
-        * @param {string} queryID Query id
-        * @param {string} newLabel New label for the query
-        */
-       Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
-               var queryItem = this.savedQueriesModel.getItemByID( queryID );
-
-               if ( queryItem ) {
-                       queryItem.updateLabel( newLabel );
-               }
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Set a saved query as default
-        *
-        * @param {string} queryID Query Id. If null is given, default
-        *  query is reset.
-        */
-       Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
-               this.savedQueriesModel.setDefault( queryID );
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Load a saved query
-        *
-        * @param {string} queryID Query id
-        */
-       Controller.prototype.applySavedQuery = function ( queryID ) {
-               var currentMatchingQuery,
-                       params = this.savedQueriesModel.getItemParams( queryID );
-
-               currentMatchingQuery = this.findQueryMatchingCurrentState();
+               this.updateChangesList();
 
-               if (
-                       currentMatchingQuery &&
-                       currentMatchingQuery.getID() === queryID
-               ) {
-                       // If the query we want to load is the one that is already
-                       // loaded, don't reload it
-                       return;
-               }
+               // Check filter interactions
+               this.filtersModel.reassessFilterInteractions( filterItem );
+       }
+};
+
+/**
+ * Clear both highlight and selection of a filter
+ *
+ * @param {string} filterName Name of the filter item
+ */
+Controller.prototype.clearFilter = function ( filterName ) {
+       var filterItem = this.filtersModel.getItemByName( filterName ),
+               isHighlighted = filterItem.isHighlighted(),
+               isSelected = filterItem.isSelected();
+
+       if ( isSelected || isHighlighted ) {
+               this.filtersModel.clearHighlightColor( filterName );
+               this.filtersModel.toggleFilterSelected( filterName, false );
 
-               if ( this.applyParamChange( params ) ) {
-                       // Update changes list only if there was a difference in filter selection
+               if ( isSelected ) {
+                       // Only update the changes list if the filter changed
+                       // its selection state. If it only changed its highlight
+                       // then don't reload
                        this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL( params );
-               }
-
-               // Log filter grouping
-               this.trackFilterGroupings( 'savedfilters' );
-       };
-
-       /**
-        * Check whether the current filter and highlight state exists
-        * in the saved queries model.
-        *
-        * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
-        */
-       Controller.prototype.findQueryMatchingCurrentState = function () {
-               return this.savedQueriesModel.findMatchingQuery(
-                       this.filtersModel.getCurrentParameterState( true )
-               );
-       };
-
-       /**
-        * Save the current state of the saved queries model with all
-        * query item representation in the user settings.
-        */
-       Controller.prototype._saveSavedQueries = function () {
-               var stringified, oldPrefValue,
-                       backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
-                       state = this.savedQueriesModel.getState();
-
-               // Stringify state
-               stringified = JSON.stringify( state );
-
-               if ( byteLength( stringified ) > 65535 ) {
-                       // Sanity check, since the preference can only hold that.
-                       return;
                }
 
-               if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
-                       // The queries were converted from the previous version
-                       // Keep the old string in the [prefname]-versionbackup
-                       oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
+               this.filtersModel.reassessFilterInteractions( filterItem );
 
-                       // Save the old preference in the backup preference
-                       new mw.Api().saveOption( backupPrefName, oldPrefValue );
-                       // Update the preference for this session
-                       mw.user.options.set( backupPrefName, oldPrefValue );
-               }
-
-               // Save the preference
-               new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
-               // Update the preference for this session
-               mw.user.options.set( this.savedQueriesPreferenceName, stringified );
-
-               // Tag as already saved so we don't do this again
-               this.wereSavedQueriesSaved = true;
-       };
-
-       /**
-        * Update sticky preferences with current model state
-        */
-       Controller.prototype.updateStickyPreferences = function () {
-               // Update default sticky values with selected, whether they came from
-               // the initial defaults or from the URL value that is being normalized
-               this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).findSelectedItems()[ 0 ].getParamName() );
-               this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );
-
-               // TODO: Make these automatic by having the model go over sticky
-               // items and update their default values automatically
-       };
-
-       /**
-        * Update the limit default value
-        *
-        * @param {number} newValue New value
-        */
-       Controller.prototype.updateLimitDefault = function ( newValue ) {
-               this.updateNumericPreference( this.limitPreferenceName, newValue );
-       };
-
-       /**
-        * Update the days default value
-        *
-        * @param {number} newValue New value
-        */
-       Controller.prototype.updateDaysDefault = function ( newValue ) {
-               this.updateNumericPreference( this.daysPreferenceName, newValue );
-       };
-
-       /**
-        * Update the group by page default value
-        *
-        * @param {boolean} newValue New value
-        */
-       Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
-               this.updateNumericPreference( 'usenewrc', Number( newValue ) );
-       };
-
-       /**
-        * Update the collapsed state value
-        *
-        * @param {boolean} isCollapsed Filter area is collapsed
-        */
-       Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
-               this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
-       };
-
-       /**
-        * Update a numeric preference with a new value
-        *
-        * @param {string} prefName Preference name
-        * @param {number|string} newValue New value
-        */
-       Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
-               // FIXME: $.isNumeric is deprecated
-               // eslint-disable-next-line no-jquery/no-is-numeric
-               if ( !$.isNumeric( newValue ) ) {
-                       return;
-               }
-
-               newValue = Number( newValue );
-
-               if ( mw.user.options.get( prefName ) !== newValue ) {
-                       // Save the preference
-                       new mw.Api().saveOption( prefName, newValue );
-                       // Update the preference for this session
-                       mw.user.options.set( prefName, newValue );
-               }
-       };
+               // Log filter grouping
+               this.trackFilterGroupings( 'removefilter' );
+       }
 
-       /**
-        * Synchronize the URL with the current state of the filters
-        * without adding an history entry.
-        */
-       Controller.prototype.replaceUrl = function () {
+       if ( isHighlighted ) {
+               this._trackHighlight( 'clear', filterName );
+       }
+};
+
+/**
+ * Toggle the highlight feature on and off
+ */
+Controller.prototype.toggleHighlight = function () {
+       this.filtersModel.toggleHighlight();
+       this.uriProcessor.updateURL();
+
+       if ( this.filtersModel.isHighlightEnabled() ) {
+               mw.hook( 'RcFilters.highlight.enable' ).fire();
+       }
+};
+
+/**
+ * Toggle the namespaces inverted feature on and off
+ */
+Controller.prototype.toggleInvertedNamespaces = function () {
+       this.filtersModel.toggleInvertedNamespaces();
+       if (
+               this.filtersModel.getFiltersByView( 'namespaces' ).filter(
+                       function ( filterItem ) { return filterItem.isSelected(); }
+               ).length
+       ) {
+               // Only re-fetch results if there are namespace items that are actually selected
+               this.updateChangesList();
+       } else {
                this.uriProcessor.updateURL();
-       };
-
-       /**
-        * Update filter state (selection and highlighting) based
-        * on current URL values.
-        *
-        * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
-        *  list based on the updated model.
-        */
-       Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
-               fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
-
-               this.uriProcessor.updateModelBasedOnQuery();
-
-               // Update the sticky preferences, in case we received a value
-               // from the URL
-               this.updateStickyPreferences();
+       }
+};
+
+/**
+ * Set the value of the 'showlinkedto' parameter
+ * @param {boolean} value
+ */
+Controller.prototype.setShowLinkedTo = function ( value ) {
+       var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
+               showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
+
+       this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
+       this.uriProcessor.updateURL();
+       // reload the results only when target is set
+       if ( targetItem.getValue() ) {
+               this.updateChangesList();
+       }
+};
+
+/**
+ * Set the target page
+ * @param {string} page
+ */
+Controller.prototype.setTargetPage = function ( page ) {
+       var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
+       targetItem.setValue( page );
+       this.uriProcessor.updateURL();
+       this.updateChangesList();
+};
+
+/**
+ * Set the highlight color for a filter item
+ *
+ * @param {string} filterName Name of the filter item
+ * @param {string} color Selected color
+ */
+Controller.prototype.setHighlightColor = function ( filterName, color ) {
+       this.filtersModel.setHighlightColor( filterName, color );
+       this.uriProcessor.updateURL();
+       this._trackHighlight( 'set', { name: filterName, color: color } );
+};
+
+/**
+ * Clear highlight for a filter item
+ *
+ * @param {string} filterName Name of the filter item
+ */
+Controller.prototype.clearHighlightColor = function ( filterName ) {
+       this.filtersModel.clearHighlightColor( filterName );
+       this.uriProcessor.updateURL();
+       this._trackHighlight( 'clear', filterName );
+};
+
+/**
+ * Enable or disable live updates.
+ * @param {boolean} enable True to enable, false to disable
+ */
+Controller.prototype.toggleLiveUpdate = function ( enable ) {
+       this.changesListModel.toggleLiveUpdate( enable );
+       if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
+               this.updateChangesList( null, this.LIVE_UPDATE );
+       }
+};
+
+/**
+ * Set a timeout for the next live update.
+ * @private
+ */
+Controller.prototype._scheduleLiveUpdate = function () {
+       setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
+};
+
+/**
+ * Perform a live update.
+ * @private
+ */
+Controller.prototype._doLiveUpdate = function () {
+       if ( !this._shouldCheckForNewChanges() ) {
+               // skip this turn and check back later
+               this._scheduleLiveUpdate();
+               return;
+       }
+
+       this._checkForNewChanges()
+               .then( function ( statusCode ) {
+                       // no result is 204 with the 'peek' param
+                       // logged out is 205
+                       var newChanges = statusCode === 200;
+
+                       if ( !this._shouldCheckForNewChanges() ) {
+                               // by the time the response is received,
+                               // it may not be appropriate anymore
+                               return;
+                       }
 
-               // Only update and fetch new results if it is requested
-               if ( fetchChangesList ) {
-                       this.updateChangesList();
-               }
-       };
+                       // 205 is the status code returned from server when user's logged in/out
+                       // status is not matching while fetching live update changes.
+                       // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
+                       // Bug: T177717
+                       if ( statusCode === 205 ) {
+                               location.reload( false );
+                               return;
+                       }
 
-       /**
-        * Update the list of changes and notify the model
-        *
-        * @param {Object} [params] Extra parameters to add to the API call
-        * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
-        * @return {jQuery.Promise} Promise that is resolved when the update is complete
-        */
-       Controller.prototype.updateChangesList = function ( params, updateMode ) {
-               updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
-
-               if ( updateMode === this.FILTER_CHANGE ) {
-                       this.uriProcessor.updateURL( params );
-               }
-               if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
-                       this.changesListModel.invalidate();
-               }
-               this.changesListModel.setNewChangesExist( false );
-               this.updatingChangesList = true;
-               return this._fetchChangesList()
-                       .then(
-                               // Success
-                               function ( pieces ) {
-                                       var $changesListContent = pieces.changes,
-                                               $fieldset = pieces.fieldset;
-                                       this.changesListModel.update(
-                                               $changesListContent,
-                                               $fieldset,
-                                               pieces.noResultsDetails,
-                                               false,
-                                               // separator between old and new changes
-                                               updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
-                                       );
-                               }.bind( this )
-                               // Do nothing for failure
-                       )
-                       .always( function () {
-                               this.updatingChangesList = false;
-                       }.bind( this ) );
+                       if ( newChanges ) {
+                               if ( this.changesListModel.getLiveUpdate() ) {
+                                       return this.updateChangesList( null, this.LIVE_UPDATE );
+                               } else {
+                                       this.changesListModel.setNewChangesExist( true );
+                               }
+                       }
+               }.bind( this ) )
+               .always( this._scheduleLiveUpdate.bind( this ) );
+};
+
+/**
+ * @return {boolean} It's appropriate to check for new changes now
+ * @private
+ */
+Controller.prototype._shouldCheckForNewChanges = function () {
+       return !document.hidden &&
+               !this.filtersModel.hasConflict() &&
+               !this.changesListModel.getNewChangesExist() &&
+               !this.updatingChangesList &&
+               this.changesListModel.getNextFrom();
+};
+
+/**
+ * Check if new changes, newer than those currently shown, are available
+ *
+ * @return {jQuery.Promise} Promise object that resolves with a bool
+ *   specifying if there are new changes or not
+ *
+ * @private
+ */
+Controller.prototype._checkForNewChanges = function () {
+       var params = {
+               limit: 1,
+               peek: 1, // bypasses ChangesList specific UI
+               from: this.changesListModel.getNextFrom(),
+               isAnon: mw.user.isAnon()
        };
-
-       /**
-        * Get an object representing the default parameter state, whether
-        * it is from the model defaults or from the saved queries.
-        *
-        * @return {Object} Default parameters
-        */
-       Controller.prototype._getDefaultParams = function () {
-               if ( this.savedQueriesModel.getDefault() ) {
-                       return this.savedQueriesModel.getDefaultParams();
-               } else {
-                       return this.filtersModel.getDefaultParams();
+       return this._queryChangesList( 'liveUpdate', params ).then(
+               function ( data ) {
+                       return data.status;
                }
-       };
+       );
+};
+
+/**
+ * Show the new changes
+ *
+ * @return {jQuery.Promise} Promise object that resolves after
+ * fetching and showing the new changes
+ */
+Controller.prototype.showNewChanges = function () {
+       return this.updateChangesList( null, this.SHOW_NEW_CHANGES );
+};
+
+/**
+ * Save the current model state as a saved query
+ *
+ * @param {string} [label] Label of the saved query
+ * @param {boolean} [setAsDefault=false] This query should be set as the default
+ */
+Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
+       // Add item
+       this.savedQueriesModel.addNewQuery(
+               label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
+               this.filtersModel.getCurrentParameterState( true ),
+               setAsDefault
+       );
+
+       // Save item
+       this._saveSavedQueries();
+};
+
+/**
+ * Remove a saved query
+ *
+ * @param {string} queryID Query id
+ */
+Controller.prototype.removeSavedQuery = function ( queryID ) {
+       this.savedQueriesModel.removeQuery( queryID );
+
+       this._saveSavedQueries();
+};
+
+/**
+ * Rename a saved query
+ *
+ * @param {string} queryID Query id
+ * @param {string} newLabel New label for the query
+ */
+Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
+       var queryItem = this.savedQueriesModel.getItemByID( queryID );
+
+       if ( queryItem ) {
+               queryItem.updateLabel( newLabel );
+       }
+       this._saveSavedQueries();
+};
+
+/**
+ * Set a saved query as default
+ *
+ * @param {string} queryID Query Id. If null is given, default
+ *  query is reset.
+ */
+Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
+       this.savedQueriesModel.setDefault( queryID );
+       this._saveSavedQueries();
+};
+
+/**
+ * Load a saved query
+ *
+ * @param {string} queryID Query id
+ */
+Controller.prototype.applySavedQuery = function ( queryID ) {
+       var currentMatchingQuery,
+               params = this.savedQueriesModel.getItemParams( queryID );
+
+       currentMatchingQuery = this.findQueryMatchingCurrentState();
+
+       if (
+               currentMatchingQuery &&
+               currentMatchingQuery.getID() === queryID
+       ) {
+               // If the query we want to load is the one that is already
+               // loaded, don't reload it
+               return;
+       }
+
+       if ( this.applyParamChange( params ) ) {
+               // Update changes list only if there was a difference in filter selection
+               this.updateChangesList();
+       } else {
+               this.uriProcessor.updateURL( params );
+       }
+
+       // Log filter grouping
+       this.trackFilterGroupings( 'savedfilters' );
+};
+
+/**
+ * Check whether the current filter and highlight state exists
+ * in the saved queries model.
+ *
+ * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
+ */
+Controller.prototype.findQueryMatchingCurrentState = function () {
+       return this.savedQueriesModel.findMatchingQuery(
+               this.filtersModel.getCurrentParameterState( true )
+       );
+};
+
+/**
+ * Save the current state of the saved queries model with all
+ * query item representation in the user settings.
+ */
+Controller.prototype._saveSavedQueries = function () {
+       var stringified, oldPrefValue,
+               backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
+               state = this.savedQueriesModel.getState();
+
+       // Stringify state
+       stringified = JSON.stringify( state );
+
+       if ( byteLength( stringified ) > 65535 ) {
+               // Sanity check, since the preference can only hold that.
+               return;
+       }
+
+       if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
+               // The queries were converted from the previous version
+               // Keep the old string in the [prefname]-versionbackup
+               oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
+
+               // Save the old preference in the backup preference
+               new mw.Api().saveOption( backupPrefName, oldPrefValue );
+               // Update the preference for this session
+               mw.user.options.set( backupPrefName, oldPrefValue );
+       }
+
+       // Save the preference
+       new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
+       // Update the preference for this session
+       mw.user.options.set( this.savedQueriesPreferenceName, stringified );
+
+       // Tag as already saved so we don't do this again
+       this.wereSavedQueriesSaved = true;
+};
+
+/**
+ * Update sticky preferences with current model state
+ */
+Controller.prototype.updateStickyPreferences = function () {
+       // Update default sticky values with selected, whether they came from
+       // the initial defaults or from the URL value that is being normalized
+       this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).findSelectedItems()[ 0 ].getParamName() );
+       this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );
+
+       // TODO: Make these automatic by having the model go over sticky
+       // items and update their default values automatically
+};
+
+/**
+ * Update the limit default value
+ *
+ * @param {number} newValue New value
+ */
+Controller.prototype.updateLimitDefault = function ( newValue ) {
+       this.updateNumericPreference( this.limitPreferenceName, newValue );
+};
+
+/**
+ * Update the days default value
+ *
+ * @param {number} newValue New value
+ */
+Controller.prototype.updateDaysDefault = function ( newValue ) {
+       this.updateNumericPreference( this.daysPreferenceName, newValue );
+};
+
+/**
+ * Update the group by page default value
+ *
+ * @param {boolean} newValue New value
+ */
+Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
+       this.updateNumericPreference( 'usenewrc', Number( newValue ) );
+};
+
+/**
+ * Update the collapsed state value
+ *
+ * @param {boolean} isCollapsed Filter area is collapsed
+ */
+Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
+       this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
+};
+
+/**
+ * Update a numeric preference with a new value
+ *
+ * @param {string} prefName Preference name
+ * @param {number|string} newValue New value
+ */
+Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
+       // FIXME: $.isNumeric is deprecated
+       // eslint-disable-next-line no-jquery/no-is-numeric
+       if ( !$.isNumeric( newValue ) ) {
+               return;
+       }
+
+       newValue = Number( newValue );
+
+       if ( mw.user.options.get( prefName ) !== newValue ) {
+               // Save the preference
+               new mw.Api().saveOption( prefName, newValue );
+               // Update the preference for this session
+               mw.user.options.set( prefName, newValue );
+       }
+};
+
+/**
+ * Synchronize the URL with the current state of the filters
+ * without adding an history entry.
+ */
+Controller.prototype.replaceUrl = function () {
+       this.uriProcessor.updateURL();
+};
+
+/**
+ * Update filter state (selection and highlighting) based
+ * on current URL values.
+ *
+ * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
+ *  list based on the updated model.
+ */
+Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
+       fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
+
+       this.uriProcessor.updateModelBasedOnQuery();
+
+       // Update the sticky preferences, in case we received a value
+       // from the URL
+       this.updateStickyPreferences();
+
+       // Only update and fetch new results if it is requested
+       if ( fetchChangesList ) {
+               this.updateChangesList();
+       }
+};
+
+/**
+ * Update the list of changes and notify the model
+ *
+ * @param {Object} [params] Extra parameters to add to the API call
+ * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
+ * @return {jQuery.Promise} Promise that is resolved when the update is complete
+ */
+Controller.prototype.updateChangesList = function ( params, updateMode ) {
+       updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
+
+       if ( updateMode === this.FILTER_CHANGE ) {
+               this.uriProcessor.updateURL( params );
+       }
+       if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
+               this.changesListModel.invalidate();
+       }
+       this.changesListModel.setNewChangesExist( false );
+       this.updatingChangesList = true;
+       return this._fetchChangesList()
+               .then(
+                       // Success
+                       function ( pieces ) {
+                               var $changesListContent = pieces.changes,
+                                       $fieldset = pieces.fieldset;
+                               this.changesListModel.update(
+                                       $changesListContent,
+                                       $fieldset,
+                                       pieces.noResultsDetails,
+                                       false,
+                                       // separator between old and new changes
+                                       updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
+                               );
+                       }.bind( this )
+                       // Do nothing for failure
+               )
+               .always( function () {
+                       this.updatingChangesList = false;
+               }.bind( this ) );
+};
+
+/**
+ * Get an object representing the default parameter state, whether
+ * it is from the model defaults or from the saved queries.
+ *
+ * @return {Object} Default parameters
+ */
+Controller.prototype._getDefaultParams = function () {
+       if ( this.savedQueriesModel.getDefault() ) {
+               return this.savedQueriesModel.getDefaultParams();
+       } else {
+               return this.filtersModel.getDefaultParams();
+       }
+};
+
+/**
+ * Query the list of changes from the server for the current filters
+ *
+ * @param {string} counterId Id for this request. To allow concurrent requests
+ *  not to invalidate each other.
+ * @param {Object} [params={}] Parameters to add to the query
+ *
+ * @return {jQuery.Promise} Promise object resolved with { content, status }
+ */
+Controller.prototype._queryChangesList = function ( counterId, params ) {
+       var uri = this.uriProcessor.getUpdatedUri(),
+               stickyParams = this.filtersModel.getStickyParamsValues(),
+               requestId,
+               latestRequest;
+
+       params = params || {};
+       params.action = 'render'; // bypasses MW chrome
+
+       uri.extend( params );
+
+       this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
+       requestId = ++this.requestCounter[ counterId ];
+       latestRequest = function () {
+               return requestId === this.requestCounter[ counterId ];
+       }.bind( this );
+
+       // Sticky parameters override the URL params
+       // this is to make sure that whether we represent
+       // the sticky params in the URL or not (they may
+       // be normalized out) the sticky parameters are
+       // always being sent to the server with their
+       // current/default values
+       uri.extend( stickyParams );
+
+       return $.ajax( uri.toString(), { contentType: 'html' } )
+               .then(
+                       function ( content, message, jqXHR ) {
+                               if ( !latestRequest() ) {
+                                       return $.Deferred().reject();
+                               }
+                               return {
+                                       content: content,
+                                       status: jqXHR.status
+                               };
+                       },
+                       // RC returns 404 when there is no results
+                       function ( jqXHR ) {
+                               if ( latestRequest() ) {
+                                       return $.Deferred().resolve(
+                                               {
+                                                       content: jqXHR.responseText,
+                                                       status: jqXHR.status
+                                               }
+                                       ).promise();
+                               }
+                       }
+               );
+};
+
+/**
+ * Fetch the list of changes from the server for the current filters
+ *
+ * @return {jQuery.Promise} Promise object that will resolve with the changes list
+ *  and the fieldset.
+ */
+Controller.prototype._fetchChangesList = function () {
+       return this._queryChangesList( 'updateChangesList' )
+               .then(
+                       function ( data ) {
+                               var $parsed;
 
-       /**
-        * Query the list of changes from the server for the current filters
-        *
-        * @param {string} counterId Id for this request. To allow concurrent requests
-        *  not to invalidate each other.
-        * @param {Object} [params={}] Parameters to add to the query
-        *
-        * @return {jQuery.Promise} Promise object resolved with { content, status }
-        */
-       Controller.prototype._queryChangesList = function ( counterId, params ) {
-               var uri = this.uriProcessor.getUpdatedUri(),
-                       stickyParams = this.filtersModel.getStickyParamsValues(),
-                       requestId,
-                       latestRequest;
-
-               params = params || {};
-               params.action = 'render'; // bypasses MW chrome
-
-               uri.extend( params );
-
-               this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
-               requestId = ++this.requestCounter[ counterId ];
-               latestRequest = function () {
-                       return requestId === this.requestCounter[ counterId ];
-               }.bind( this );
-
-               // Sticky parameters override the URL params
-               // this is to make sure that whether we represent
-               // the sticky params in the URL or not (they may
-               // be normalized out) the sticky parameters are
-               // always being sent to the server with their
-               // current/default values
-               uri.extend( stickyParams );
-
-               return $.ajax( uri.toString(), { contentType: 'html' } )
-                       .then(
-                               function ( content, message, jqXHR ) {
-                                       if ( !latestRequest() ) {
-                                               return $.Deferred().reject();
-                                       }
+                               // Status code 0 is not HTTP status code,
+                               // but is valid value of XMLHttpRequest status.
+                               // It is used for variety of network errors, for example
+                               // when an AJAX call was cancelled before getting the response
+                               if ( data && data.status === 0 ) {
                                        return {
-                                               content: content,
-                                               status: jqXHR.status
+                                               changes: 'NO_RESULTS',
+                                               // We need empty result set, to avoid exceptions because of undefined value
+                                               fieldset: $( [] ),
+                                               noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
                                        };
-                               },
-                               // RC returns 404 when there is no results
-                               function ( jqXHR ) {
-                                       if ( latestRequest() ) {
-                                               return $.Deferred().resolve(
-                                                       {
-                                                               content: jqXHR.responseText,
-                                                               status: jqXHR.status
-                                                       }
-                                               ).promise();
-                                       }
                                }
-                       );
-       };
-
-       /**
-        * Fetch the list of changes from the server for the current filters
-        *
-        * @return {jQuery.Promise} Promise object that will resolve with the changes list
-        *  and the fieldset.
-        */
-       Controller.prototype._fetchChangesList = function () {
-               return this._queryChangesList( 'updateChangesList' )
-                       .then(
-                               function ( data ) {
-                                       var $parsed;
-
-                                       // Status code 0 is not HTTP status code,
-                                       // but is valid value of XMLHttpRequest status.
-                                       // It is used for variety of network errors, for example
-                                       // when an AJAX call was cancelled before getting the response
-                                       if ( data && data.status === 0 ) {
-                                               return {
-                                                       changes: 'NO_RESULTS',
-                                                       // We need empty result set, to avoid exceptions because of undefined value
-                                                       fieldset: $( [] ),
-                                                       noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
-                                               };
-                                       }
-
-                                       $parsed = $( '<div>' ).append( $( $.parseHTML(
-                                               data ? data.content : ''
-                                       ) ) );
 
-                                       return this._extractChangesListInfo( $parsed, data.status );
-                               }.bind( this )
-                       );
-       };
+                               $parsed = $( '<div>' ).append( $( $.parseHTML(
+                                       data ? data.content : ''
+                               ) ) );
 
-       /**
-        * Track usage of highlight feature
-        *
-        * @param {string} action
-        * @param {Array|Object|string} filters
-        */
-       Controller.prototype._trackHighlight = function ( action, filters ) {
-               filters = typeof filters === 'string' ? { name: filters } : filters;
-               filters = !Array.isArray( filters ) ? [ filters ] : filters;
-               mw.track(
-                       'event.ChangesListHighlights',
-                       {
-                               action: action,
-                               filters: filters,
-                               userId: mw.user.getId()
-                       }
+                               return this._extractChangesListInfo( $parsed, data.status );
+                       }.bind( this )
                );
-       };
-
-       /**
-        * Track filter grouping usage
-        *
-        * @param {string} action Action taken
-        */
-       Controller.prototype.trackFilterGroupings = function ( action ) {
-               var controller = this,
-                       rightNow = new Date().getTime(),
-                       randomIdentifier = String( mw.user.sessionId() ) + String( rightNow ) + String( Math.random() ),
-                       // Get all current filters
-                       filters = this.filtersModel.findSelectedItems().map( function ( item ) {
-                               return item.getName();
-                       } );
-
-               action = action || 'filtermenu';
-
-               // Check if these filters were the ones we just logged previously
-               // (Don't log the same grouping twice, in case the user opens/closes)
-               // the menu without action, or with the same result
-               if (
-                       // Only log if the two arrays are different in size
-                       filters.length !== this.prevLoggedItems.length ||
-                       // Or if any filters are not the same as the cached filters
-                       filters.some( function ( filterName ) {
-                               return controller.prevLoggedItems.indexOf( filterName ) === -1;
-                       } ) ||
-                       // Or if any cached filters are not the same as given filters
-                       this.prevLoggedItems.some( function ( filterName ) {
-                               return filters.indexOf( filterName ) === -1;
-                       } )
-               ) {
-                       filters.forEach( function ( filterName ) {
-                               mw.track(
-                                       'event.ChangesListFilterGrouping',
-                                       {
-                                               action: action,
-                                               groupIdentifier: randomIdentifier,
-                                               filter: filterName,
-                                               userId: mw.user.getId()
-                                       }
-                               );
-                       } );
-
-                       // Cache the filter names
-                       this.prevLoggedItems = filters;
+};
+
+/**
+ * Track usage of highlight feature
+ *
+ * @param {string} action
+ * @param {Array|Object|string} filters
+ */
+Controller.prototype._trackHighlight = function ( action, filters ) {
+       filters = typeof filters === 'string' ? { name: filters } : filters;
+       filters = !Array.isArray( filters ) ? [ filters ] : filters;
+       mw.track(
+               'event.ChangesListHighlights',
+               {
+                       action: action,
+                       filters: filters,
+                       userId: mw.user.getId()
                }
-       };
-
-       /**
-        * Apply a change of parameters to the model state, and check whether
-        * the new state is different than the old state.
-        *
-        * @param  {Object} newParamState New parameter state to apply
-        * @return {boolean} New applied model state is different than the previous state
-        */
-       Controller.prototype.applyParamChange = function ( newParamState ) {
-               var after,
-                       before = this.filtersModel.getSelectedState();
-
-               this.filtersModel.updateStateFromParams( newParamState );
-
-               after = this.filtersModel.getSelectedState();
-
-               return !OO.compare( before, after );
-       };
-
-       /**
-        * Mark all changes as seen on Watchlist
-        */
-       Controller.prototype.markAllChangesAsSeen = function () {
-               var api = new mw.Api();
-               api.postWithToken( 'csrf', {
-                       formatversion: 2,
-                       action: 'setnotificationtimestamp',
-                       entirewatchlist: true
-               } ).then( function () {
-                       this.updateChangesList( null, 'markSeen' );
-               }.bind( this ) );
-       };
-
-       /**
-        * Set the current search for the system.
-        *
-        * @param {string} searchQuery Search query, including triggers
-        */
-       Controller.prototype.setSearch = function ( searchQuery ) {
-               this.filtersModel.setSearch( searchQuery );
-       };
-
-       /**
-        * Switch the view by changing the search query trigger
-        * without changing the search term
-        *
-        * @param  {string} view View to change to
-        */
-       Controller.prototype.switchView = function ( view ) {
-               this.setSearch(
-                       this.filtersModel.getViewTrigger( view ) +
-                       this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
-               );
-       };
+       );
+};
+
+/**
+ * Track filter grouping usage
+ *
+ * @param {string} action Action taken
+ */
+Controller.prototype.trackFilterGroupings = function ( action ) {
+       var controller = this,
+               rightNow = new Date().getTime(),
+               randomIdentifier = String( mw.user.sessionId() ) + String( rightNow ) + String( Math.random() ),
+               // Get all current filters
+               filters = this.filtersModel.findSelectedItems().map( function ( item ) {
+                       return item.getName();
+               } );
 
-       /**
-        * Reset the search for a specific view. This means we null the search query
-        * and replace it with the relevant trigger for the requested view
-        *
-        * @param  {string} [view='default'] View to change to
-        */
-       Controller.prototype.resetSearchForView = function ( view ) {
-               view = view || 'default';
-
-               this.setSearch(
-                       this.filtersModel.getViewTrigger( view )
-               );
-       };
+       action = action || 'filtermenu';
+
+       // Check if these filters were the ones we just logged previously
+       // (Don't log the same grouping twice, in case the user opens/closes)
+       // the menu without action, or with the same result
+       if (
+               // Only log if the two arrays are different in size
+               filters.length !== this.prevLoggedItems.length ||
+               // Or if any filters are not the same as the cached filters
+               filters.some( function ( filterName ) {
+                       return controller.prevLoggedItems.indexOf( filterName ) === -1;
+               } ) ||
+               // Or if any cached filters are not the same as given filters
+               this.prevLoggedItems.some( function ( filterName ) {
+                       return filters.indexOf( filterName ) === -1;
+               } )
+       ) {
+               filters.forEach( function ( filterName ) {
+                       mw.track(
+                               'event.ChangesListFilterGrouping',
+                               {
+                                       action: action,
+                                       groupIdentifier: randomIdentifier,
+                                       filter: filterName,
+                                       userId: mw.user.getId()
+                               }
+                       );
+               } );
 
-       module.exports = Controller;
-}() );
+               // Cache the filter names
+               this.prevLoggedItems = filters;
+       }
+};
+
+/**
+ * Apply a change of parameters to the model state, and check whether
+ * the new state is different than the old state.
+ *
+ * @param  {Object} newParamState New parameter state to apply
+ * @return {boolean} New applied model state is different than the previous state
+ */
+Controller.prototype.applyParamChange = function ( newParamState ) {
+       var after,
+               before = this.filtersModel.getSelectedState();
+
+       this.filtersModel.updateStateFromParams( newParamState );
+
+       after = this.filtersModel.getSelectedState();
+
+       return !OO.compare( before, after );
+};
+
+/**
+ * Mark all changes as seen on Watchlist
+ */
+Controller.prototype.markAllChangesAsSeen = function () {
+       var api = new mw.Api();
+       api.postWithToken( 'csrf', {
+               formatversion: 2,
+               action: 'setnotificationtimestamp',
+               entirewatchlist: true
+       } ).then( function () {
+               this.updateChangesList( null, 'markSeen' );
+       }.bind( this ) );
+};
+
+/**
+ * Set the current search for the system.
+ *
+ * @param {string} searchQuery Search query, including triggers
+ */
+Controller.prototype.setSearch = function ( searchQuery ) {
+       this.filtersModel.setSearch( searchQuery );
+};
+
+/**
+ * Switch the view by changing the search query trigger
+ * without changing the search term
+ *
+ * @param  {string} view View to change to
+ */
+Controller.prototype.switchView = function ( view ) {
+       this.setSearch(
+               this.filtersModel.getViewTrigger( view ) +
+               this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
+       );
+};
+
+/**
+ * Reset the search for a specific view. This means we null the search query
+ * and replace it with the relevant trigger for the requested view
+ *
+ * @param  {string} [view='default'] View to change to
+ */
+Controller.prototype.resetSearchForView = function ( view ) {
+       view = view || 'default';
+
+       this.setSearch(
+               this.filtersModel.getViewTrigger( view )
+       );
+};
+
+module.exports = Controller;
index a4ef73b..42bfae6 100644 (file)
@@ -1,12 +1,10 @@
-( function () {
-       /**
-        * Supported highlight colors.
-        * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
-        *
-        * @member mw.rcfilters
-        * @property {string[]}
-        */
-       var HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];
+/**
+ * Supported highlight colors.
+ * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
+ *
+ * @member mw.rcfilters
+ * @property {string[]}
+ */
+var HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];
 
-       module.exports = HighlightColors;
-}() );
+module.exports = HighlightColors;
index 37874d5..3b69654 100644 (file)
-( function () {
-       /* eslint no-underscore-dangle: "off" */
-       /**
-        * URI Processor for RCFilters
-        *
-        * @class mw.rcfilters.UriProcessor
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
-        * @param {Object} [config] Configuration object
-        * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
-        *  title normalization to separate title subpage/parts into the target= url
-        *  parameter
-        */
-       var UriProcessor = function MwRcfiltersController( filtersModel, config ) {
-               config = config || {};
-               this.filtersModel = filtersModel;
-
-               this.normalizeTarget = !!config.normalizeTarget;
-       };
-
-       /* Initialization */
-       OO.initClass( UriProcessor );
-
-       /* Static methods */
-
-       /**
-        * Replace the url history through replaceState
-        *
-        * @param {mw.Uri} newUri New URI to replace
-        */
-       UriProcessor.static.replaceState = function ( newUri ) {
-               window.history.replaceState(
-                       { tag: 'rcfilters' },
-                       document.title,
-                       newUri.toString()
-               );
-       };
-
-       /**
-        * Push the url to history through pushState
-        *
-        * @param {mw.Uri} newUri New URI to push
-        */
-       UriProcessor.static.pushState = function ( newUri ) {
-               window.history.pushState(
-                       { tag: 'rcfilters' },
-                       document.title,
-                       newUri.toString()
-               );
-       };
-
-       /* Methods */
-
-       /**
-        * Get the version that this URL query is tagged with.
-        *
-        * @param {Object} [uriQuery] URI query
-        * @return {number} URL version
-        */
-       UriProcessor.prototype.getVersion = function ( uriQuery ) {
-               uriQuery = uriQuery || new mw.Uri().query;
-
-               return Number( uriQuery.urlversion || 1 );
-       };
-
-       /**
-        * Get an updated mw.Uri object based on the model state
-        *
-        * @param {mw.Uri} [uri] An external URI to build the new uri
-        *  with. This is mainly for tests, to be able to supply external query
-        *  parameters and make sure they are retained.
-        * @return {mw.Uri} Updated Uri
-        */
-       UriProcessor.prototype.getUpdatedUri = function ( uri ) {
-               var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
-                       unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );
-
-               normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
-                       $.extend(
-                               true,
-                               {},
-                               normalizedUri.query,
-                               // The representation must be expanded so it can
-                               // override the uri query params but we then output
-                               // a minimized version for the entire URI representation
-                               // for the method
-                               this.filtersModel.getExpandedParamRepresentation()
-                       )
-               );
-
-               // Reapply unrecognized params and url version
-               normalizedUri.query = $.extend(
+/* eslint no-underscore-dangle: "off" */
+/**
+ * URI Processor for RCFilters
+ *
+ * @class mw.rcfilters.UriProcessor
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
+ * @param {Object} [config] Configuration object
+ * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
+ *  title normalization to separate title subpage/parts into the target= url
+ *  parameter
+ */
+var UriProcessor = function MwRcfiltersController( filtersModel, config ) {
+       config = config || {};
+       this.filtersModel = filtersModel;
+
+       this.normalizeTarget = !!config.normalizeTarget;
+};
+
+/* Initialization */
+OO.initClass( UriProcessor );
+
+/* Static methods */
+
+/**
+ * Replace the url history through replaceState
+ *
+ * @param {mw.Uri} newUri New URI to replace
+ */
+UriProcessor.static.replaceState = function ( newUri ) {
+       window.history.replaceState(
+               { tag: 'rcfilters' },
+               document.title,
+               newUri.toString()
+       );
+};
+
+/**
+ * Push the url to history through pushState
+ *
+ * @param {mw.Uri} newUri New URI to push
+ */
+UriProcessor.static.pushState = function ( newUri ) {
+       window.history.pushState(
+               { tag: 'rcfilters' },
+               document.title,
+               newUri.toString()
+       );
+};
+
+/* Methods */
+
+/**
+ * Get the version that this URL query is tagged with.
+ *
+ * @param {Object} [uriQuery] URI query
+ * @return {number} URL version
+ */
+UriProcessor.prototype.getVersion = function ( uriQuery ) {
+       uriQuery = uriQuery || new mw.Uri().query;
+
+       return Number( uriQuery.urlversion || 1 );
+};
+
+/**
+ * Get an updated mw.Uri object based on the model state
+ *
+ * @param {mw.Uri} [uri] An external URI to build the new uri
+ *  with. This is mainly for tests, to be able to supply external query
+ *  parameters and make sure they are retained.
+ * @return {mw.Uri} Updated Uri
+ */
+UriProcessor.prototype.getUpdatedUri = function ( uri ) {
+       var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
+               unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );
+
+       normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
+               $.extend(
                        true,
                        {},
                        normalizedUri.query,
-                       unrecognizedParams,
-                       { urlversion: '2' }
-               );
-
-               return normalizedUri;
-       };
-
-       /**
-        * Move the subpage to the target parameter
-        *
-        * @param {mw.Uri} uri
-        * @return {mw.Uri}
-        * @private
-        */
-       UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
-               var parts,
-                       // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
-                       re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
-
-               if ( !this.normalizeTarget ) {
-                       return uri;
-               }
-
-               // target in title param
-               if ( uri.query.title ) {
-                       parts = uri.query.title.match( re );
-                       if ( parts ) {
-                               uri.query.title = parts[ 1 ];
-                               uri.query.target = parts[ 2 ];
-                       }
-               }
+                       // The representation must be expanded so it can
+                       // override the uri query params but we then output
+                       // a minimized version for the entire URI representation
+                       // for the method
+                       this.filtersModel.getExpandedParamRepresentation()
+               )
+       );
+
+       // Reapply unrecognized params and url version
+       normalizedUri.query = $.extend(
+               true,
+               {},
+               normalizedUri.query,
+               unrecognizedParams,
+               { urlversion: '2' }
+       );
+
+       return normalizedUri;
+};
+
+/**
+ * Move the subpage to the target parameter
+ *
+ * @param {mw.Uri} uri
+ * @return {mw.Uri}
+ * @private
+ */
+UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
+       var parts,
+               // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
+               re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
+
+       if ( !this.normalizeTarget ) {
+               return uri;
+       }
 
-               // target in path
-               parts = mw.Uri.decode( uri.path ).match( re );
+       // target in title param
+       if ( uri.query.title ) {
+               parts = uri.query.title.match( re );
                if ( parts ) {
-                       uri.path = parts[ 1 ];
+                       uri.query.title = parts[ 1 ];
                        uri.query.target = parts[ 2 ];
                }
-
-               return uri;
-       };
-
-       /**
-        * Get an object representing given parameters that are unrecognized by the model
-        *
-        * @param  {Object} params Full params object
-        * @return {Object} Unrecognized params
-        */
-       UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
-               // Start with full representation
-               var givenParamNames = Object.keys( params ),
-                       unrecognizedParams = $.extend( true, {}, params );
-
-               // Extract unrecognized parameters
-               Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
-                       // Remove recognized params
-                       if ( givenParamNames.indexOf( paramName ) > -1 ) {
-                               delete unrecognizedParams[ paramName ];
-                       }
-               } );
-
-               return unrecognizedParams;
-       };
-
-       /**
-        * Update the URL of the page to reflect current filters
-        *
-        * This should not be called directly from outside the controller.
-        * If an action requires changing the URL, it should either use the
-        * highlighting actions below, or call #updateChangesList which does
-        * the uri corrections already.
-        *
-        * @param {Object} [params] Extra parameters to add to the API call
-        */
-       UriProcessor.prototype.updateURL = function ( params ) {
-               var currentUri = new mw.Uri(),
-                       updatedUri = this.getUpdatedUri();
-
-               updatedUri.extend( params || {} );
-
-               if (
-                       this.getVersion( currentUri.query ) !== 2 ||
-                       this.isNewState( currentUri.query, updatedUri.query )
-               ) {
-                       this.constructor.static.replaceState( updatedUri );
+       }
+
+       // target in path
+       parts = mw.Uri.decode( uri.path ).match( re );
+       if ( parts ) {
+               uri.path = parts[ 1 ];
+               uri.query.target = parts[ 2 ];
+       }
+
+       return uri;
+};
+
+/**
+ * Get an object representing given parameters that are unrecognized by the model
+ *
+ * @param  {Object} params Full params object
+ * @return {Object} Unrecognized params
+ */
+UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
+       // Start with full representation
+       var givenParamNames = Object.keys( params ),
+               unrecognizedParams = $.extend( true, {}, params );
+
+       // Extract unrecognized parameters
+       Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
+               // Remove recognized params
+               if ( givenParamNames.indexOf( paramName ) > -1 ) {
+                       delete unrecognizedParams[ paramName ];
                }
-       };
-
-       /**
-        * Update the filters model based on the URI query
-        * This happens on initialization, and from this moment on,
-        * we consider the system synchronized, and the model serves
-        * as the source of truth for the URL.
-        *
-        * This methods should only be called once on initialization.
-        * After initialization, the model updates the URL, not the
-        * other way around.
-        *
-        * @param {Object} [uriQuery] URI query
-        */
-       UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
-               uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
-               this.filtersModel.updateStateFromParams(
-                       this._getNormalizedQueryParams( uriQuery )
-               );
-       };
-
-       /**
-        * Compare two URI queries to decide whether they are different
-        * enough to represent a new state.
-        *
-        * @param {Object} currentUriQuery Current Uri query
-        * @param {Object} updatedUriQuery Updated Uri query
-        * @return {boolean} This is a new state
-        */
-       UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
-               var currentParamState, updatedParamState,
-                       notEquivalent = function ( obj1, obj2 ) {
-                               var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
-                               return keys.some( function ( key ) {
-                                       return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
-                               } );
-                       };
-
-               // Compare states instead of parameters
-               // This will allow us to always have a proper check of whether
-               // the requested new url is one to change or not, regardless of
-               // actual parameter visibility/representation in the URL
-               currentParamState = $.extend(
-                       true,
-                       {},
-                       this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
-                       this.getUnrecognizedParams( currentUriQuery )
-               );
-               updatedParamState = $.extend(
-                       true,
-                       {},
-                       this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
-                       this.getUnrecognizedParams( updatedUriQuery )
-               );
-
-               return notEquivalent( currentParamState, updatedParamState );
-       };
-
-       /**
-        * Check whether the given query has parameters that are
-        * recognized as parameters we should load the system with
-        *
-        * @param {mw.Uri} [uriQuery] Given URI query
-        * @return {boolean} Query contains valid recognized parameters
-        */
-       UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
-               var anyValidInUrl,
-                       validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );
-
-               uriQuery = uriQuery || new mw.Uri().query;
-
-               anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
-                       return validParameterNames.indexOf( parameter ) > -1;
-               } );
-
-               // URL version 2 is allowed to be empty or within nonrecognized params
-               return anyValidInUrl || this.getVersion( uriQuery ) === 2;
-       };
-
-       /**
-        * Get the adjusted URI params based on the url version
-        * If the urlversion is not 2, the parameters are merged with
-        * the model's defaults.
-        * Always merge in the hidden parameter defaults.
-        *
-        * @private
-        * @param {Object} uriQuery Current URI query
-        * @return {Object} Normalized parameters
-        */
-       UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
-               // Check whether we are dealing with urlversion=2
-               // If we are, we do not merge the initial request with
-               // defaults. Not having urlversion=2 means we need to
-               // reproduce the server-side request and merge the
-               // requested parameters (or starting state) with the
-               // wiki default.
-               // Any subsequent change of the URL through the RCFilters
-               // system will receive 'urlversion=2'
-               var base = this.getVersion( uriQuery ) === 2 ?
-                       {} :
-                       this.filtersModel.getDefaultParams();
-
-               return $.extend(
-                       true,
-                       {},
-                       this.filtersModel.getMinimizedParamRepresentation(
-                               $.extend( true, {}, base, uriQuery )
-                       ),
-                       { urlversion: '2' }
-               );
-       };
-
-       module.exports = UriProcessor;
-}() );
+       } );
+
+       return unrecognizedParams;
+};
+
+/**
+ * Update the URL of the page to reflect current filters
+ *
+ * This should not be called directly from outside the controller.
+ * If an action requires changing the URL, it should either use the
+ * highlighting actions below, or call #updateChangesList which does
+ * the uri corrections already.
+ *
+ * @param {Object} [params] Extra parameters to add to the API call
+ */
+UriProcessor.prototype.updateURL = function ( params ) {
+       var currentUri = new mw.Uri(),
+               updatedUri = this.getUpdatedUri();
+
+       updatedUri.extend( params || {} );
+
+       if (
+               this.getVersion( currentUri.query ) !== 2 ||
+               this.isNewState( currentUri.query, updatedUri.query )
+       ) {
+               this.constructor.static.replaceState( updatedUri );
+       }
+};
+
+/**
+ * Update the filters model based on the URI query
+ * This happens on initialization, and from this moment on,
+ * we consider the system synchronized, and the model serves
+ * as the source of truth for the URL.
+ *
+ * This methods should only be called once on initialization.
+ * After initialization, the model updates the URL, not the
+ * other way around.
+ *
+ * @param {Object} [uriQuery] URI query
+ */
+UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
+       uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
+       this.filtersModel.updateStateFromParams(
+               this._getNormalizedQueryParams( uriQuery )
+       );
+};
+
+/**
+ * Compare two URI queries to decide whether they are different
+ * enough to represent a new state.
+ *
+ * @param {Object} currentUriQuery Current Uri query
+ * @param {Object} updatedUriQuery Updated Uri query
+ * @return {boolean} This is a new state
+ */
+UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
+       var currentParamState, updatedParamState,
+               notEquivalent = function ( obj1, obj2 ) {
+                       var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
+                       return keys.some( function ( key ) {
+                               return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
+                       } );
+               };
+
+       // Compare states instead of parameters
+       // This will allow us to always have a proper check of whether
+       // the requested new url is one to change or not, regardless of
+       // actual parameter visibility/representation in the URL
+       currentParamState = $.extend(
+               true,
+               {},
+               this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
+               this.getUnrecognizedParams( currentUriQuery )
+       );
+       updatedParamState = $.extend(
+               true,
+               {},
+               this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
+               this.getUnrecognizedParams( updatedUriQuery )
+       );
+
+       return notEquivalent( currentParamState, updatedParamState );
+};
+
+/**
+ * Check whether the given query has parameters that are
+ * recognized as parameters we should load the system with
+ *
+ * @param {mw.Uri} [uriQuery] Given URI query
+ * @return {boolean} Query contains valid recognized parameters
+ */
+UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
+       var anyValidInUrl,
+               validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );
+
+       uriQuery = uriQuery || new mw.Uri().query;
+
+       anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
+               return validParameterNames.indexOf( parameter ) > -1;
+       } );
+
+       // URL version 2 is allowed to be empty or within nonrecognized params
+       return anyValidInUrl || this.getVersion( uriQuery ) === 2;
+};
+
+/**
+ * Get the adjusted URI params based on the url version
+ * If the urlversion is not 2, the parameters are merged with
+ * the model's defaults.
+ * Always merge in the hidden parameter defaults.
+ *
+ * @private
+ * @param {Object} uriQuery Current URI query
+ * @return {Object} Normalized parameters
+ */
+UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
+       // Check whether we are dealing with urlversion=2
+       // If we are, we do not merge the initial request with
+       // defaults. Not having urlversion=2 means we need to
+       // reproduce the server-side request and merge the
+       // requested parameters (or starting state) with the
+       // wiki default.
+       // Any subsequent change of the URL through the RCFilters
+       // system will receive 'urlversion=2'
+       var base = this.getVersion( uriQuery ) === 2 ?
+               {} :
+               this.filtersModel.getDefaultParams();
+
+       return $.extend(
+               true,
+               {},
+               this.filtersModel.getMinimizedParamRepresentation(
+                       $.extend( true, {}, base, uriQuery )
+               ),
+               { urlversion: '2' }
+       );
+};
+
+module.exports = UriProcessor;
index 64d2e79..70677b9 100644 (file)
-( function () {
-       /**
-        * View model for the changes list
-        *
-        * @class mw.rcfilters.dm.ChangesListViewModel
-        * @mixins OO.EventEmitter
-        *
-        * @param {jQuery} $initialFieldset The initial server-generated legacy form content
-        * @constructor
-        */
-       var ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               this.valid = true;
-               this.newChangesExist = false;
-               this.liveUpdate = false;
-               this.unseenWatchedChanges = false;
-
-               this.extractNextFrom( $initialFieldset );
-       };
-
-       /* Initialization */
-       OO.initClass( ChangesListViewModel );
-       OO.mixinClass( ChangesListViewModel, OO.EventEmitter );
-
-       /* Events */
-
-       /**
-        * @event invalidate
-        *
-        * The list of changes is now invalid (out of date)
-        */
-
-       /**
-        * @event update
-        * @param {jQuery|string} $changesListContent List of changes
-        * @param {jQuery} $fieldset Server-generated form
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
-        * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
-        *
-        * The list of changes has been updated
-        */
-
-       /**
-        * @event newChangesExist
-        * @param {boolean} newChangesExist
-        *
-        * The existence of changes newer than those currently displayed has changed.
-        */
-
-       /**
-        * @event liveUpdateChange
-        * @param {boolean} enable
-        *
-        * The state of the 'live update' feature has changed.
-        */
-
-       /* Methods */
-
-       /**
-        * Invalidate the list of changes
-        *
-        * @fires invalidate
-        */
-       ChangesListViewModel.prototype.invalidate = function () {
-               if ( this.valid ) {
-                       this.valid = false;
-                       this.emit( 'invalidate' );
-               }
-       };
-
-       /**
-        * Update the model with an updated list of changes
-        *
-        * @param {jQuery|string} changesListContent
-        * @param {jQuery} $fieldset
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
-        * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
-        * @fires update
-        */
-       ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
-               var from = this.nextFrom;
-               this.valid = true;
-               this.extractNextFrom( $fieldset );
-               this.checkForUnseenWatchedChanges( changesListContent );
-               this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
-       };
-
-       /**
-        * Specify whether new changes exist
-        *
-        * @param {boolean} newChangesExist
-        * @fires newChangesExist
-        */
-       ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
-               if ( newChangesExist !== this.newChangesExist ) {
-                       this.newChangesExist = newChangesExist;
-                       this.emit( 'newChangesExist', newChangesExist );
-               }
-       };
-
-       /**
-        * @return {boolean} Whether new changes exist
-        */
-       ChangesListViewModel.prototype.getNewChangesExist = function () {
-               return this.newChangesExist;
-       };
-
-       /**
-        * Extract the value of the 'from' parameter from a link in the field set
-        *
-        * @param {jQuery} $fieldset
-        */
-       ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
-               var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
-               if ( data && data.from ) {
-                       this.nextFrom = data.from;
-               }
-       };
-
-       /**
-        * @return {string} The 'from' parameter that can be used to query new changes
-        */
-       ChangesListViewModel.prototype.getNextFrom = function () {
-               return this.nextFrom;
-       };
-
-       /**
-        * Toggle the 'live update' feature on/off
-        *
-        * @param {boolean} enable
-        */
-       ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
-               enable = enable === undefined ? !this.liveUpdate : enable;
-               if ( enable !== this.liveUpdate ) {
-                       this.liveUpdate = enable;
-                       this.emit( 'liveUpdateChange', this.liveUpdate );
-               }
-       };
-
-       /**
-        * @return {boolean} The 'live update' feature is enabled
-        */
-       ChangesListViewModel.prototype.getLiveUpdate = function () {
-               return this.liveUpdate;
-       };
-
-       /**
-        * Check if some of the given changes watched and unseen
-        *
-        * @param {jQuery|string} changeslistContent
-        */
-       ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
-               this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
-                       changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
-       };
-
-       /**
-        * @return {boolean} Whether some of the current changes are watched and unseen
-        */
-       ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
-               return this.unseenWatchedChanges;
-       };
-
-       module.exports = ChangesListViewModel;
-}() );
+/**
+ * View model for the changes list
+ *
+ * @class mw.rcfilters.dm.ChangesListViewModel
+ * @mixins OO.EventEmitter
+ *
+ * @param {jQuery} $initialFieldset The initial server-generated legacy form content
+ * @constructor
+ */
+var ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
+       // Mixin constructor
+       OO.EventEmitter.call( this );
+
+       this.valid = true;
+       this.newChangesExist = false;
+       this.liveUpdate = false;
+       this.unseenWatchedChanges = false;
+
+       this.extractNextFrom( $initialFieldset );
+};
+
+/* Initialization */
+OO.initClass( ChangesListViewModel );
+OO.mixinClass( ChangesListViewModel, OO.EventEmitter );
+
+/* Events */
+
+/**
+ * @event invalidate
+ *
+ * The list of changes is now invalid (out of date)
+ */
+
+/**
+ * @event update
+ * @param {jQuery|string} $changesListContent List of changes
+ * @param {jQuery} $fieldset Server-generated form
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
+ * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
+ *
+ * The list of changes has been updated
+ */
+
+/**
+ * @event newChangesExist
+ * @param {boolean} newChangesExist
+ *
+ * The existence of changes newer than those currently displayed has changed.
+ */
+
+/**
+ * @event liveUpdateChange
+ * @param {boolean} enable
+ *
+ * The state of the 'live update' feature has changed.
+ */
+
+/* Methods */
+
+/**
+ * Invalidate the list of changes
+ *
+ * @fires invalidate
+ */
+ChangesListViewModel.prototype.invalidate = function () {
+       if ( this.valid ) {
+               this.valid = false;
+               this.emit( 'invalidate' );
+       }
+};
+
+/**
+ * Update the model with an updated list of changes
+ *
+ * @param {jQuery|string} changesListContent
+ * @param {jQuery} $fieldset
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
+ * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
+ * @fires update
+ */
+ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
+       var from = this.nextFrom;
+       this.valid = true;
+       this.extractNextFrom( $fieldset );
+       this.checkForUnseenWatchedChanges( changesListContent );
+       this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
+};
+
+/**
+ * Specify whether new changes exist
+ *
+ * @param {boolean} newChangesExist
+ * @fires newChangesExist
+ */
+ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
+       if ( newChangesExist !== this.newChangesExist ) {
+               this.newChangesExist = newChangesExist;
+               this.emit( 'newChangesExist', newChangesExist );
+       }
+};
+
+/**
+ * @return {boolean} Whether new changes exist
+ */
+ChangesListViewModel.prototype.getNewChangesExist = function () {
+       return this.newChangesExist;
+};
+
+/**
+ * Extract the value of the 'from' parameter from a link in the field set
+ *
+ * @param {jQuery} $fieldset
+ */
+ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
+       var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
+       if ( data && data.from ) {
+               this.nextFrom = data.from;
+       }
+};
+
+/**
+ * @return {string} The 'from' parameter that can be used to query new changes
+ */
+ChangesListViewModel.prototype.getNextFrom = function () {
+       return this.nextFrom;
+};
+
+/**
+ * Toggle the 'live update' feature on/off
+ *
+ * @param {boolean} enable
+ */
+ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
+       enable = enable === undefined ? !this.liveUpdate : enable;
+       if ( enable !== this.liveUpdate ) {
+               this.liveUpdate = enable;
+               this.emit( 'liveUpdateChange', this.liveUpdate );
+       }
+};
+
+/**
+ * @return {boolean} The 'live update' feature is enabled
+ */
+ChangesListViewModel.prototype.getLiveUpdate = function () {
+       return this.liveUpdate;
+};
+
+/**
+ * Check if some of the given changes watched and unseen
+ *
+ * @param {jQuery|string} changeslistContent
+ */
+ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
+       this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
+               changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
+};
+
+/**
+ * @return {boolean} Whether some of the current changes are watched and unseen
+ */
+ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
+       return this.unseenWatchedChanges;
+};
+
+module.exports = ChangesListViewModel;
index db504b5..8bd5eb2 100644 (file)
-( function () {
-       var FilterItem = require( './FilterItem.js' ),
-               FilterGroup;
-
-       /**
-        * View model for a filter group
-        *
-        * @class mw.rcfilters.dm.FilterGroup
-        * @mixins OO.EventEmitter
-        * @mixins OO.EmitterList
-        *
-        * @constructor
-        * @param {string} name Group name
-        * @param {Object} [config] Configuration options
-        * @cfg {string} [type='send_unselected_if_any'] Group type
-        * @cfg {string} [view='default'] Name of the display group this group
-        *  is a part of.
-        * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
-        *  with a preference, does not participate in Saved Queries, and is
-        *  not shown in the active filters area.
-        * @cfg {string} [title] Group title
-        * @cfg {boolean} [hidden] This group is hidden from the regular menu views
-        *  and the active filters area.
-        * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
-        *  group from the URL, even if it wasn't initially set up.
-        * @cfg {number} [range] An object defining minimum and maximum values for numeric
-        *  groups. { min: x, max: y }
-        * @cfg {number} [minValue] Minimum value for numeric groups
-        * @cfg {string} [separator='|'] Value separator for 'string_options' groups
-        * @cfg {boolean} [active] Group is active
-        * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
-        * @cfg {Object} [conflicts] Defines the conflicts for this filter group
-        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
-        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
-        *  with 'default' and 'inverted' as keys.
-        * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
-        * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
-        * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
-        * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
-        * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
-        * @cfg {boolean} [visible=true] The visibility of the group
-        */
-       FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-               OO.EmitterList.call( this );
-
-               this.name = name;
-               this.type = config.type || 'send_unselected_if_any';
-               this.view = config.view || 'default';
-               this.sticky = !!config.sticky;
-               this.title = config.title || name;
-               this.hidden = !!config.hidden;
-               this.allowArbitrary = !!config.allowArbitrary;
-               this.numericRange = config.range;
-               this.separator = config.separator || '|';
-               this.labelPrefixKey = config.labelPrefixKey;
-               this.visible = config.visible === undefined ? true : !!config.visible;
-
-               this.currSelected = null;
-               this.active = !!config.active;
-               this.fullCoverage = !!config.fullCoverage;
-
-               this.whatsThis = config.whatsThis || {};
-
-               this.conflicts = config.conflicts || {};
-               this.defaultParams = {};
-               this.defaultFilters = {};
-
-               this.aggregate( { update: 'filterItemUpdate' } );
-               this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
-       };
-
-       /* Initialization */
-       OO.initClass( FilterGroup );
-       OO.mixinClass( FilterGroup, OO.EventEmitter );
-       OO.mixinClass( FilterGroup, OO.EmitterList );
-
-       /* Events */
-
-       /**
-        * @event update
-        *
-        * Group state has been updated
-        */
-
-       /* Methods */
-
-       /**
-        * Initialize the group and create its filter items
-        *
-        * @param {Object} filterDefinition Filter definition for this group
-        * @param {string|Object} [groupDefault] Definition of the group default
-        */
-       FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
-               var defaultParam,
-                       supersetMap = {},
-                       model = this,
-                       items = [];
-
-               filterDefinition.forEach( function ( filter ) {
-                       // Instantiate an item
-                       var subsetNames = [],
-                               filterItem = new FilterItem( filter.name, model, {
-                                       group: model.getName(),
-                                       label: filter.label || filter.name,
-                                       description: filter.description || '',
-                                       labelPrefixKey: model.labelPrefixKey,
-                                       cssClass: filter.cssClass,
-                                       identifiers: filter.identifiers,
-                                       defaultHighlightColor: filter.defaultHighlightColor
-                               } );
-
-                       if ( filter.subset ) {
-                               filter.subset = filter.subset.map( function ( el ) {
-                                       return el.filter;
-                               } );
-
-                               subsetNames = [];
-
-                               filter.subset.forEach( function ( subsetFilterName ) {
-                                       // Subsets (unlike conflicts) are always inside the same group
-                                       // We can re-map the names of the filters we are getting from
-                                       // the subsets with the group prefix
-                                       var subsetName = model.getPrefixedName( subsetFilterName );
-                                       // For convenience, we should store each filter's "supersets" -- these are
-                                       // the filters that have that item in their subset list. This will just
-                                       // make it easier to go through whether the item has any other items
-                                       // that affect it (and are selected) at any given time
-                                       supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
-                                       mw.rcfilters.utils.addArrayElementsUnique(
-                                               supersetMap[ subsetName ],
-                                               filterItem.getName()
-                                       );
-
-                                       // Translate subset param name to add the group name, so we
-                                       // get consistent naming. We know that subsets are only within
-                                       // the same group
-                                       subsetNames.push( subsetName );
-                               } );
-
-                               // Set translated subset
-                               filterItem.setSubset( subsetNames );
-                       }
-
-                       items.push( filterItem );
-
-                       // Store default parameter state; in this case, default is defined per filter
-                       if (
-                               model.getType() === 'send_unselected_if_any' ||
-                               model.getType() === 'boolean'
-                       ) {
-                               // Store the default parameter state
-                               // For this group type, parameter values are direct
-                               // We need to convert from a boolean to a string ('1' and '0')
-                               model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
-                       } else if ( model.getType() === 'any_value' ) {
-                               model.defaultParams[ filter.name ] = filter.default;
-                       }
-               } );
-
-               // Add items
-               this.addItems( items );
-
-               // Now that we have all items, we can apply the superset map
-               this.getItems().forEach( function ( filterItem ) {
-                       filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
-               } );
-
-               // Store default parameter state; in this case, default is defined per the
-               // entire group, given by groupDefault method parameter
-               if ( this.getType() === 'string_options' ) {
-                       // Store the default parameter group state
-                       // For this group, the parameter is group name and value is the names
-                       // of selected items
-                       this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
-                               // Current values
-                               groupDefault ?
-                                       groupDefault.split( this.getSeparator() ) :
-                                       [],
-                               // Legal values
-                               this.getItems().map( function ( item ) {
-                                       return item.getParamName();
-                               } )
-                       ).join( this.getSeparator() );
-               } else if ( this.getType() === 'single_option' ) {
-                       defaultParam = groupDefault !== undefined ?
-                               groupDefault : this.getItems()[ 0 ].getParamName();
-
-                       // For this group, the parameter is the group name,
-                       // and a single item can be selected: default or first item
-                       this.defaultParams[ this.getName() ] = defaultParam;
-               }
-
-               // add highlights to defaultParams
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isHighlighted() ) {
-                               this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
-                       }
-               }.bind( this ) );
-
-               // Store default filter state based on default params
-               this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
+var FilterItem = require( './FilterItem.js' ),
+       FilterGroup;
+
+/**
+ * View model for a filter group
+ *
+ * @class mw.rcfilters.dm.FilterGroup
+ * @mixins OO.EventEmitter
+ * @mixins OO.EmitterList
+ *
+ * @constructor
+ * @param {string} name Group name
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [type='send_unselected_if_any'] Group type
+ * @cfg {string} [view='default'] Name of the display group this group
+ *  is a part of.
+ * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
+ *  with a preference, does not participate in Saved Queries, and is
+ *  not shown in the active filters area.
+ * @cfg {string} [title] Group title
+ * @cfg {boolean} [hidden] This group is hidden from the regular menu views
+ *  and the active filters area.
+ * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
+ *  group from the URL, even if it wasn't initially set up.
+ * @cfg {number} [range] An object defining minimum and maximum values for numeric
+ *  groups. { min: x, max: y }
+ * @cfg {number} [minValue] Minimum value for numeric groups
+ * @cfg {string} [separator='|'] Value separator for 'string_options' groups
+ * @cfg {boolean} [active] Group is active
+ * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
+ * @cfg {Object} [conflicts] Defines the conflicts for this filter group
+ * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+ *  group. If the prefix has 'invert' state, the parameter is expected to be an object
+ *  with 'default' and 'inverted' as keys.
+ * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
+ * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
+ * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
+ * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
+ * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
+ * @cfg {boolean} [visible=true] The visibility of the group
+ */
+FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
+       config = config || {};
+
+       // Mixin constructor
+       OO.EventEmitter.call( this );
+       OO.EmitterList.call( this );
+
+       this.name = name;
+       this.type = config.type || 'send_unselected_if_any';
+       this.view = config.view || 'default';
+       this.sticky = !!config.sticky;
+       this.title = config.title || name;
+       this.hidden = !!config.hidden;
+       this.allowArbitrary = !!config.allowArbitrary;
+       this.numericRange = config.range;
+       this.separator = config.separator || '|';
+       this.labelPrefixKey = config.labelPrefixKey;
+       this.visible = config.visible === undefined ? true : !!config.visible;
+
+       this.currSelected = null;
+       this.active = !!config.active;
+       this.fullCoverage = !!config.fullCoverage;
+
+       this.whatsThis = config.whatsThis || {};
+
+       this.conflicts = config.conflicts || {};
+       this.defaultParams = {};
+       this.defaultFilters = {};
+
+       this.aggregate( { update: 'filterItemUpdate' } );
+       this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
+};
+
+/* Initialization */
+OO.initClass( FilterGroup );
+OO.mixinClass( FilterGroup, OO.EventEmitter );
+OO.mixinClass( FilterGroup, OO.EmitterList );
+
+/* Events */
+
+/**
+ * @event update
+ *
+ * Group state has been updated
+ */
+
+/* Methods */
+
+/**
+ * Initialize the group and create its filter items
+ *
+ * @param {Object} filterDefinition Filter definition for this group
+ * @param {string|Object} [groupDefault] Definition of the group default
+ */
+FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
+       var defaultParam,
+               supersetMap = {},
+               model = this,
+               items = [];
+
+       filterDefinition.forEach( function ( filter ) {
+               // Instantiate an item
+               var subsetNames = [],
+                       filterItem = new FilterItem( filter.name, model, {
+                               group: model.getName(),
+                               label: filter.label || filter.name,
+                               description: filter.description || '',
+                               labelPrefixKey: model.labelPrefixKey,
+                               cssClass: filter.cssClass,
+                               identifiers: filter.identifiers,
+                               defaultHighlightColor: filter.defaultHighlightColor
+                       } );
 
-               // Check for filters that should be initially selected by their default value
-               if ( this.isSticky() ) {
-                       // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( this.defaultFilters, function ( filterName, filterValue ) {
-                               model.getItemByName( filterName ).toggleSelected( filterValue );
+               if ( filter.subset ) {
+                       filter.subset = filter.subset.map( function ( el ) {
+                               return el.filter;
                        } );
-               }
 
-               // Verify that single_option group has at least one item selected
-               if (
-                       this.getType() === 'single_option' &&
-                       this.findSelectedItems().length === 0
-               ) {
-                       defaultParam = groupDefault !== undefined ?
-                               groupDefault : this.getItems()[ 0 ].getParamName();
+                       subsetNames = [];
+
+                       filter.subset.forEach( function ( subsetFilterName ) {
+                               // Subsets (unlike conflicts) are always inside the same group
+                               // We can re-map the names of the filters we are getting from
+                               // the subsets with the group prefix
+                               var subsetName = model.getPrefixedName( subsetFilterName );
+                               // For convenience, we should store each filter's "supersets" -- these are
+                               // the filters that have that item in their subset list. This will just
+                               // make it easier to go through whether the item has any other items
+                               // that affect it (and are selected) at any given time
+                               supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
+                               mw.rcfilters.utils.addArrayElementsUnique(
+                                       supersetMap[ subsetName ],
+                                       filterItem.getName()
+                               );
+
+                               // Translate subset param name to add the group name, so we
+                               // get consistent naming. We know that subsets are only within
+                               // the same group
+                               subsetNames.push( subsetName );
+                       } );
 
-                       // Single option means there must be a single option
-                       // selected, so we have to either select the default
-                       // or select the first option
-                       this.selectItemByParamName( defaultParam );
-               }
-       };
-
-       /**
-        * Respond to filterItem update event
-        *
-        * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
-        * @fires update
-        */
-       FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
-               // Update state
-               var changed = false,
-                       active = this.areAnySelected(),
-                       model = this;
-
-               if ( this.getType() === 'single_option' ) {
-                       // This group must have one item selected always
-                       // and must never have more than one item selected at a time
-                       if ( this.findSelectedItems().length === 0 ) {
-                               // Nothing is selected anymore
-                               // Select the default or the first item
-                               this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
-                                       this.getItems()[ 0 ];
-                               this.currSelected.toggleSelected( true );
-                               changed = true;
-                       } else if ( this.findSelectedItems().length > 1 ) {
-                               // There is more than one item selected
-                               // This should only happen if the item given
-                               // is the one that is selected, so unselect
-                               // all items that is not it
-                               this.findSelectedItems().forEach( function ( itemModel ) {
-                                       // Note that in case the given item is actually
-                                       // not selected, this loop will end up unselecting
-                                       // all items, which would trigger the case above
-                                       // when the last item is unselected anyways
-                                       var selected = itemModel.getName() === item.getName() &&
-                                               item.isSelected();
-
-                                       itemModel.toggleSelected( selected );
-                                       if ( selected ) {
-                                               model.currSelected = itemModel;
-                                       }
-                               } );
-                               changed = true;
-                       }
+                       // Set translated subset
+                       filterItem.setSubset( subsetNames );
                }
 
-               if ( this.isSticky() ) {
-                       // If this group is sticky, then change the default according to the
-                       // current selection.
-                       this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
-               }
+               items.push( filterItem );
 
+               // Store default parameter state; in this case, default is defined per filter
                if (
-                       changed ||
-                       this.active !== active ||
-                       this.currSelected !== item
+                       model.getType() === 'send_unselected_if_any' ||
+                       model.getType() === 'boolean'
                ) {
-                       this.active = active;
-                       this.currSelected = item;
-
-                       this.emit( 'update' );
+                       // Store the default parameter state
+                       // For this group type, parameter values are direct
+                       // We need to convert from a boolean to a string ('1' and '0')
+                       model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
+               } else if ( model.getType() === 'any_value' ) {
+                       model.defaultParams[ filter.name ] = filter.default;
                }
-       };
-
-       /**
-        * Get group active state
-        *
-        * @return {boolean} Active state
-        */
-       FilterGroup.prototype.isActive = function () {
-               return this.active;
-       };
-
-       /**
-        * Get group hidden state
-        *
-        * @return {boolean} Hidden state
-        */
-       FilterGroup.prototype.isHidden = function () {
-               return this.hidden;
-       };
-
-       /**
-        * Get group allow arbitrary state
-        *
-        * @return {boolean} Group allows an arbitrary value from the URL
-        */
-       FilterGroup.prototype.isAllowArbitrary = function () {
-               return this.allowArbitrary;
-       };
-
-       /**
-        * Get group maximum value for numeric groups
-        *
-        * @return {number|null} Group max value
-        */
-       FilterGroup.prototype.getMaxValue = function () {
-               return this.numericRange && this.numericRange.max !== undefined ?
-                       this.numericRange.max : null;
-       };
-
-       /**
-        * Get group minimum value for numeric groups
-        *
-        * @return {number|null} Group max value
-        */
-       FilterGroup.prototype.getMinValue = function () {
-               return this.numericRange && this.numericRange.min !== undefined ?
-                       this.numericRange.min : null;
-       };
-
-       /**
-        * Get group name
-        *
-        * @return {string} Group name
-        */
-       FilterGroup.prototype.getName = function () {
-               return this.name;
-       };
-
-       /**
-        * Get the default param state of this group
-        *
-        * @return {Object} Default param state
-        */
-       FilterGroup.prototype.getDefaultParams = function () {
-               return this.defaultParams;
-       };
-
-       /**
-        * Get the default filter state of this group
-        *
-        * @return {Object} Default filter state
-        */
-       FilterGroup.prototype.getDefaultFilters = function () {
-               return this.defaultFilters;
-       };
-
-       /**
-        * This is for a single_option and string_options group types
-        * it returns the value of the default
-        *
-        * @return {string} Value of the default
-        */
-       FilterGroup.prototype.getDefaulParamValue = function () {
-               return this.defaultParams[ this.getName() ];
-       };
-       /**
-        * Get the messags defining the 'whats this' popup for this group
-        *
-        * @return {Object} What's this messages
-        */
-       FilterGroup.prototype.getWhatsThis = function () {
-               return this.whatsThis;
-       };
-
-       /**
-        * Check whether this group has a 'what's this' message
-        *
-        * @return {boolean} This group has a what's this message
-        */
-       FilterGroup.prototype.hasWhatsThis = function () {
-               return !!this.whatsThis.body;
-       };
-
-       /**
-        * Get the conflicts associated with the entire group.
-        * Conflict object is set up by filter name keys and conflict
-        * definition. For example:
-        * [
-        *     {
-        *         filterName: {
-        *             filter: filterName,
-        *             group: group1
-        *         }
-        *     },
-        *     {
-        *         filterName2: {
-        *             filter: filterName2,
-        *             group: group2
-        *         }
-        *     }
-        * ]
-        * @return {Object} Conflict definition
-        */
-       FilterGroup.prototype.getConflicts = function () {
-               return this.conflicts;
-       };
-
-       /**
-        * Set conflicts for this group. See #getConflicts for the expected
-        * structure of the definition.
-        *
-        * @param {Object} conflicts Conflicts for this group
-        */
-       FilterGroup.prototype.setConflicts = function ( conflicts ) {
-               this.conflicts = conflicts;
-       };
-
-       /**
-        * Set conflicts for each filter item in the group based on the
-        * given conflict map
-        *
-        * @param {Object} conflicts Object representing the conflict map,
-        *  keyed by the item name, where its value is an object for all its conflicts
-        */
-       FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( conflicts[ filterItem.getName() ] ) {
-                               filterItem.setConflicts( conflicts[ filterItem.getName() ] );
-                       }
-               } );
-       };
-
-       /**
-        * Check whether this item has a potential conflict with the given item
-        *
-        * This checks whether the given item is in the list of conflicts of
-        * the current item, but makes no judgment about whether the conflict
-        * is currently at play (either one of the items may not be selected)
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
-        * @return {boolean} This item has a conflict with the given item
-        */
-       FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
-               return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
-       };
-
-       /**
-        * Check whether there are any items selected
-        *
-        * @return {boolean} Any items in the group are selected
-        */
-       FilterGroup.prototype.areAnySelected = function () {
-               return this.getItems().some( function ( filterItem ) {
-                       return filterItem.isSelected();
-               } );
-       };
+       } );
+
+       // Add items
+       this.addItems( items );
+
+       // Now that we have all items, we can apply the superset map
+       this.getItems().forEach( function ( filterItem ) {
+               filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
+       } );
+
+       // Store default parameter state; in this case, default is defined per the
+       // entire group, given by groupDefault method parameter
+       if ( this.getType() === 'string_options' ) {
+               // Store the default parameter group state
+               // For this group, the parameter is group name and value is the names
+               // of selected items
+               this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
+                       // Current values
+                       groupDefault ?
+                               groupDefault.split( this.getSeparator() ) :
+                               [],
+                       // Legal values
+                       this.getItems().map( function ( item ) {
+                               return item.getParamName();
+                       } )
+               ).join( this.getSeparator() );
+       } else if ( this.getType() === 'single_option' ) {
+               defaultParam = groupDefault !== undefined ?
+                       groupDefault : this.getItems()[ 0 ].getParamName();
+
+               // For this group, the parameter is the group name,
+               // and a single item can be selected: default or first item
+               this.defaultParams[ this.getName() ] = defaultParam;
+       }
+
+       // add highlights to defaultParams
+       this.getItems().forEach( function ( filterItem ) {
+               if ( filterItem.isHighlighted() ) {
+                       this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
+               }
+       }.bind( this ) );
 
-       /**
-        * Check whether all items selected
-        *
-        * @return {boolean} All items are selected
-        */
-       FilterGroup.prototype.areAllSelected = function () {
-               var selected = [],
-                       unselected = [];
+       // Store default filter state based on default params
+       this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
 
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isSelected() ) {
-                               selected.push( filterItem );
-                       } else {
-                               unselected.push( filterItem );
-                       }
+       // Check for filters that should be initially selected by their default value
+       if ( this.isSticky() ) {
+               // eslint-disable-next-line no-jquery/no-each-util
+               $.each( this.defaultFilters, function ( filterName, filterValue ) {
+                       model.getItemByName( filterName ).toggleSelected( filterValue );
                } );
-
-               if ( unselected.length === 0 ) {
-                       return true;
+       }
+
+       // Verify that single_option group has at least one item selected
+       if (
+               this.getType() === 'single_option' &&
+               this.findSelectedItems().length === 0
+       ) {
+               defaultParam = groupDefault !== undefined ?
+                       groupDefault : this.getItems()[ 0 ].getParamName();
+
+               // Single option means there must be a single option
+               // selected, so we have to either select the default
+               // or select the first option
+               this.selectItemByParamName( defaultParam );
+       }
+};
+
+/**
+ * Respond to filterItem update event
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
+ * @fires update
+ */
+FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
+       // Update state
+       var changed = false,
+               active = this.areAnySelected(),
+               model = this;
+
+       if ( this.getType() === 'single_option' ) {
+               // This group must have one item selected always
+               // and must never have more than one item selected at a time
+               if ( this.findSelectedItems().length === 0 ) {
+                       // Nothing is selected anymore
+                       // Select the default or the first item
+                       this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
+                               this.getItems()[ 0 ];
+                       this.currSelected.toggleSelected( true );
+                       changed = true;
+               } else if ( this.findSelectedItems().length > 1 ) {
+                       // There is more than one item selected
+                       // This should only happen if the item given
+                       // is the one that is selected, so unselect
+                       // all items that is not it
+                       this.findSelectedItems().forEach( function ( itemModel ) {
+                               // Note that in case the given item is actually
+                               // not selected, this loop will end up unselecting
+                               // all items, which would trigger the case above
+                               // when the last item is unselected anyways
+                               var selected = itemModel.getName() === item.getName() &&
+                                       item.isSelected();
+
+                               itemModel.toggleSelected( selected );
+                               if ( selected ) {
+                                       model.currSelected = itemModel;
+                               }
+                       } );
+                       changed = true;
                }
+       }
+
+       if ( this.isSticky() ) {
+               // If this group is sticky, then change the default according to the
+               // current selection.
+               this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
+       }
+
+       if (
+               changed ||
+               this.active !== active ||
+               this.currSelected !== item
+       ) {
+               this.active = active;
+               this.currSelected = item;
+
+               this.emit( 'update' );
+       }
+};
+
+/**
+ * Get group active state
+ *
+ * @return {boolean} Active state
+ */
+FilterGroup.prototype.isActive = function () {
+       return this.active;
+};
+
+/**
+ * Get group hidden state
+ *
+ * @return {boolean} Hidden state
+ */
+FilterGroup.prototype.isHidden = function () {
+       return this.hidden;
+};
+
+/**
+ * Get group allow arbitrary state
+ *
+ * @return {boolean} Group allows an arbitrary value from the URL
+ */
+FilterGroup.prototype.isAllowArbitrary = function () {
+       return this.allowArbitrary;
+};
+
+/**
+ * Get group maximum value for numeric groups
+ *
+ * @return {number|null} Group max value
+ */
+FilterGroup.prototype.getMaxValue = function () {
+       return this.numericRange && this.numericRange.max !== undefined ?
+               this.numericRange.max : null;
+};
+
+/**
+ * Get group minimum value for numeric groups
+ *
+ * @return {number|null} Group max value
+ */
+FilterGroup.prototype.getMinValue = function () {
+       return this.numericRange && this.numericRange.min !== undefined ?
+               this.numericRange.min : null;
+};
+
+/**
+ * Get group name
+ *
+ * @return {string} Group name
+ */
+FilterGroup.prototype.getName = function () {
+       return this.name;
+};
+
+/**
+ * Get the default param state of this group
+ *
+ * @return {Object} Default param state
+ */
+FilterGroup.prototype.getDefaultParams = function () {
+       return this.defaultParams;
+};
+
+/**
+ * Get the default filter state of this group
+ *
+ * @return {Object} Default filter state
+ */
+FilterGroup.prototype.getDefaultFilters = function () {
+       return this.defaultFilters;
+};
+
+/**
+ * This is for a single_option and string_options group types
+ * it returns the value of the default
+ *
+ * @return {string} Value of the default
+ */
+FilterGroup.prototype.getDefaulParamValue = function () {
+       return this.defaultParams[ this.getName() ];
+};
+/**
+ * Get the messags defining the 'whats this' popup for this group
+ *
+ * @return {Object} What's this messages
+ */
+FilterGroup.prototype.getWhatsThis = function () {
+       return this.whatsThis;
+};
+
+/**
+ * Check whether this group has a 'what's this' message
+ *
+ * @return {boolean} This group has a what's this message
+ */
+FilterGroup.prototype.hasWhatsThis = function () {
+       return !!this.whatsThis.body;
+};
+
+/**
+ * Get the conflicts associated with the entire group.
+ * Conflict object is set up by filter name keys and conflict
+ * definition. For example:
+ * [
+ *     {
+ *         filterName: {
+ *             filter: filterName,
+ *             group: group1
+ *         }
+ *     },
+ *     {
+ *         filterName2: {
+ *             filter: filterName2,
+ *             group: group2
+ *         }
+ *     }
+ * ]
+ * @return {Object} Conflict definition
+ */
+FilterGroup.prototype.getConflicts = function () {
+       return this.conflicts;
+};
+
+/**
+ * Set conflicts for this group. See #getConflicts for the expected
+ * structure of the definition.
+ *
+ * @param {Object} conflicts Conflicts for this group
+ */
+FilterGroup.prototype.setConflicts = function ( conflicts ) {
+       this.conflicts = conflicts;
+};
+
+/**
+ * Set conflicts for each filter item in the group based on the
+ * given conflict map
+ *
+ * @param {Object} conflicts Object representing the conflict map,
+ *  keyed by the item name, where its value is an object for all its conflicts
+ */
+FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
+       this.getItems().forEach( function ( filterItem ) {
+               if ( conflicts[ filterItem.getName() ] ) {
+                       filterItem.setConflicts( conflicts[ filterItem.getName() ] );
+               }
+       } );
+};
+
+/**
+ * Check whether this item has a potential conflict with the given item
+ *
+ * This checks whether the given item is in the list of conflicts of
+ * the current item, but makes no judgment about whether the conflict
+ * is currently at play (either one of the items may not be selected)
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+ * @return {boolean} This item has a conflict with the given item
+ */
+FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
+       return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+};
+
+/**
+ * Check whether there are any items selected
+ *
+ * @return {boolean} Any items in the group are selected
+ */
+FilterGroup.prototype.areAnySelected = function () {
+       return this.getItems().some( function ( filterItem ) {
+               return filterItem.isSelected();
+       } );
+};
+
+/**
+ * Check whether all items selected
+ *
+ * @return {boolean} All items are selected
+ */
+FilterGroup.prototype.areAllSelected = function () {
+       var selected = [],
+               unselected = [];
+
+       this.getItems().forEach( function ( filterItem ) {
+               if ( filterItem.isSelected() ) {
+                       selected.push( filterItem );
+               } else {
+                       unselected.push( filterItem );
+               }
+       } );
 
-               // check if every unselected is a subset of a selected
-               return unselected.every( function ( unselectedFilterItem ) {
-                       return selected.some( function ( selectedFilterItem ) {
-                               return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
-                       } );
-               } );
-       };
-
-       /**
-        * Get all selected items in this group
-        *
-        * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
-        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
-        */
-       FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
-               var excludeName = ( excludeItem && excludeItem.getName() ) || '';
-
-               return this.getItems().filter( function ( item ) {
-                       return item.getName() !== excludeName && item.isSelected();
+       if ( unselected.length === 0 ) {
+               return true;
+       }
+
+       // check if every unselected is a subset of a selected
+       return unselected.every( function ( unselectedFilterItem ) {
+               return selected.some( function ( selectedFilterItem ) {
+                       return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
                } );
-       };
-
-       /**
-        * Check whether all selected items are in conflict with the given item
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
-        * @return {boolean} All selected items are in conflict with this item
-        */
-       FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
-               var selectedItems = this.findSelectedItems( filterItem );
-
-               return selectedItems.length > 0 &&
-                       (
-                               // The group as a whole is in conflict with this item
-                               this.existsInConflicts( filterItem ) ||
-                               // All selected items are in conflict individually
-                               selectedItems.every( function ( selectedFilter ) {
-                                       return selectedFilter.existsInConflicts( filterItem );
-                               } )
-                       );
-       };
-
-       /**
-        * Check whether any of the selected items are in conflict with the given item
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
-        * @return {boolean} Any of the selected items are in conflict with this item
-        */
-       FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
-               var selectedItems = this.findSelectedItems( filterItem );
-
-               return selectedItems.length > 0 && (
+       } );
+};
+
+/**
+ * Get all selected items in this group
+ *
+ * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
+ * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+ */
+FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
+       var excludeName = ( excludeItem && excludeItem.getName() ) || '';
+
+       return this.getItems().filter( function ( item ) {
+               return item.getName() !== excludeName && item.isSelected();
+       } );
+};
+
+/**
+ * Check whether all selected items are in conflict with the given item
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+ * @return {boolean} All selected items are in conflict with this item
+ */
+FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
+       var selectedItems = this.findSelectedItems( filterItem );
+
+       return selectedItems.length > 0 &&
+               (
                        // The group as a whole is in conflict with this item
                        this.existsInConflicts( filterItem ) ||
-                       // Any selected items are in conflict individually
-                       selectedItems.some( function ( selectedFilter ) {
+                       // All selected items are in conflict individually
+                       selectedItems.every( function ( selectedFilter ) {
                                return selectedFilter.existsInConflicts( filterItem );
                        } )
                );
-       };
-
-       /**
-        * Get the parameter representation from this group
-        *
-        * @param {Object} [filterRepresentation] An object defining the state
-        *  of the filters in this group, keyed by their name and current selected
-        *  state value.
-        * @return {Object} Parameter representation
-        */
-       FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
-               var values,
-                       areAnySelected = false,
-                       buildFromCurrentState = !filterRepresentation,
-                       defaultFilters = this.getDefaultFilters(),
-                       result = {},
-                       model = this,
-                       filterParamNames = {},
-                       getSelectedParameter = function ( filters ) {
-                               var item,
-                                       selected = [];
-
-                               // Find if any are selected
-                               // eslint-disable-next-line no-jquery/no-each-util
-                               $.each( filters, function ( name, value ) {
-                                       if ( value ) {
-                                               selected.push( name );
-                                       }
-                               } );
-
-                               item = model.getItemByName( selected[ 0 ] );
-                               return ( item && item.getParamName() ) || '';
-                       };
-
-               filterRepresentation = filterRepresentation || {};
-
-               // Create or complete the filterRepresentation definition
-               this.getItems().forEach( function ( item ) {
-                       // Map filter names to their parameter names
-                       filterParamNames[ item.getName() ] = item.getParamName();
-
-                       if ( buildFromCurrentState ) {
-                               // This means we have not been given a filter representation
-                               // so we are building one based on current state
-                               filterRepresentation[ item.getName() ] = item.getValue();
-                       } else if ( filterRepresentation[ item.getName() ] === undefined ) {
-                               // We are given a filter representation, but we have to make
-                               // sure that we fill in the missing filters if there are any
-                               // we will assume they are all falsey
-                               if ( model.isSticky() ) {
-                                       filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
-                               } else {
-                                       filterRepresentation[ item.getName() ] = false;
-                               }
-                       }
-
-                       if ( filterRepresentation[ item.getName() ] ) {
-                               areAnySelected = true;
-                       }
-               } );
-
-               // Build result
-               if (
-                       this.getType() === 'send_unselected_if_any' ||
-                       this.getType() === 'boolean' ||
-                       this.getType() === 'any_value'
-               ) {
-                       // First, check if any of the items are selected at all.
-                       // If none is selected, we're treating it as if they are
-                       // all false
-
-                       // Go over the items and define the correct values
-                       // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( filterRepresentation, function ( name, value ) {
-                               // We must store all parameter values as strings '0' or '1'
-                               if ( model.getType() === 'send_unselected_if_any' ) {
-                                       result[ filterParamNames[ name ] ] = areAnySelected ?
-                                               String( Number( !value ) ) :
-                                               '0';
-                               } else if ( model.getType() === 'boolean' ) {
-                                       // Representation is straight-forward and direct from
-                                       // the parameter value to the filter state
-                                       result[ filterParamNames[ name ] ] = String( Number( !!value ) );
-                               } else if ( model.getType() === 'any_value' ) {
-                                       result[ filterParamNames[ name ] ] = value;
-                               }
-                       } );
-               } else if ( this.getType() === 'string_options' ) {
-                       values = [];
-
+};
+
+/**
+ * Check whether any of the selected items are in conflict with the given item
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+ * @return {boolean} Any of the selected items are in conflict with this item
+ */
+FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
+       var selectedItems = this.findSelectedItems( filterItem );
+
+       return selectedItems.length > 0 && (
+               // The group as a whole is in conflict with this item
+               this.existsInConflicts( filterItem ) ||
+               // Any selected items are in conflict individually
+               selectedItems.some( function ( selectedFilter ) {
+                       return selectedFilter.existsInConflicts( filterItem );
+               } )
+       );
+};
+
+/**
+ * Get the parameter representation from this group
+ *
+ * @param {Object} [filterRepresentation] An object defining the state
+ *  of the filters in this group, keyed by their name and current selected
+ *  state value.
+ * @return {Object} Parameter representation
+ */
+FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
+       var values,
+               areAnySelected = false,
+               buildFromCurrentState = !filterRepresentation,
+               defaultFilters = this.getDefaultFilters(),
+               result = {},
+               model = this,
+               filterParamNames = {},
+               getSelectedParameter = function ( filters ) {
+                       var item,
+                               selected = [];
+
+                       // Find if any are selected
                        // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( filterRepresentation, function ( name, value ) {
-                               // Collect values
+                       $.each( filters, function ( name, value ) {
                                if ( value ) {
-                                       values.push( filterParamNames[ name ] );
+                                       selected.push( name );
                                }
                        } );
 
-                       result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
-                               'all' : values.join( this.getSeparator() );
-               } else if ( this.getType() === 'single_option' ) {
-                       result[ this.getName() ] = getSelectedParameter( filterRepresentation );
+                       item = model.getItemByName( selected[ 0 ] );
+                       return ( item && item.getParamName() ) || '';
+               };
+
+       filterRepresentation = filterRepresentation || {};
+
+       // Create or complete the filterRepresentation definition
+       this.getItems().forEach( function ( item ) {
+               // Map filter names to their parameter names
+               filterParamNames[ item.getName() ] = item.getParamName();
+
+               if ( buildFromCurrentState ) {
+                       // This means we have not been given a filter representation
+                       // so we are building one based on current state
+                       filterRepresentation[ item.getName() ] = item.getValue();
+               } else if ( filterRepresentation[ item.getName() ] === undefined ) {
+                       // We are given a filter representation, but we have to make
+                       // sure that we fill in the missing filters if there are any
+                       // we will assume they are all falsey
+                       if ( model.isSticky() ) {
+                               filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
+                       } else {
+                               filterRepresentation[ item.getName() ] = false;
+                       }
                }
 
-               return result;
-       };
-
-       /**
-        * Get the filter representation this group would provide
-        * based on given parameter states.
-        *
-        * @param {Object} [paramRepresentation] An object defining a parameter
-        *  state to translate the filter state from. If not given, an object
-        *  representing all filters as falsey is returned; same as if the parameter
-        *  given were an empty object, or had some of the filters missing.
-        * @return {Object} Filter representation
-        */
-       FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
-               var areAnySelected, paramValues, item, currentValue,
-                       oneWasSelected = false,
-                       defaultParams = this.getDefaultParams(),
-                       expandedParams = $.extend( true, {}, paramRepresentation ),
-                       model = this,
-                       paramToFilterMap = {},
-                       result = {};
-
-               if ( this.isSticky() ) {
-                       // If the group is sticky, check if all parameters are represented
-                       // and for those that aren't represented, add them with their default
-                       // values
-                       paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
+               if ( filterRepresentation[ item.getName() ] ) {
+                       areAnySelected = true;
                }
-
-               paramRepresentation = paramRepresentation || {};
-               if (
-                       this.getType() === 'send_unselected_if_any' ||
-                       this.getType() === 'boolean' ||
-                       this.getType() === 'any_value'
-               ) {
-                       // Go over param representation; map and check for selections
-                       this.getItems().forEach( function ( filterItem ) {
-                               var paramName = filterItem.getParamName();
-
-                               expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
-                               paramToFilterMap[ paramName ] = filterItem;
-
-                               if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
-                                       areAnySelected = true;
-                               }
-                       } );
-
-                       // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( expandedParams, function ( paramName, paramValue ) {
-                               var filterItem = paramToFilterMap[ paramName ];
-
-                               if ( model.getType() === 'send_unselected_if_any' ) {
-                                       // Flip the definition between the parameter
-                                       // state and the filter state
-                                       // This is what the 'toggleSelected' value of the filter is
-                                       result[ filterItem.getName() ] = areAnySelected ?
-                                               !Number( paramValue ) :
-                                               // Otherwise, there are no selected items in the
-                                               // group, which means the state is false
-                                               false;
-                               } else if ( model.getType() === 'boolean' ) {
-                                       // Straight-forward definition of state
-                                       result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
-                               } else if ( model.getType() === 'any_value' ) {
-                                       result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
-                               }
-                       } );
-               } else if ( this.getType() === 'string_options' ) {
-                       currentValue = paramRepresentation[ this.getName() ] || '';
-
-                       // Normalize the given parameter values
-                       paramValues = mw.rcfilters.utils.normalizeParamOptions(
-                               // Given
-                               currentValue.split(
-                                       this.getSeparator()
-                               ),
-                               // Allowed values
-                               this.getItems().map( function ( filterItem ) {
-                                       return filterItem.getParamName();
-                               } )
-                       );
-                       // Translate the parameter values into a filter selection state
-                       this.getItems().forEach( function ( filterItem ) {
-                               // All true (either because all values are written or the term 'all' is written)
-                               // is the same as all filters set to true
-                               result[ filterItem.getName() ] = (
-                                       // If it is the word 'all'
-                                       paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
-                                       // All values are written
-                                       paramValues.length === model.getItemCount()
-                               ) ?
-                                       true :
-                                       // Otherwise, the filter is selected only if it appears in the parameter values
-                                       paramValues.indexOf( filterItem.getParamName() ) > -1;
-                       } );
-               } else if ( this.getType() === 'single_option' ) {
-                       // There is parameter that fits a single filter and if not, get the default
-                       this.getItems().forEach( function ( filterItem ) {
-                               var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];
-
-                               result[ filterItem.getName() ] = selected;
-                               oneWasSelected = oneWasSelected || selected;
-                       } );
-               }
-
-               // Go over result and make sure all filters are represented.
-               // If any filters are missing, they will get a falsey value
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( result[ filterItem.getName() ] === undefined ) {
-                               result[ filterItem.getName() ] = this.getFalsyValue();
+       } );
+
+       // Build result
+       if (
+               this.getType() === 'send_unselected_if_any' ||
+               this.getType() === 'boolean' ||
+               this.getType() === 'any_value'
+       ) {
+               // First, check if any of the items are selected at all.
+               // If none is selected, we're treating it as if they are
+               // all false
+
+               // Go over the items and define the correct values
+               // eslint-disable-next-line no-jquery/no-each-util
+               $.each( filterRepresentation, function ( name, value ) {
+                       // We must store all parameter values as strings '0' or '1'
+                       if ( model.getType() === 'send_unselected_if_any' ) {
+                               result[ filterParamNames[ name ] ] = areAnySelected ?
+                                       String( Number( !value ) ) :
+                                       '0';
+                       } else if ( model.getType() === 'boolean' ) {
+                               // Representation is straight-forward and direct from
+                               // the parameter value to the filter state
+                               result[ filterParamNames[ name ] ] = String( Number( !!value ) );
+                       } else if ( model.getType() === 'any_value' ) {
+                               result[ filterParamNames[ name ] ] = value;
                        }
-               }.bind( this ) );
-
-               // Make sure that at least one option is selected in
-               // single_option groups, no matter what path was taken
-               // If none was selected by the given definition, then
-               // we need to select the one in the base state -- either
-               // the default given, or the first item
-               if (
-                       this.getType() === 'single_option' &&
-                       !oneWasSelected
-               ) {
-                       item = this.getItems()[ 0 ];
-                       if ( defaultParams[ this.getName() ] ) {
-                               item = this.getItemByParamName( defaultParams[ this.getName() ] );
+               } );
+       } else if ( this.getType() === 'string_options' ) {
+               values = [];
+
+               // eslint-disable-next-line no-jquery/no-each-util
+               $.each( filterRepresentation, function ( name, value ) {
+                       // Collect values
+                       if ( value ) {
+                               values.push( filterParamNames[ name ] );
                        }
+               } );
 
-                       result[ item.getName() ] = true;
-               }
-
-               return result;
-       };
-
-       /**
-        * @return {*} The appropriate falsy value for this group type
-        */
-       FilterGroup.prototype.getFalsyValue = function () {
-               return this.getType() === 'any_value' ? '' : false;
-       };
+               result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
+                       'all' : values.join( this.getSeparator() );
+       } else if ( this.getType() === 'single_option' ) {
+               result[ this.getName() ] = getSelectedParameter( filterRepresentation );
+       }
+
+       return result;
+};
+
+/**
+ * Get the filter representation this group would provide
+ * based on given parameter states.
+ *
+ * @param {Object} [paramRepresentation] An object defining a parameter
+ *  state to translate the filter state from. If not given, an object
+ *  representing all filters as falsey is returned; same as if the parameter
+ *  given were an empty object, or had some of the filters missing.
+ * @return {Object} Filter representation
+ */
+FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
+       var areAnySelected, paramValues, item, currentValue,
+               oneWasSelected = false,
+               defaultParams = this.getDefaultParams(),
+               expandedParams = $.extend( true, {}, paramRepresentation ),
+               model = this,
+               paramToFilterMap = {},
+               result = {};
+
+       if ( this.isSticky() ) {
+               // If the group is sticky, check if all parameters are represented
+               // and for those that aren't represented, add them with their default
+               // values
+               paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
+       }
+
+       paramRepresentation = paramRepresentation || {};
+       if (
+               this.getType() === 'send_unselected_if_any' ||
+               this.getType() === 'boolean' ||
+               this.getType() === 'any_value'
+       ) {
+               // Go over param representation; map and check for selections
+               this.getItems().forEach( function ( filterItem ) {
+                       var paramName = filterItem.getParamName();
 
-       /**
-        * Get current selected state of all filter items in this group
-        *
-        * @return {Object} Selected state
-        */
-       FilterGroup.prototype.getSelectedState = function () {
-               var state = {};
+                       expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
+                       paramToFilterMap[ paramName ] = filterItem;
 
-               this.getItems().forEach( function ( filterItem ) {
-                       state[ filterItem.getName() ] = filterItem.getValue();
+                       if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
+                               areAnySelected = true;
+                       }
                } );
 
-               return state;
-       };
-
-       /**
-        * Get item by its filter name
-        *
-        * @param {string} filterName Filter name
-        * @return {mw.rcfilters.dm.FilterItem} Filter item
-        */
-       FilterGroup.prototype.getItemByName = function ( filterName ) {
-               return this.getItems().filter( function ( item ) {
-                       return item.getName() === filterName;
-               } )[ 0 ];
-       };
-
-       /**
-        * Select an item by its parameter name
-        *
-        * @param {string} paramName Filter parameter name
-        */
-       FilterGroup.prototype.selectItemByParamName = function ( paramName ) {
-               this.getItems().forEach( function ( item ) {
-                       item.toggleSelected( item.getParamName() === String( paramName ) );
+               // eslint-disable-next-line no-jquery/no-each-util
+               $.each( expandedParams, function ( paramName, paramValue ) {
+                       var filterItem = paramToFilterMap[ paramName ];
+
+                       if ( model.getType() === 'send_unselected_if_any' ) {
+                               // Flip the definition between the parameter
+                               // state and the filter state
+                               // This is what the 'toggleSelected' value of the filter is
+                               result[ filterItem.getName() ] = areAnySelected ?
+                                       !Number( paramValue ) :
+                                       // Otherwise, there are no selected items in the
+                                       // group, which means the state is false
+                                       false;
+                       } else if ( model.getType() === 'boolean' ) {
+                               // Straight-forward definition of state
+                               result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
+                       } else if ( model.getType() === 'any_value' ) {
+                               result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
+                       }
                } );
-       };
-
-       /**
-        * Get item by its parameter name
-        *
-        * @param {string} paramName Parameter name
-        * @return {mw.rcfilters.dm.FilterItem} Filter item
-        */
-       FilterGroup.prototype.getItemByParamName = function ( paramName ) {
-               return this.getItems().filter( function ( item ) {
-                       return item.getParamName() === String( paramName );
-               } )[ 0 ];
-       };
-
-       /**
-        * Get group type
-        *
-        * @return {string} Group type
-        */
-       FilterGroup.prototype.getType = function () {
-               return this.type;
-       };
-
-       /**
-        * Check whether this group is represented by a single parameter
-        * or whether each item is its own parameter
-        *
-        * @return {boolean} This group is a single parameter
-        */
-       FilterGroup.prototype.isPerGroupRequestParameter = function () {
-               return (
-                       this.getType() === 'string_options' ||
-                       this.getType() === 'single_option'
+       } else if ( this.getType() === 'string_options' ) {
+               currentValue = paramRepresentation[ this.getName() ] || '';
+
+               // Normalize the given parameter values
+               paramValues = mw.rcfilters.utils.normalizeParamOptions(
+                       // Given
+                       currentValue.split(
+                               this.getSeparator()
+                       ),
+                       // Allowed values
+                       this.getItems().map( function ( filterItem ) {
+                               return filterItem.getParamName();
+                       } )
                );
-       };
-
-       /**
-        * Get display group
-        *
-        * @return {string} Display group
-        */
-       FilterGroup.prototype.getView = function () {
-               return this.view;
-       };
-
-       /**
-        * Get the prefix used for the filter names inside this group.
-        *
-        * @param {string} [name] Filter name to prefix
-        * @return {string} Group prefix
-        */
-       FilterGroup.prototype.getNamePrefix = function () {
-               return this.getName() + '__';
-       };
-
-       /**
-        * Get a filter name with the prefix used for the filter names inside this group.
-        *
-        * @param {string} name Filter name to prefix
-        * @return {string} Group prefix
-        */
-       FilterGroup.prototype.getPrefixedName = function ( name ) {
-               return this.getNamePrefix() + name;
-       };
-
-       /**
-        * Get group's title
-        *
-        * @return {string} Title
-        */
-       FilterGroup.prototype.getTitle = function () {
-               return this.title;
-       };
-
-       /**
-        * Get group's values separator
-        *
-        * @return {string} Values separator
-        */
-       FilterGroup.prototype.getSeparator = function () {
-               return this.separator;
-       };
-
-       /**
-        * Check whether the group is defined as full coverage
-        *
-        * @return {boolean} Group is full coverage
-        */
-       FilterGroup.prototype.isFullCoverage = function () {
-               return this.fullCoverage;
-       };
-
-       /**
-        * Check whether the group is defined as sticky default
-        *
-        * @return {boolean} Group is sticky default
-        */
-       FilterGroup.prototype.isSticky = function () {
-               return this.sticky;
-       };
-
-       /**
-        * Normalize a value given to this group. This is mostly for correcting
-        * arbitrary values for 'single option' groups, given by the user settings
-        * or the URL that can go outside the limits that are allowed.
-        *
-        * @param  {string} value Given value
-        * @return {string} Corrected value
-        */
-       FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
-               if (
-                       this.getType() === 'single_option' &&
-                       this.isAllowArbitrary()
-               ) {
-                       if (
-                               this.getMaxValue() !== null &&
-                               value > this.getMaxValue()
-                       ) {
-                               // Change the value to the actual max value
-                               return String( this.getMaxValue() );
-                       } else if (
-                               this.getMinValue() !== null &&
-                               value < this.getMinValue()
-                       ) {
-                               // Change the value to the actual min value
-                               return String( this.getMinValue() );
-                       }
-               }
-
-               return value;
-       };
+               // Translate the parameter values into a filter selection state
+               this.getItems().forEach( function ( filterItem ) {
+                       // All true (either because all values are written or the term 'all' is written)
+                       // is the same as all filters set to true
+                       result[ filterItem.getName() ] = (
+                               // If it is the word 'all'
+                               paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
+                               // All values are written
+                               paramValues.length === model.getItemCount()
+                       ) ?
+                               true :
+                               // Otherwise, the filter is selected only if it appears in the parameter values
+                               paramValues.indexOf( filterItem.getParamName() ) > -1;
+               } );
+       } else if ( this.getType() === 'single_option' ) {
+               // There is parameter that fits a single filter and if not, get the default
+               this.getItems().forEach( function ( filterItem ) {
+                       var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];
 
-       /**
-        * Toggle the visibility of this group
-        *
-        * @param {boolean} [isVisible] Item is visible
-        */
-       FilterGroup.prototype.toggleVisible = function ( isVisible ) {
-               isVisible = isVisible === undefined ? !this.visible : isVisible;
+                       result[ filterItem.getName() ] = selected;
+                       oneWasSelected = oneWasSelected || selected;
+               } );
+       }
 
-               if ( this.visible !== isVisible ) {
-                       this.visible = isVisible;
-                       this.emit( 'update' );
+       // Go over result and make sure all filters are represented.
+       // If any filters are missing, they will get a falsey value
+       this.getItems().forEach( function ( filterItem ) {
+               if ( result[ filterItem.getName() ] === undefined ) {
+                       result[ filterItem.getName() ] = this.getFalsyValue();
+               }
+       }.bind( this ) );
+
+       // Make sure that at least one option is selected in
+       // single_option groups, no matter what path was taken
+       // If none was selected by the given definition, then
+       // we need to select the one in the base state -- either
+       // the default given, or the first item
+       if (
+               this.getType() === 'single_option' &&
+               !oneWasSelected
+       ) {
+               item = this.getItems()[ 0 ];
+               if ( defaultParams[ this.getName() ] ) {
+                       item = this.getItemByParamName( defaultParams[ this.getName() ] );
                }
-       };
-
-       /**
-        * Check whether the group is visible
-        *
-        * @return {boolean} Group is visible
-        */
-       FilterGroup.prototype.isVisible = function () {
-               return this.visible;
-       };
-
-       /**
-        * Set the visibility of the items under this group by the given items array
-        *
-        * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
-        */
-       FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
-               this.getItems().forEach( function ( itemModel ) {
-                       itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
-               } );
-       };
 
-       module.exports = FilterGroup;
-}() );
+               result[ item.getName() ] = true;
+       }
+
+       return result;
+};
+
+/**
+ * @return {*} The appropriate falsy value for this group type
+ */
+FilterGroup.prototype.getFalsyValue = function () {
+       return this.getType() === 'any_value' ? '' : false;
+};
+
+/**
+ * Get current selected state of all filter items in this group
+ *
+ * @return {Object} Selected state
+ */
+FilterGroup.prototype.getSelectedState = function () {
+       var state = {};
+
+       this.getItems().forEach( function ( filterItem ) {
+               state[ filterItem.getName() ] = filterItem.getValue();
+       } );
+
+       return state;
+};
+
+/**
+ * Get item by its filter name
+ *
+ * @param {string} filterName Filter name
+ * @return {mw.rcfilters.dm.FilterItem} Filter item
+ */
+FilterGroup.prototype.getItemByName = function ( filterName ) {
+       return this.getItems().filter( function ( item ) {
+               return item.getName() === filterName;
+       } )[ 0 ];
+};
+
+/**
+ * Select an item by its parameter name
+ *
+ * @param {string} paramName Filter parameter name
+ */
+FilterGroup.prototype.selectItemByParamName = function ( paramName ) {
+       this.getItems().forEach( function ( item ) {
+               item.toggleSelected( item.getParamName() === String( paramName ) );
+       } );
+};
+
+/**
+ * Get item by its parameter name
+ *
+ * @param {string} paramName Parameter name
+ * @return {mw.rcfilters.dm.FilterItem} Filter item
+ */
+FilterGroup.prototype.getItemByParamName = function ( paramName ) {
+       return this.getItems().filter( function ( item ) {
+               return item.getParamName() === String( paramName );
+       } )[ 0 ];
+};
+
+/**
+ * Get group type
+ *
+ * @return {string} Group type
+ */
+FilterGroup.prototype.getType = function () {
+       return this.type;
+};
+
+/**
+ * Check whether this group is represented by a single parameter
+ * or whether each item is its own parameter
+ *
+ * @return {boolean} This group is a single parameter
+ */
+FilterGroup.prototype.isPerGroupRequestParameter = function () {
+       return (
+               this.getType() === 'string_options' ||
+               this.getType() === 'single_option'
+       );
+};
+
+/**
+ * Get display group
+ *
+ * @return {string} Display group
+ */
+FilterGroup.prototype.getView = function () {
+       return this.view;
+};
+
+/**
+ * Get the prefix used for the filter names inside this group.
+ *
+ * @param {string} [name] Filter name to prefix
+ * @return {string} Group prefix
+ */
+FilterGroup.prototype.getNamePrefix = function () {
+       return this.getName() + '__';
+};
+
+/**
+ * Get a filter name with the prefix used for the filter names inside this group.
+ *
+ * @param {string} name Filter name to prefix
+ * @return {string} Group prefix
+ */
+FilterGroup.prototype.getPrefixedName = function ( name ) {
+       return this.getNamePrefix() + name;
+};
+
+/**
+ * Get group's title
+ *
+ * @return {string} Title
+ */
+FilterGroup.prototype.getTitle = function () {
+       return this.title;
+};
+
+/**
+ * Get group's values separator
+ *
+ * @return {string} Values separator
+ */
+FilterGroup.prototype.getSeparator = function () {
+       return this.separator;
+};
+
+/**
+ * Check whether the group is defined as full coverage
+ *
+ * @return {boolean} Group is full coverage
+ */
+FilterGroup.prototype.isFullCoverage = function () {
+       return this.fullCoverage;
+};
+
+/**
+ * Check whether the group is defined as sticky default
+ *
+ * @return {boolean} Group is sticky default
+ */
+FilterGroup.prototype.isSticky = function () {
+       return this.sticky;
+};
+
+/**
+ * Normalize a value given to this group. This is mostly for correcting
+ * arbitrary values for 'single option' groups, given by the user settings
+ * or the URL that can go outside the limits that are allowed.
+ *
+ * @param  {string} value Given value
+ * @return {string} Corrected value
+ */
+FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
+       if (
+               this.getType() === 'single_option' &&
+               this.isAllowArbitrary()
+       ) {
+               if (
+                       this.getMaxValue() !== null &&
+                       value > this.getMaxValue()
+               ) {
+                       // Change the value to the actual max value
+                       return String( this.getMaxValue() );
+               } else if (
+                       this.getMinValue() !== null &&
+                       value < this.getMinValue()
+               ) {
+                       // Change the value to the actual min value
+                       return String( this.getMinValue() );
+               }
+       }
+
+       return value;
+};
+
+/**
+ * Toggle the visibility of this group
+ *
+ * @param {boolean} [isVisible] Item is visible
+ */
+FilterGroup.prototype.toggleVisible = function ( isVisible ) {
+       isVisible = isVisible === undefined ? !this.visible : isVisible;
+
+       if ( this.visible !== isVisible ) {
+               this.visible = isVisible;
+               this.emit( 'update' );
+       }
+};
+
+/**
+ * Check whether the group is visible
+ *
+ * @return {boolean} Group is visible
+ */
+FilterGroup.prototype.isVisible = function () {
+       return this.visible;
+};
+
+/**
+ * Set the visibility of the items under this group by the given items array
+ *
+ * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
+ */
+FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
+       this.getItems().forEach( function ( itemModel ) {
+               itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
+       } );
+};
+
+module.exports = FilterGroup;
index 1138c4e..8725f51 100644 (file)
-( function () {
-       var ItemModel = require( './ItemModel.js' ),
-               FilterItem;
-
-       /**
-        * Filter item model
-        *
-        * @class mw.rcfilters.dm.FilterItem
-        * @extends mw.rcfilters.dm.ItemModel
-        *
-        * @constructor
-        * @param {string} param Filter param name
-        * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
-        * @param {Object} config Configuration object
-        * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
-        *  selected, makes inactive.
-        * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
-        * @cfg {Object} [conflicts] Defines the conflicts for this filter
-        * @cfg {boolean} [visible=true] The visibility of the group
-        */
-       FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
-               config = config || {};
-
-               this.groupModel = groupModel;
-
-               // Parent
-               FilterItem.parent.call( this, param, $.extend( {
-                       namePrefix: this.groupModel.getNamePrefix()
-               }, config ) );
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               // Interaction definitions
-               this.subset = config.subset || [];
-               this.conflicts = config.conflicts || {};
-               this.superset = [];
-               this.visible = config.visible === undefined ? true : !!config.visible;
-
-               // Interaction states
-               this.included = false;
-               this.conflicted = false;
-               this.fullyCovered = false;
+var ItemModel = require( './ItemModel.js' ),
+       FilterItem;
+
+/**
+ * Filter item model
+ *
+ * @class mw.rcfilters.dm.FilterItem
+ * @extends mw.rcfilters.dm.ItemModel
+ *
+ * @constructor
+ * @param {string} param Filter param name
+ * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
+ * @param {Object} config Configuration object
+ * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
+ *  selected, makes inactive.
+ * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
+ * @cfg {Object} [conflicts] Defines the conflicts for this filter
+ * @cfg {boolean} [visible=true] The visibility of the group
+ */
+FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
+       config = config || {};
+
+       this.groupModel = groupModel;
+
+       // Parent
+       FilterItem.parent.call( this, param, $.extend( {
+               namePrefix: this.groupModel.getNamePrefix()
+       }, config ) );
+       // Mixin constructor
+       OO.EventEmitter.call( this );
+
+       // Interaction definitions
+       this.subset = config.subset || [];
+       this.conflicts = config.conflicts || {};
+       this.superset = [];
+       this.visible = config.visible === undefined ? true : !!config.visible;
+
+       // Interaction states
+       this.included = false;
+       this.conflicted = false;
+       this.fullyCovered = false;
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterItem, ItemModel );
+
+/* Methods */
+
+/**
+ * Return the representation of the state of this item.
+ *
+ * @return {Object} State of the object
+ */
+FilterItem.prototype.getState = function () {
+       return {
+               selected: this.isSelected(),
+               included: this.isIncluded(),
+               conflicted: this.isConflicted(),
+               fullyCovered: this.isFullyCovered()
        };
-
-       /* Initialization */
-
-       OO.inheritClass( FilterItem, ItemModel );
-
-       /* Methods */
-
-       /**
-        * Return the representation of the state of this item.
-        *
-        * @return {Object} State of the object
-        */
-       FilterItem.prototype.getState = function () {
-               return {
-                       selected: this.isSelected(),
-                       included: this.isIncluded(),
-                       conflicted: this.isConflicted(),
-                       fullyCovered: this.isFullyCovered()
-               };
-       };
-
-       /**
-        * Get the message for the display area for the currently active conflict
-        *
-        * @private
-        * @return {string} Conflict result message key
-        */
-       FilterItem.prototype.getCurrentConflictResultMessage = function () {
-               var details = {};
-
-               // First look in filter's own conflicts
-               details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
-               if ( !details.message ) {
-                       // Fall back onto conflicts in the group
-                       details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
-               }
-
-               return details.message;
-       };
-
-       /**
-        * Get the details of the active conflict on this filter
-        *
-        * @private
-        * @param {Object} conflicts Conflicts to examine
-        * @param {string} [key='contextDescription'] Message key
-        * @return {Object} Object with conflict message and conflict items
-        * @return {string} return.message Conflict message
-        * @return {string[]} return.names Conflicting item labels
-        */
-       FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
-               var group,
-                       conflictMessage = '',
-                       itemLabels = [];
-
-               key = key || 'contextDescription';
-
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( conflicts, function ( filterName, conflict ) {
-                       if ( !conflict.item.isSelected() ) {
-                               return;
-                       }
-
-                       if ( !conflictMessage ) {
-                               conflictMessage = conflict[ key ];
-                               group = conflict.group;
-                       }
-
-                       if ( group === conflict.group ) {
-                               itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
-                       }
-               } );
-
-               return {
-                       message: conflictMessage,
-                       names: itemLabels
-               };
-
-       };
-
-       /**
-        * @inheritdoc
-        */
-       FilterItem.prototype.getStateMessage = function () {
-               var messageKey, details, superset,
-                       affectingItems = [];
-
-               if ( this.isSelected() ) {
-                       if ( this.isConflicted() ) {
-                               // First look in filter's own conflicts
-                               details = this.getConflictDetails( this.getOwnConflicts() );
-                               if ( !details.message ) {
-                                       // Fall back onto conflicts in the group
-                                       details = this.getConflictDetails( this.getGroupModel().getConflicts() );
-                               }
-
-                               messageKey = details.message;
-                               affectingItems = details.names;
-                       } else if ( this.isIncluded() && !this.isHighlighted() ) {
-                               // We only show the 'no effect' full-coverage message
-                               // if the item is also not highlighted. See T161273
-                               superset = this.getSuperset();
-                               // For this message we need to collect the affecting superset
-                               affectingItems = this.getGroupModel().findSelectedItems( this )
-                                       .filter( function ( item ) {
-                                               return superset.indexOf( item.getName() ) !== -1;
-                                       } )
-                                       .map( function ( item ) {
-                                               return mw.msg( 'quotation-marks', item.getLabel() );
-                                       } );
-
-                               messageKey = 'rcfilters-state-message-subset';
-                       } else if ( this.isFullyCovered() && !this.isHighlighted() ) {
-                               affectingItems = this.getGroupModel().findSelectedItems( this )
-                                       .map( function ( item ) {
-                                               return mw.msg( 'quotation-marks', item.getLabel() );
-                                       } );
-
-                               messageKey = 'rcfilters-state-message-fullcoverage';
-                       }
+};
+
+/**
+ * Get the message for the display area for the currently active conflict
+ *
+ * @private
+ * @return {string} Conflict result message key
+ */
+FilterItem.prototype.getCurrentConflictResultMessage = function () {
+       var details = {};
+
+       // First look in filter's own conflicts
+       details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
+       if ( !details.message ) {
+               // Fall back onto conflicts in the group
+               details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
+       }
+
+       return details.message;
+};
+
+/**
+ * Get the details of the active conflict on this filter
+ *
+ * @private
+ * @param {Object} conflicts Conflicts to examine
+ * @param {string} [key='contextDescription'] Message key
+ * @return {Object} Object with conflict message and conflict items
+ * @return {string} return.message Conflict message
+ * @return {string[]} return.names Conflicting item labels
+ */
+FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
+       var group,
+               conflictMessage = '',
+               itemLabels = [];
+
+       key = key || 'contextDescription';
+
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( conflicts, function ( filterName, conflict ) {
+               if ( !conflict.item.isSelected() ) {
+                       return;
                }
 
-               if ( messageKey ) {
-                       // Build message
-                       return mw.msg(
-                               messageKey,
-                               mw.language.listToText( affectingItems ),
-                               affectingItems.length
-                       );
+               if ( !conflictMessage ) {
+                       conflictMessage = conflict[ key ];
+                       group = conflict.group;
                }
 
-               // Display description
-               return this.getDescription();
-       };
-
-       /**
-        * Get the model of the group this filter belongs to
-        *
-        * @return {mw.rcfilters.dm.FilterGroup} Filter group model
-        */
-       FilterItem.prototype.getGroupModel = function () {
-               return this.groupModel;
-       };
-
-       /**
-        * Get the group name this filter belongs to
-        *
-        * @return {string} Filter group name
-        */
-       FilterItem.prototype.getGroupName = function () {
-               return this.groupModel.getName();
-       };
-
-       /**
-        * Get filter subset
-        * This is a list of filter names that are defined to be included
-        * when this filter is selected.
-        *
-        * @return {string[]} Filter subset
-        */
-       FilterItem.prototype.getSubset = function () {
-               return this.subset;
-       };
-
-       /**
-        * Get filter superset
-        * This is a generated list of filters that define this filter
-        * to be included when either of them is selected.
-        *
-        * @return {string[]} Filter superset
-        */
-       FilterItem.prototype.getSuperset = function () {
-               return this.superset;
-       };
-
-       /**
-        * Check whether the filter is currently in a conflict state
-        *
-        * @return {boolean} Filter is in conflict state
-        */
-       FilterItem.prototype.isConflicted = function () {
-               return this.conflicted;
-       };
-
-       /**
-        * Check whether the filter is currently in an already included subset
-        *
-        * @return {boolean} Filter is in an already-included subset
-        */
-       FilterItem.prototype.isIncluded = function () {
-               return this.included;
-       };
-
-       /**
-        * Check whether the filter is currently fully covered
-        *
-        * @return {boolean} Filter is in fully-covered state
-        */
-       FilterItem.prototype.isFullyCovered = function () {
-               return this.fullyCovered;
-       };
-
-       /**
-        * Get all conflicts associated with this filter or its group
-        *
-        * Conflict object is set up by filter name keys and conflict
-        * definition. For example:
-        *
-        *  {
-        *      filterName: {
-        *          filter: filterName,
-        *          group: group1,
-        *          label: itemLabel,
-        *          item: itemModel
-        *      }
-        *      filterName2: {
-        *          filter: filterName2,
-        *          group: group2
-        *          label: itemLabel2,
-        *          item: itemModel2
-        *      }
-        *  }
-        *
-        * @return {Object} Filter conflicts
-        */
-       FilterItem.prototype.getConflicts = function () {
-               return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
-       };
-
-       /**
-        * Get the conflicts associated with this filter
-        *
-        * @return {Object} Filter conflicts
-        */
-       FilterItem.prototype.getOwnConflicts = function () {
-               return this.conflicts;
-       };
-
-       /**
-        * Set conflicts for this filter. See #getConflicts for the expected
-        * structure of the definition.
-        *
-        * @param {Object} conflicts Conflicts for this filter
-        */
-       FilterItem.prototype.setConflicts = function ( conflicts ) {
-               this.conflicts = conflicts || {};
-       };
-
-       /**
-        * Set filter superset
-        *
-        * @param {string[]} superset Filter superset
-        */
-       FilterItem.prototype.setSuperset = function ( superset ) {
-               this.superset = superset || [];
-       };
-
-       /**
-        * Set filter subset
-        *
-        * @param {string[]} subset Filter subset
-        */
-       FilterItem.prototype.setSubset = function ( subset ) {
-               this.subset = subset || [];
-       };
-
-       /**
-        * Check whether a filter exists in the subset list for this filter
-        *
-        * @param {string} filterName Filter name
-        * @return {boolean} Filter name is in the subset list
-        */
-       FilterItem.prototype.existsInSubset = function ( filterName ) {
-               return this.subset.indexOf( filterName ) > -1;
-       };
-
-       /**
-        * Check whether this item has a potential conflict with the given item
-        *
-        * This checks whether the given item is in the list of conflicts of
-        * the current item, but makes no judgment about whether the conflict
-        * is currently at play (either one of the items may not be selected)
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
-        * @return {boolean} This item has a conflict with the given item
-        */
-       FilterItem.prototype.existsInConflicts = function ( filterItem ) {
-               return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
-       };
-
-       /**
-        * Set the state of this filter as being conflicted
-        * (This means any filters in its conflicts are selected)
-        *
-        * @param {boolean} [conflicted] Filter is in conflict state
-        * @fires update
-        */
-       FilterItem.prototype.toggleConflicted = function ( conflicted ) {
-               conflicted = conflicted === undefined ? !this.conflicted : conflicted;
-
-               if ( this.conflicted !== conflicted ) {
-                       this.conflicted = conflicted;
-                       this.emit( 'update' );
+               if ( group === conflict.group ) {
+                       itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
                }
-       };
+       } );
 
-       /**
-        * Set the state of this filter as being already included
-        * (This means any filters in its superset are selected)
-        *
-        * @param {boolean} [included] Filter is included as part of a subset
-        * @fires update
-        */
-       FilterItem.prototype.toggleIncluded = function ( included ) {
-               included = included === undefined ? !this.included : included;
-
-               if ( this.included !== included ) {
-                       this.included = included;
-                       this.emit( 'update' );
-               }
+       return {
+               message: conflictMessage,
+               names: itemLabels
        };
 
-       /**
-        * Toggle the fully covered state of the item
-        *
-        * @param {boolean} [isFullyCovered] Filter is fully covered
-        * @fires update
-        */
-       FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
-               isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
-
-               if ( this.fullyCovered !== isFullyCovered ) {
-                       this.fullyCovered = isFullyCovered;
-                       this.emit( 'update' );
-               }
-       };
+};
+
+/**
+ * @inheritdoc
+ */
+FilterItem.prototype.getStateMessage = function () {
+       var messageKey, details, superset,
+               affectingItems = [];
+
+       if ( this.isSelected() ) {
+               if ( this.isConflicted() ) {
+                       // First look in filter's own conflicts
+                       details = this.getConflictDetails( this.getOwnConflicts() );
+                       if ( !details.message ) {
+                               // Fall back onto conflicts in the group
+                               details = this.getConflictDetails( this.getGroupModel().getConflicts() );
+                       }
 
-       /**
-        * Toggle the visibility of this item
-        *
-        * @param {boolean} [isVisible] Item is visible
-        */
-       FilterItem.prototype.toggleVisible = function ( isVisible ) {
-               isVisible = isVisible === undefined ? !this.visible : !!isVisible;
-
-               if ( this.visible !== isVisible ) {
-                       this.visible = isVisible;
-                       this.emit( 'update' );
+                       messageKey = details.message;
+                       affectingItems = details.names;
+               } else if ( this.isIncluded() && !this.isHighlighted() ) {
+                       // We only show the 'no effect' full-coverage message
+                       // if the item is also not highlighted. See T161273
+                       superset = this.getSuperset();
+                       // For this message we need to collect the affecting superset
+                       affectingItems = this.getGroupModel().findSelectedItems( this )
+                               .filter( function ( item ) {
+                                       return superset.indexOf( item.getName() ) !== -1;
+                               } )
+                               .map( function ( item ) {
+                                       return mw.msg( 'quotation-marks', item.getLabel() );
+                               } );
+
+                       messageKey = 'rcfilters-state-message-subset';
+               } else if ( this.isFullyCovered() && !this.isHighlighted() ) {
+                       affectingItems = this.getGroupModel().findSelectedItems( this )
+                               .map( function ( item ) {
+                                       return mw.msg( 'quotation-marks', item.getLabel() );
+                               } );
+
+                       messageKey = 'rcfilters-state-message-fullcoverage';
                }
-       };
-
-       /**
-        * Check whether the item is visible
-        *
-        * @return {boolean} Item is visible
-        */
-       FilterItem.prototype.isVisible = function () {
-               return this.visible;
-       };
-
-       module.exports = FilterItem;
-
-}() );
+       }
+
+       if ( messageKey ) {
+               // Build message
+               return mw.msg(
+                       messageKey,
+                       mw.language.listToText( affectingItems ),
+                       affectingItems.length
+               );
+       }
+
+       // Display description
+       return this.getDescription();
+};
+
+/**
+ * Get the model of the group this filter belongs to
+ *
+ * @return {mw.rcfilters.dm.FilterGroup} Filter group model
+ */
+FilterItem.prototype.getGroupModel = function () {
+       return this.groupModel;
+};
+
+/**
+ * Get the group name this filter belongs to
+ *
+ * @return {string} Filter group name
+ */
+FilterItem.prototype.getGroupName = function () {
+       return this.groupModel.getName();
+};
+
+/**
+ * Get filter subset
+ * This is a list of filter names that are defined to be included
+ * when this filter is selected.
+ *
+ * @return {string[]} Filter subset
+ */
+FilterItem.prototype.getSubset = function () {
+       return this.subset;
+};
+
+/**
+ * Get filter superset
+ * This is a generated list of filters that define this filter
+ * to be included when either of them is selected.
+ *
+ * @return {string[]} Filter superset
+ */
+FilterItem.prototype.getSuperset = function () {
+       return this.superset;
+};
+
+/**
+ * Check whether the filter is currently in a conflict state
+ *
+ * @return {boolean} Filter is in conflict state
+ */
+FilterItem.prototype.isConflicted = function () {
+       return this.conflicted;
+};
+
+/**
+ * Check whether the filter is currently in an already included subset
+ *
+ * @return {boolean} Filter is in an already-included subset
+ */
+FilterItem.prototype.isIncluded = function () {
+       return this.included;
+};
+
+/**
+ * Check whether the filter is currently fully covered
+ *
+ * @return {boolean} Filter is in fully-covered state
+ */
+FilterItem.prototype.isFullyCovered = function () {
+       return this.fullyCovered;
+};
+
+/**
+ * Get all conflicts associated with this filter or its group
+ *
+ * Conflict object is set up by filter name keys and conflict
+ * definition. For example:
+ *
+ *  {
+ *      filterName: {
+ *          filter: filterName,
+ *          group: group1,
+ *          label: itemLabel,
+ *          item: itemModel
+ *      }
+ *      filterName2: {
+ *          filter: filterName2,
+ *          group: group2
+ *          label: itemLabel2,
+ *          item: itemModel2
+ *      }
+ *  }
+ *
+ * @return {Object} Filter conflicts
+ */
+FilterItem.prototype.getConflicts = function () {
+       return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
+};
+
+/**
+ * Get the conflicts associated with this filter
+ *
+ * @return {Object} Filter conflicts
+ */
+FilterItem.prototype.getOwnConflicts = function () {
+       return this.conflicts;
+};
+
+/**
+ * Set conflicts for this filter. See #getConflicts for the expected
+ * structure of the definition.
+ *
+ * @param {Object} conflicts Conflicts for this filter
+ */
+FilterItem.prototype.setConflicts = function ( conflicts ) {
+       this.conflicts = conflicts || {};
+};
+
+/**
+ * Set filter superset
+ *
+ * @param {string[]} superset Filter superset
+ */
+FilterItem.prototype.setSuperset = function ( superset ) {
+       this.superset = superset || [];
+};
+
+/**
+ * Set filter subset
+ *
+ * @param {string[]} subset Filter subset
+ */
+FilterItem.prototype.setSubset = function ( subset ) {
+       this.subset = subset || [];
+};
+
+/**
+ * Check whether a filter exists in the subset list for this filter
+ *
+ * @param {string} filterName Filter name
+ * @return {boolean} Filter name is in the subset list
+ */
+FilterItem.prototype.existsInSubset = function ( filterName ) {
+       return this.subset.indexOf( filterName ) > -1;
+};
+
+/**
+ * Check whether this item has a potential conflict with the given item
+ *
+ * This checks whether the given item is in the list of conflicts of
+ * the current item, but makes no judgment about whether the conflict
+ * is currently at play (either one of the items may not be selected)
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+ * @return {boolean} This item has a conflict with the given item
+ */
+FilterItem.prototype.existsInConflicts = function ( filterItem ) {
+       return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+};
+
+/**
+ * Set the state of this filter as being conflicted
+ * (This means any filters in its conflicts are selected)
+ *
+ * @param {boolean} [conflicted] Filter is in conflict state
+ * @fires update
+ */
+FilterItem.prototype.toggleConflicted = function ( conflicted ) {
+       conflicted = conflicted === undefined ? !this.conflicted : conflicted;
+
+       if ( this.conflicted !== conflicted ) {
+               this.conflicted = conflicted;
+               this.emit( 'update' );
+       }
+};
+
+/**
+ * Set the state of this filter as being already included
+ * (This means any filters in its superset are selected)
+ *
+ * @param {boolean} [included] Filter is included as part of a subset
+ * @fires update
+ */
+FilterItem.prototype.toggleIncluded = function ( included ) {
+       included = included === undefined ? !this.included : included;
+
+       if ( this.included !== included ) {
+               this.included = included;
+               this.emit( 'update' );
+       }
+};
+
+/**
+ * Toggle the fully covered state of the item
+ *
+ * @param {boolean} [isFullyCovered] Filter is fully covered
+ * @fires update
+ */
+FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
+       isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
+
+       if ( this.fullyCovered !== isFullyCovered ) {
+               this.fullyCovered = isFullyCovered;
+               this.emit( 'update' );
+       }
+};
+
+/**
+ * Toggle the visibility of this item
+ *
+ * @param {boolean} [isVisible] Item is visible
+ */
+FilterItem.prototype.toggleVisible = function ( isVisible ) {
+       isVisible = isVisible === undefined ? !this.visible : !!isVisible;
+
+       if ( this.visible !== isVisible ) {
+               this.visible = isVisible;
+               this.emit( 'update' );
+       }
+};
+
+/**
+ * Check whether the item is visible
+ *
+ * @return {boolean} Item is visible
+ */
+FilterItem.prototype.isVisible = function () {
+       return this.visible;
+};
+
+module.exports = FilterItem;
index d1b9f7a..07c484b 100644 (file)
-( function () {
-       var FilterGroup = require( './FilterGroup.js' ),
-               FilterItem = require( './FilterItem.js' ),
-               FiltersViewModel;
-
-       /**
-        * View model for the filters selection and display
-        *
-        * @class mw.rcfilters.dm.FiltersViewModel
-        * @mixins OO.EventEmitter
-        * @mixins OO.EmitterList
-        *
-        * @constructor
-        */
-       FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-               OO.EmitterList.call( this );
-
-               this.groups = {};
-               this.defaultParams = {};
-               this.highlightEnabled = false;
-               this.parameterMap = {};
-               this.emptyParameterState = null;
-
-               this.views = {};
-               this.currentView = 'default';
-               this.searchQuery = null;
-
-               // Events
-               this.aggregate( { update: 'filterItemUpdate' } );
-               this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
-       };
-
-       /* Initialization */
-       OO.initClass( FiltersViewModel );
-       OO.mixinClass( FiltersViewModel, OO.EventEmitter );
-       OO.mixinClass( FiltersViewModel, OO.EmitterList );
-
-       /* Events */
-
-       /**
-        * @event initialize
-        *
-        * Filter list is initialized
-        */
-
-       /**
-        * @event update
-        *
-        * Model has been updated
-        */
-
-       /**
-        * @event itemUpdate
-        * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
-        *
-        * Filter item has changed
-        */
-
-       /**
-        * @event highlightChange
-        * @param {boolean} Highlight feature is enabled
-        *
-        * Highlight feature has been toggled enabled or disabled
-        */
-
-       /* Methods */
-
-       /**
-        * Re-assess the states of filter items based on the interactions between them
-        *
-        * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
-        *  method will go over the state of all items
-        */
-       FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
-               var allSelected,
-                       model = this,
-                       iterationItems = item !== undefined ? [ item ] : this.getItems();
-
-               iterationItems.forEach( function ( checkedItem ) {
-                       var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
-                               groupModel = checkedItem.getGroupModel();
-
-                       // Check for subsets (included filters) plus the item itself:
-                       allCheckedItems.forEach( function ( filterItemName ) {
-                               var itemInSubset = model.getItemByName( filterItemName );
-
-                               itemInSubset.toggleIncluded(
-                                       // If any of itemInSubset's supersets are selected, this item
-                                       // is included
-                                       itemInSubset.getSuperset().some( function ( supersetName ) {
-                                               return ( model.getItemByName( supersetName ).isSelected() );
+var FilterGroup = require( './FilterGroup.js' ),
+       FilterItem = require( './FilterItem.js' ),
+       FiltersViewModel;
+
+/**
+ * View model for the filters selection and display
+ *
+ * @class mw.rcfilters.dm.FiltersViewModel
+ * @mixins OO.EventEmitter
+ * @mixins OO.EmitterList
+ *
+ * @constructor
+ */
+FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
+       // Mixin constructor
+       OO.EventEmitter.call( this );
+       OO.EmitterList.call( this );
+
+       this.groups = {};
+       this.defaultParams = {};
+       this.highlightEnabled = false;
+       this.parameterMap = {};
+       this.emptyParameterState = null;
+
+       this.views = {};
+       this.currentView = 'default';
+       this.searchQuery = null;
+
+       // Events
+       this.aggregate( { update: 'filterItemUpdate' } );
+       this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
+};
+
+/* Initialization */
+OO.initClass( FiltersViewModel );
+OO.mixinClass( FiltersViewModel, OO.EventEmitter );
+OO.mixinClass( FiltersViewModel, OO.EmitterList );
+
+/* Events */
+
+/**
+ * @event initialize
+ *
+ * Filter list is initialized
+ */
+
+/**
+ * @event update
+ *
+ * Model has been updated
+ */
+
+/**
+ * @event itemUpdate
+ * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
+ *
+ * Filter item has changed
+ */
+
+/**
+ * @event highlightChange
+ * @param {boolean} Highlight feature is enabled
+ *
+ * Highlight feature has been toggled enabled or disabled
+ */
+
+/* Methods */
+
+/**
+ * Re-assess the states of filter items based on the interactions between them
+ *
+ * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
+ *  method will go over the state of all items
+ */
+FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
+       var allSelected,
+               model = this,
+               iterationItems = item !== undefined ? [ item ] : this.getItems();
+
+       iterationItems.forEach( function ( checkedItem ) {
+               var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
+                       groupModel = checkedItem.getGroupModel();
+
+               // Check for subsets (included filters) plus the item itself:
+               allCheckedItems.forEach( function ( filterItemName ) {
+                       var itemInSubset = model.getItemByName( filterItemName );
+
+                       itemInSubset.toggleIncluded(
+                               // If any of itemInSubset's supersets are selected, this item
+                               // is included
+                               itemInSubset.getSuperset().some( function ( supersetName ) {
+                                       return ( model.getItemByName( supersetName ).isSelected() );
+                               } )
+                       );
+               } );
+
+               // Update coverage for the changed group
+               if ( groupModel.isFullCoverage() ) {
+                       allSelected = groupModel.areAllSelected();
+                       groupModel.getItems().forEach( function ( filterItem ) {
+                               filterItem.toggleFullyCovered( allSelected );
+                       } );
+               }
+       } );
+
+       // Check for conflicts
+       // In this case, we must go over all items, since
+       // conflicts are bidirectional and depend not only on
+       // individual items, but also on the selected states of
+       // the groups they're in.
+       this.getItems().forEach( function ( filterItem ) {
+               var inConflict = false,
+                       filterItemGroup = filterItem.getGroupModel();
+
+               // For each item, see if that item is still conflicting
+               // eslint-disable-next-line no-jquery/no-each-util
+               $.each( model.groups, function ( groupName, groupModel ) {
+                       if ( filterItem.getGroupName() === groupName ) {
+                               // Check inside the group
+                               inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
+                       } else {
+                               // According to the spec, if two items conflict from two different
+                               // groups, the conflict only lasts if the groups **only have selected
+                               // items that are conflicting**. If a group has selected items that
+                               // are conflicting and non-conflicting, the scope of the result has
+                               // expanded enough to completely remove the conflict.
+
+                               // For example, see two groups with conflicts:
+                               // userExpLevel: [
+                               //   {
+                               //     name: 'experienced',
+                               //     conflicts: [ 'unregistered' ]
+                               //   }
+                               // ],
+                               // registration: [
+                               //   {
+                               //     name: 'registered',
+                               //   },
+                               //   {
+                               //     name: 'unregistered',
+                               //   }
+                               // ]
+                               // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
+                               // because, inherently, 'experienced' filter only includes registered users, and so
+                               // both filters are in conflict with one another.
+                               // However, the minute we select 'registered', the scope of our results
+                               // has expanded to no longer have a conflict with 'experienced' filter, and
+                               // so the conflict is removed.
+
+                               // In our case, we need to check if the entire group conflicts with
+                               // the entire item's group, so we follow the above spec
+                               inConflict = (
+                                       // The foreign group is in conflict with this item
+                                       groupModel.areAllSelectedInConflictWith( filterItem ) &&
+                                       // Every selected member of the item's own group is also
+                                       // in conflict with the other group
+                                       filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
+                                               return groupModel.areAllSelectedInConflictWith( otherGroupItem );
                                        } )
                                );
-                       } );
-
-                       // Update coverage for the changed group
-                       if ( groupModel.isFullCoverage() ) {
-                               allSelected = groupModel.areAllSelected();
-                               groupModel.getItems().forEach( function ( filterItem ) {
-                                       filterItem.toggleFullyCovered( allSelected );
-                               } );
                        }
+
+                       // If we're in conflict, this will return 'false' which
+                       // will break the loop. Otherwise, we're not in conflict
+                       // and the loop continues
+                       return !inConflict;
                } );
 
-               // Check for conflicts
-               // In this case, we must go over all items, since
-               // conflicts are bidirectional and depend not only on
-               // individual items, but also on the selected states of
-               // the groups they're in.
-               this.getItems().forEach( function ( filterItem ) {
-                       var inConflict = false,
-                               filterItemGroup = filterItem.getGroupModel();
+               // Toggle the item state
+               filterItem.toggleConflicted( inConflict );
+       } );
+};
+
+/**
+ * Get whether the model has any conflict in its items
+ *
+ * @return {boolean} There is a conflict
+ */
+FiltersViewModel.prototype.hasConflict = function () {
+       return this.getItems().some( function ( filterItem ) {
+               return filterItem.isSelected() && filterItem.isConflicted();
+       } );
+};
+
+/**
+ * Get the first item with a current conflict
+ *
+ * @return {mw.rcfilters.dm.FilterItem|undefined} Conflicted item or undefined when not found
+ */
+FiltersViewModel.prototype.getFirstConflictedItem = function () {
+       var i, filterItem, items = this.getItems();
+       for ( i = 0; i < items.length; i++ ) {
+               filterItem = items[ i ];
+               if ( filterItem.isSelected() && filterItem.isConflicted() ) {
+                       return filterItem;
+               }
+       }
+};
+
+/**
+ * Set filters and preserve a group relationship based on
+ * the definition given by an object
+ *
+ * @param {Array} filterGroups Filters definition
+ * @param {Object} [views] Extra views definition
+ *  Expected in the following format:
+ *  {
+ *     namespaces: {
+ *       label: 'namespaces', // Message key
+ *       trigger: ':',
+ *       groups: [
+ *         {
+ *            // Group info
+ *            name: 'namespaces' // Parameter name
+ *            title: 'namespaces' // Message key
+ *            type: 'string_options',
+ *            separator: ';',
+ *            labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+ *            fullCoverage: true
+ *            items: []
+ *         }
+ *       ]
+ *     }
+ *  }
+ */
+FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
+       var filterConflictResult, groupConflictResult,
+               allViews = {},
+               model = this,
+               items = [],
+               groupConflictMap = {},
+               filterConflictMap = {},
+               /*!
+                * Expand a conflict definition from group name to
+                * the list of all included filters in that group.
+                * We do this so that the direct relationship in the
+                * models are consistently item->items rather than
+                * mixing item->group with item->item.
+                *
+                * @param {Object} obj Conflict definition
+                * @return {Object} Expanded conflict definition
+                */
+               expandConflictDefinitions = function ( obj ) {
+                       var result = {};
 
-                       // For each item, see if that item is still conflicting
                        // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( model.groups, function ( groupName, groupModel ) {
-                               if ( filterItem.getGroupName() === groupName ) {
-                                       // Check inside the group
-                                       inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
-                               } else {
-                                       // According to the spec, if two items conflict from two different
-                                       // groups, the conflict only lasts if the groups **only have selected
-                                       // items that are conflicting**. If a group has selected items that
-                                       // are conflicting and non-conflicting, the scope of the result has
-                                       // expanded enough to completely remove the conflict.
-
-                                       // For example, see two groups with conflicts:
-                                       // userExpLevel: [
-                                       //   {
-                                       //     name: 'experienced',
-                                       //     conflicts: [ 'unregistered' ]
-                                       //   }
-                                       // ],
-                                       // registration: [
-                                       //   {
-                                       //     name: 'registered',
-                                       //   },
-                                       //   {
-                                       //     name: 'unregistered',
-                                       //   }
-                                       // ]
-                                       // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
-                                       // because, inherently, 'experienced' filter only includes registered users, and so
-                                       // both filters are in conflict with one another.
-                                       // However, the minute we select 'registered', the scope of our results
-                                       // has expanded to no longer have a conflict with 'experienced' filter, and
-                                       // so the conflict is removed.
-
-                                       // In our case, we need to check if the entire group conflicts with
-                                       // the entire item's group, so we follow the above spec
-                                       inConflict = (
-                                               // The foreign group is in conflict with this item
-                                               groupModel.areAllSelectedInConflictWith( filterItem ) &&
-                                               // Every selected member of the item's own group is also
-                                               // in conflict with the other group
-                                               filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
-                                                       return groupModel.areAllSelectedInConflictWith( otherGroupItem );
-                                               } )
-                                       );
-                               }
-
-                               // If we're in conflict, this will return 'false' which
-                               // will break the loop. Otherwise, we're not in conflict
-                               // and the loop continues
-                               return !inConflict;
-                       } );
-
-                       // Toggle the item state
-                       filterItem.toggleConflicted( inConflict );
-               } );
-       };
-
-       /**
-        * Get whether the model has any conflict in its items
-        *
-        * @return {boolean} There is a conflict
-        */
-       FiltersViewModel.prototype.hasConflict = function () {
-               return this.getItems().some( function ( filterItem ) {
-                       return filterItem.isSelected() && filterItem.isConflicted();
-               } );
-       };
-
-       /**
-        * Get the first item with a current conflict
-        *
-        * @return {mw.rcfilters.dm.FilterItem|undefined} Conflicted item or undefined when not found
-        */
-       FiltersViewModel.prototype.getFirstConflictedItem = function () {
-               var i, filterItem, items = this.getItems();
-               for ( i = 0; i < items.length; i++ ) {
-                       filterItem = items[ i ];
-                       if ( filterItem.isSelected() && filterItem.isConflicted() ) {
-                               return filterItem;
-                       }
-               }
-       };
-
-       /**
-        * Set filters and preserve a group relationship based on
-        * the definition given by an object
-        *
-        * @param {Array} filterGroups Filters definition
-        * @param {Object} [views] Extra views definition
-        *  Expected in the following format:
-        *  {
-        *     namespaces: {
-        *       label: 'namespaces', // Message key
-        *       trigger: ':',
-        *       groups: [
-        *         {
-        *            // Group info
-        *            name: 'namespaces' // Parameter name
-        *            title: 'namespaces' // Message key
-        *            type: 'string_options',
-        *            separator: ';',
-        *            labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
-        *            fullCoverage: true
-        *            items: []
-        *         }
-        *       ]
-        *     }
-        *  }
-        */
-       FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
-               var filterConflictResult, groupConflictResult,
-                       allViews = {},
-                       model = this,
-                       items = [],
-                       groupConflictMap = {},
-                       filterConflictMap = {},
-                       /*!
-                        * Expand a conflict definition from group name to
-                        * the list of all included filters in that group.
-                        * We do this so that the direct relationship in the
-                        * models are consistently item->items rather than
-                        * mixing item->group with item->item.
-                        *
-                        * @param {Object} obj Conflict definition
-                        * @return {Object} Expanded conflict definition
-                        */
-                       expandConflictDefinitions = function ( obj ) {
-                               var result = {};
-
-                               // eslint-disable-next-line no-jquery/no-each-util
-                               $.each( obj, function ( key, conflicts ) {
-                                       var filterName,
-                                               adjustedConflicts = {};
-
-                                       conflicts.forEach( function ( conflict ) {
-                                               var filter;
-
-                                               if ( conflict.filter ) {
-                                                       filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
-                                                       filter = model.getItemByName( filterName );
-
-                                                       // Rename
-                                                       adjustedConflicts[ filterName ] = $.extend(
+                       $.each( obj, function ( key, conflicts ) {
+                               var filterName,
+                                       adjustedConflicts = {};
+
+                               conflicts.forEach( function ( conflict ) {
+                                       var filter;
+
+                                       if ( conflict.filter ) {
+                                               filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
+                                               filter = model.getItemByName( filterName );
+
+                                               // Rename
+                                               adjustedConflicts[ filterName ] = $.extend(
+                                                       {},
+                                                       conflict,
+                                                       {
+                                                               filter: filterName,
+                                                               item: filter
+                                                       }
+                                               );
+                                       } else {
+                                               // This conflict is for an entire group. Split it up to
+                                               // represent each filter
+
+                                               // Get the relevant group items
+                                               model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
+                                                       // Rebuild the conflict
+                                                       adjustedConflicts[ groupItem.getName() ] = $.extend(
                                                                {},
                                                                conflict,
                                                                {
-                                                                       filter: filterName,
-                                                                       item: filter
+                                                                       filter: groupItem.getName(),
+                                                                       item: groupItem
                                                                }
                                                        );
-                                               } else {
-                                                       // This conflict is for an entire group. Split it up to
-                                                       // represent each filter
-
-                                                       // Get the relevant group items
-                                                       model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
-                                                               // Rebuild the conflict
-                                                               adjustedConflicts[ groupItem.getName() ] = $.extend(
-                                                                       {},
-                                                                       conflict,
-                                                                       {
-                                                                               filter: groupItem.getName(),
-                                                                               item: groupItem
-                                                                       }
-                                                               );
-                                                       } );
-                                               }
-                                       } );
-
-                                       result[ key ] = adjustedConflicts;
-                               } );
-
-                               return result;
-                       };
-
-               // Reset
-               this.clearItems();
-               this.groups = {};
-               this.views = {};
-
-               // Clone
-               filterGroups = OO.copy( filterGroups );
-
-               // Normalize definition from the server
-               filterGroups.forEach( function ( data ) {
-                       var i;
-                       // What's this information needs to be normalized
-                       data.whatsThis = {
-                               body: data.whatsThisBody,
-                               header: data.whatsThisHeader,
-                               linkText: data.whatsThisLinkText,
-                               url: data.whatsThisUrl
-                       };
-
-                       // Title is a msg-key
-                       data.title = data.title ? mw.msg( data.title ) : data.name;
-
-                       // Filters are given to us with msg-keys, we need
-                       // to translate those before we hand them off
-                       for ( i = 0; i < data.filters.length; i++ ) {
-                               data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
-                               data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
-                       }
-               } );
-
-               // Collect views
-               allViews = $.extend( true, {
-                       default: {
-                               title: mw.msg( 'rcfilters-filterlist-title' ),
-                               groups: filterGroups
-                       }
-               }, views );
-
-               // Go over all views
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( allViews, function ( viewName, viewData ) {
-                       // Define the view
-                       model.views[ viewName ] = {
-                               name: viewData.name,
-                               title: viewData.title,
-                               trigger: viewData.trigger
-                       };
-
-                       // Go over groups
-                       viewData.groups.forEach( function ( groupData ) {
-                               var group = groupData.name;
-
-                               if ( !model.groups[ group ] ) {
-                                       model.groups[ group ] = new FilterGroup(
-                                               group,
-                                               $.extend( true, {}, groupData, { view: viewName } )
-                                       );
-                               }
-
-                               model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
-                               items = items.concat( model.groups[ group ].getItems() );
-
-                               // Prepare conflicts
-                               if ( groupData.conflicts ) {
-                                       // Group conflicts
-                                       groupConflictMap[ group ] = groupData.conflicts;
-                               }
-
-                               groupData.filters.forEach( function ( itemData ) {
-                                       var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
-                                       // Filter conflicts
-                                       if ( itemData.conflicts ) {
-                                               filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
+                                               } );
                                        }
                                } );
-                       } );
-               } );
-
-               // Add item references to the model, for lookup
-               this.addItems( items );
 
-               // Expand conflicts
-               groupConflictResult = expandConflictDefinitions( groupConflictMap );
-               filterConflictResult = expandConflictDefinitions( filterConflictMap );
-
-               // Set conflicts for groups
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( groupConflictResult, function ( group, conflicts ) {
-                       model.groups[ group ].setConflicts( conflicts );
-               } );
+                               result[ key ] = adjustedConflicts;
+                       } );
 
-               // Set conflicts for items
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( filterConflictResult, function ( filterName, conflicts ) {
-                       var filterItem = model.getItemByName( filterName );
-                       // set conflicts for items in the group
-                       filterItem.setConflicts( conflicts );
-               } );
+                       return result;
+               };
+
+       // Reset
+       this.clearItems();
+       this.groups = {};
+       this.views = {};
+
+       // Clone
+       filterGroups = OO.copy( filterGroups );
+
+       // Normalize definition from the server
+       filterGroups.forEach( function ( data ) {
+               var i;
+               // What's this information needs to be normalized
+               data.whatsThis = {
+                       body: data.whatsThisBody,
+                       header: data.whatsThisHeader,
+                       linkText: data.whatsThisLinkText,
+                       url: data.whatsThisUrl
+               };
+
+               // Title is a msg-key
+               data.title = data.title ? mw.msg( data.title ) : data.name;
+
+               // Filters are given to us with msg-keys, we need
+               // to translate those before we hand them off
+               for ( i = 0; i < data.filters.length; i++ ) {
+                       data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
+                       data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
+               }
+       } );
 
-               // Create a map between known parameters and their models
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( this.groups, function ( group, groupModel ) {
-                       if (
-                               groupModel.getType() === 'send_unselected_if_any' ||
-                               groupModel.getType() === 'boolean' ||
-                               groupModel.getType() === 'any_value'
-                       ) {
-                               // Individual filters
-                               groupModel.getItems().forEach( function ( filterItem ) {
-                                       model.parameterMap[ filterItem.getParamName() ] = filterItem;
-                               } );
-                       } else if (
-                               groupModel.getType() === 'string_options' ||
-                               groupModel.getType() === 'single_option'
-                       ) {
-                               // Group
-                               model.parameterMap[ groupModel.getName() ] = groupModel;
+       // Collect views
+       allViews = $.extend( true, {
+               default: {
+                       title: mw.msg( 'rcfilters-filterlist-title' ),
+                       groups: filterGroups
+               }
+       }, views );
+
+       // Go over all views
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( allViews, function ( viewName, viewData ) {
+               // Define the view
+               model.views[ viewName ] = {
+                       name: viewData.name,
+                       title: viewData.title,
+                       trigger: viewData.trigger
+               };
+
+               // Go over groups
+               viewData.groups.forEach( function ( groupData ) {
+                       var group = groupData.name;
+
+                       if ( !model.groups[ group ] ) {
+                               model.groups[ group ] = new FilterGroup(
+                                       group,
+                                       $.extend( true, {}, groupData, { view: viewName } )
+                               );
                        }
-               } );
-
-               this.setSearch( '' );
-
-               this.updateHighlightedState();
 
-               // Finish initialization
-               this.emit( 'initialize' );
-       };
+                       model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
+                       items = items.concat( model.groups[ group ].getItems() );
 
-       /**
-        * Update filter view model state based on a parameter object
-        *
-        * @param {Object} params Parameters object
-        */
-       FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
-               var filtersValue;
-               // For arbitrary numeric single_option values make sure the values
-               // are normalized to fit within the limits
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
-                       params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
-               } );
-
-               // Update filter values
-               filtersValue = this.getFiltersFromParameters( params );
-               Object.keys( filtersValue ).forEach( function ( filterName ) {
-                       this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
-               }.bind( this ) );
-
-               // Update highlight state
-               this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
-                       var color = params[ filterItem.getName() + '_color' ];
-                       if ( color ) {
-                               filterItem.setHighlightColor( color );
-                       } else {
-                               filterItem.clearHighlightColor();
+                       // Prepare conflicts
+                       if ( groupData.conflicts ) {
+                               // Group conflicts
+                               groupConflictMap[ group ] = groupData.conflicts;
                        }
-               } );
-               this.updateHighlightedState();
-
-               // Check all filter interactions
-               this.reassessFilterInteractions();
-       };
-
-       /**
-        * Get a representation of an empty (falsey) parameter state
-        *
-        * @return {Object} Empty parameter state
-        */
-       FiltersViewModel.prototype.getEmptyParameterState = function () {
-               if ( !this.emptyParameterState ) {
-                       this.emptyParameterState = $.extend(
-                               true,
-                               {},
-                               this.getParametersFromFilters( {} ),
-                               this.getEmptyHighlightParameters()
-                       );
-               }
-               return this.emptyParameterState;
-       };
-
-       /**
-        * Get a representation of only the non-falsey parameters
-        *
-        * @param {Object} [parameters] A given parameter state to minimize. If not given the current
-        *  state of the system will be used.
-        * @return {Object} Empty parameter state
-        */
-       FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
-               var result = {};
-
-               parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
-
-               // Params
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( this.getEmptyParameterState(), function ( param, value ) {
-                       if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
-                               result[ param ] = parameters[ param ];
-                       }
-               } );
 
-               // Highlights
-               Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
-                       if ( parameters[ param ] ) {
-                               // If a highlight parameter is not undefined and not null
-                               // add it to the result
-                               result[ param ] = parameters[ param ];
-                       }
+                       groupData.filters.forEach( function ( itemData ) {
+                               var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
+                               // Filter conflicts
+                               if ( itemData.conflicts ) {
+                                       filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
+                               }
+                       } );
                } );
-
-               return result;
-       };
-
-       /**
-        * Get a representation of the full parameter list, including all base values
-        *
-        * @return {Object} Full parameter representation
-        */
-       FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
-               return $.extend(
+       } );
+
+       // Add item references to the model, for lookup
+       this.addItems( items );
+
+       // Expand conflicts
+       groupConflictResult = expandConflictDefinitions( groupConflictMap );
+       filterConflictResult = expandConflictDefinitions( filterConflictMap );
+
+       // Set conflicts for groups
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( groupConflictResult, function ( group, conflicts ) {
+               model.groups[ group ].setConflicts( conflicts );
+       } );
+
+       // Set conflicts for items
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( filterConflictResult, function ( filterName, conflicts ) {
+               var filterItem = model.getItemByName( filterName );
+               // set conflicts for items in the group
+               filterItem.setConflicts( conflicts );
+       } );
+
+       // Create a map between known parameters and their models
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( this.groups, function ( group, groupModel ) {
+               if (
+                       groupModel.getType() === 'send_unselected_if_any' ||
+                       groupModel.getType() === 'boolean' ||
+                       groupModel.getType() === 'any_value'
+               ) {
+                       // Individual filters
+                       groupModel.getItems().forEach( function ( filterItem ) {
+                               model.parameterMap[ filterItem.getParamName() ] = filterItem;
+                       } );
+               } else if (
+                       groupModel.getType() === 'string_options' ||
+                       groupModel.getType() === 'single_option'
+               ) {
+                       // Group
+                       model.parameterMap[ groupModel.getName() ] = groupModel;
+               }
+       } );
+
+       this.setSearch( '' );
+
+       this.updateHighlightedState();
+
+       // Finish initialization
+       this.emit( 'initialize' );
+};
+
+/**
+ * Update filter view model state based on a parameter object
+ *
+ * @param {Object} params Parameters object
+ */
+FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
+       var filtersValue;
+       // For arbitrary numeric single_option values make sure the values
+       // are normalized to fit within the limits
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+               params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
+       } );
+
+       // Update filter values
+       filtersValue = this.getFiltersFromParameters( params );
+       Object.keys( filtersValue ).forEach( function ( filterName ) {
+               this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
+       }.bind( this ) );
+
+       // Update highlight state
+       this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
+               var color = params[ filterItem.getName() + '_color' ];
+               if ( color ) {
+                       filterItem.setHighlightColor( color );
+               } else {
+                       filterItem.clearHighlightColor();
+               }
+       } );
+       this.updateHighlightedState();
+
+       // Check all filter interactions
+       this.reassessFilterInteractions();
+};
+
+/**
+ * Get a representation of an empty (falsey) parameter state
+ *
+ * @return {Object} Empty parameter state
+ */
+FiltersViewModel.prototype.getEmptyParameterState = function () {
+       if ( !this.emptyParameterState ) {
+               this.emptyParameterState = $.extend(
                        true,
                        {},
-                       this.getEmptyParameterState(),
-                       this.getCurrentParameterState()
+                       this.getParametersFromFilters( {} ),
+                       this.getEmptyHighlightParameters()
                );
-       };
-
-       /**
-        * Get a parameter representation of the current state of the model
-        *
-        * @param {boolean} [removeStickyParams] Remove sticky filters from final result
-        * @return {Object} Parameter representation of the current state of the model
-        */
-       FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
-               var state = this.getMinimizedParamRepresentation( $.extend(
-                       true,
-                       {},
-                       this.getParametersFromFilters( this.getSelectedState() ),
-                       this.getHighlightParameters()
-               ) );
-
-               if ( removeStickyParams ) {
-                       state = this.removeStickyParams( state );
+       }
+       return this.emptyParameterState;
+};
+
+/**
+ * Get a representation of only the non-falsey parameters
+ *
+ * @param {Object} [parameters] A given parameter state to minimize. If not given the current
+ *  state of the system will be used.
+ * @return {Object} Empty parameter state
+ */
+FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
+       var result = {};
+
+       parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
+
+       // Params
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( this.getEmptyParameterState(), function ( param, value ) {
+               if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
+                       result[ param ] = parameters[ param ];
                }
-
-               return state;
-       };
-
-       /**
-        * Delete sticky parameters from given object.
-        *
-        * @param {Object} paramState Parameter state
-        * @return {Object} Parameter state without sticky parameters
-        */
-       FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
-               this.getStickyParams().forEach( function ( paramName ) {
-                       delete paramState[ paramName ];
-               } );
-
-               return paramState;
-       };
-
-       /**
-        * Turn the highlight feature on or off
-        */
-       FiltersViewModel.prototype.updateHighlightedState = function () {
-               this.toggleHighlight( this.getHighlightedItems().length > 0 );
-       };
-
-       /**
-        * Get the object that defines groups by their name.
-        *
-        * @return {Object} Filter groups
-        */
-       FiltersViewModel.prototype.getFilterGroups = function () {
-               return this.groups;
-       };
-
-       /**
-        * Get the object that defines groups that match a certain view by their name.
-        *
-        * @param {string} [view] Requested view. If not given, uses current view
-        * @return {Object} Filter groups matching a display group
-        */
-       FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
-               var result = {};
-
-               view = view || this.getCurrentView();
-
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( this.groups, function ( groupName, groupModel ) {
-                       if ( groupModel.getView() === view ) {
-                               result[ groupName ] = groupModel;
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get an array of filters matching the given display group.
-        *
-        * @param {string} [view] Requested view. If not given, uses current view
-        * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
-        */
-       FiltersViewModel.prototype.getFiltersByView = function ( view ) {
-               var groups,
-                       result = [];
-
-               view = view || this.getCurrentView();
-
-               groups = this.getFilterGroupsByView( view );
-
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( groups, function ( groupName, groupModel ) {
-                       result = result.concat( groupModel.getItems() );
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get the trigger for the requested view.
-        *
-        * @param {string} view View name
-        * @return {string} View trigger, if exists
-        */
-       FiltersViewModel.prototype.getViewTrigger = function ( view ) {
-               return ( this.views[ view ] && this.views[ view ].trigger ) || '';
-       };
-
-       /**
-        * Get the value of a specific parameter
-        *
-        * @param {string} name Parameter name
-        * @return {number|string} Parameter value
-        */
-       FiltersViewModel.prototype.getParamValue = function ( name ) {
-               return this.parameters[ name ];
-       };
-
-       /**
-        * Get the current selected state of the filters
-        *
-        * @param {boolean} [onlySelected] return an object containing only the filters with a value
-        * @return {Object} Filters selected state
-        */
-       FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
-               var i,
-                       items = this.getItems(),
-                       result = {};
-
-               for ( i = 0; i < items.length; i++ ) {
-                       if ( !onlySelected || items[ i ].getValue() ) {
-                               result[ items[ i ].getName() ] = items[ i ].getValue();
-                       }
+       } );
+
+       // Highlights
+       Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
+               if ( parameters[ param ] ) {
+                       // If a highlight parameter is not undefined and not null
+                       // add it to the result
+                       result[ param ] = parameters[ param ];
                }
-
-               return result;
-       };
-
-       /**
-        * Get the current full state of the filters
-        *
-        * @return {Object} Filters full state
-        */
-       FiltersViewModel.prototype.getFullState = function () {
-               var i,
-                       items = this.getItems(),
-                       result = {};
-
-               for ( i = 0; i < items.length; i++ ) {
-                       result[ items[ i ].getName() ] = {
-                               selected: items[ i ].isSelected(),
-                               conflicted: items[ i ].isConflicted(),
-                               included: items[ i ].isIncluded()
-                       };
+       } );
+
+       return result;
+};
+
+/**
+ * Get a representation of the full parameter list, including all base values
+ *
+ * @return {Object} Full parameter representation
+ */
+FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
+       return $.extend(
+               true,
+               {},
+               this.getEmptyParameterState(),
+               this.getCurrentParameterState()
+       );
+};
+
+/**
+ * Get a parameter representation of the current state of the model
+ *
+ * @param {boolean} [removeStickyParams] Remove sticky filters from final result
+ * @return {Object} Parameter representation of the current state of the model
+ */
+FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
+       var state = this.getMinimizedParamRepresentation( $.extend(
+               true,
+               {},
+               this.getParametersFromFilters( this.getSelectedState() ),
+               this.getHighlightParameters()
+       ) );
+
+       if ( removeStickyParams ) {
+               state = this.removeStickyParams( state );
+       }
+
+       return state;
+};
+
+/**
+ * Delete sticky parameters from given object.
+ *
+ * @param {Object} paramState Parameter state
+ * @return {Object} Parameter state without sticky parameters
+ */
+FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
+       this.getStickyParams().forEach( function ( paramName ) {
+               delete paramState[ paramName ];
+       } );
+
+       return paramState;
+};
+
+/**
+ * Turn the highlight feature on or off
+ */
+FiltersViewModel.prototype.updateHighlightedState = function () {
+       this.toggleHighlight( this.getHighlightedItems().length > 0 );
+};
+
+/**
+ * Get the object that defines groups by their name.
+ *
+ * @return {Object} Filter groups
+ */
+FiltersViewModel.prototype.getFilterGroups = function () {
+       return this.groups;
+};
+
+/**
+ * Get the object that defines groups that match a certain view by their name.
+ *
+ * @param {string} [view] Requested view. If not given, uses current view
+ * @return {Object} Filter groups matching a display group
+ */
+FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
+       var result = {};
+
+       view = view || this.getCurrentView();
+
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( this.groups, function ( groupName, groupModel ) {
+               if ( groupModel.getView() === view ) {
+                       result[ groupName ] = groupModel;
                }
-
-               return result;
-       };
-
-       /**
-        * Get an object representing default parameters state
-        *
-        * @return {Object} Default parameter values
-        */
-       FiltersViewModel.prototype.getDefaultParams = function () {
-               var result = {};
-
-               // Get default filter state
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( this.groups, function ( name, model ) {
-                       if ( !model.isSticky() ) {
-                               $.extend( true, result, model.getDefaultParams() );
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get a parameter representation of all sticky parameters
-        *
-        * @return {Object} Sticky parameter values
-        */
-       FiltersViewModel.prototype.getStickyParams = function () {
-               var result = [];
-
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( this.groups, function ( name, model ) {
-                       if ( model.isSticky() ) {
-                               if ( model.isPerGroupRequestParameter() ) {
-                                       result.push( name );
-                               } else {
-                                       // Each filter is its own param
-                                       result = result.concat( model.getItems().map( function ( filterItem ) {
-                                               return filterItem.getParamName();
-                                       } ) );
-                               }
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get a parameter representation of all sticky parameters
-        *
-        * @return {Object} Sticky parameter values
-        */
-       FiltersViewModel.prototype.getStickyParamsValues = function () {
-               var result = {};
-
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( this.groups, function ( name, model ) {
-                       if ( model.isSticky() ) {
-                               $.extend( true, result, model.getParamRepresentation() );
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Analyze the groups and their filters and output an object representing
-        * the state of the parameters they represent.
-        *
-        * @param {Object} [filterDefinition] An object defining the filter values,
-        *  keyed by filter names.
-        * @return {Object} Parameter state object
-        */
-       FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
-               var groupItemDefinition,
-                       result = {},
-                       groupItems = this.getFilterGroups();
-
-               if ( filterDefinition ) {
-                       groupItemDefinition = {};
-                       // Filter definition is "flat", but in effect
-                       // each group needs to tell us its result based
-                       // on the values in it. We need to split this list
-                       // back into groupings so we can "feed" it to the
-                       // loop below, and we need to expand it so it includes
-                       // all filters (set to false)
-                       this.getItems().forEach( function ( filterItem ) {
-                               groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
-                               groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
-                       } );
+       } );
+
+       return result;
+};
+
+/**
+ * Get an array of filters matching the given display group.
+ *
+ * @param {string} [view] Requested view. If not given, uses current view
+ * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
+ */
+FiltersViewModel.prototype.getFiltersByView = function ( view ) {
+       var groups,
+               result = [];
+
+       view = view || this.getCurrentView();
+
+       groups = this.getFilterGroupsByView( view );
+
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( groups, function ( groupName, groupModel ) {
+               result = result.concat( groupModel.getItems() );
+       } );
+
+       return result;
+};
+
+/**
+ * Get the trigger for the requested view.
+ *
+ * @param {string} view View name
+ * @return {string} View trigger, if exists
+ */
+FiltersViewModel.prototype.getViewTrigger = function ( view ) {
+       return ( this.views[ view ] && this.views[ view ].trigger ) || '';
+};
+
+/**
+ * Get the value of a specific parameter
+ *
+ * @param {string} name Parameter name
+ * @return {number|string} Parameter value
+ */
+FiltersViewModel.prototype.getParamValue = function ( name ) {
+       return this.parameters[ name ];
+};
+
+/**
+ * Get the current selected state of the filters
+ *
+ * @param {boolean} [onlySelected] return an object containing only the filters with a value
+ * @return {Object} Filters selected state
+ */
+FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
+       var i,
+               items = this.getItems(),
+               result = {};
+
+       for ( i = 0; i < items.length; i++ ) {
+               if ( !onlySelected || items[ i ].getValue() ) {
+                       result[ items[ i ].getName() ] = items[ i ].getValue();
                }
-
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( groupItems, function ( group, model ) {
-                       $.extend(
-                               result,
-                               model.getParamRepresentation(
-                                       groupItemDefinition ?
-                                               groupItemDefinition[ group ] : null
-                               )
-                       );
-               } );
-
-               return result;
-       };
-
-       /**
-        * This is the opposite of the #getParametersFromFilters method; this goes over
-        * the given parameters and translates into a selected/unselected value in the filters.
-        *
-        * @param {Object} params Parameters query object
-        * @return {Object} Filter state object
-        */
-       FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
-               var groupMap = {},
-                       model = this,
-                       result = {};
-
-               // Go over the given parameters, break apart to groupings
-               // The resulting object represents the group with its parameter
-               // values. For example:
-               // {
-               //    group1: {
-               //       param1: "1",
-               //       param2: "0",
-               //       param3: "1"
-               //    },
-               //    group2: "param4|param5"
-               // }
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( params, function ( paramName, paramValue ) {
-                       var groupName,
-                               itemOrGroup = model.parameterMap[ paramName ];
-
-                       if ( itemOrGroup ) {
-                               groupName = itemOrGroup instanceof FilterItem ?
-                                       itemOrGroup.getGroupName() : itemOrGroup.getName();
-
-                               groupMap[ groupName ] = groupMap[ groupName ] || {};
-                               groupMap[ groupName ][ paramName ] = paramValue;
-                       }
-               } );
-
-               // Go over all groups, so we make sure we get the complete output
-               // even if the parameters don't include a certain group
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( this.groups, function ( groupName, groupModel ) {
-                       result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get the highlight parameters based on current filter configuration
-        *
-        * @return {Object} Object where keys are `<filter name>_color` and values
-        *                  are the selected highlight colors.
-        */
-       FiltersViewModel.prototype.getHighlightParameters = function () {
-               var highlightEnabled = this.isHighlightEnabled(),
-                       result = {};
-
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isHighlightSupported() ) {
-                               result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
-                                       filterItem.getHighlightColor() :
-                                       null;
+       }
+
+       return result;
+};
+
+/**
+ * Get the current full state of the filters
+ *
+ * @return {Object} Filters full state
+ */
+FiltersViewModel.prototype.getFullState = function () {
+       var i,
+               items = this.getItems(),
+               result = {};
+
+       for ( i = 0; i < items.length; i++ ) {
+               result[ items[ i ].getName() ] = {
+                       selected: items[ i ].isSelected(),
+                       conflicted: items[ i ].isConflicted(),
+                       included: items[ i ].isIncluded()
+               };
+       }
+
+       return result;
+};
+
+/**
+ * Get an object representing default parameters state
+ *
+ * @return {Object} Default parameter values
+ */
+FiltersViewModel.prototype.getDefaultParams = function () {
+       var result = {};
+
+       // Get default filter state
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( this.groups, function ( name, model ) {
+               if ( !model.isSticky() ) {
+                       $.extend( true, result, model.getDefaultParams() );
+               }
+       } );
+
+       return result;
+};
+
+/**
+ * Get a parameter representation of all sticky parameters
+ *
+ * @return {Object} Sticky parameter values
+ */
+FiltersViewModel.prototype.getStickyParams = function () {
+       var result = [];
+
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( this.groups, function ( name, model ) {
+               if ( model.isSticky() ) {
+                       if ( model.isPerGroupRequestParameter() ) {
+                               result.push( name );
+                       } else {
+                               // Each filter is its own param
+                               result = result.concat( model.getItems().map( function ( filterItem ) {
+                                       return filterItem.getParamName();
+                               } ) );
                        }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get an object representing the complete empty state of highlights
-        *
-        * @return {Object} Object containing all the highlight parameters set to their negative value
-        */
-       FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
-               var result = {};
-
+               }
+       } );
+
+       return result;
+};
+
+/**
+ * Get a parameter representation of all sticky parameters
+ *
+ * @return {Object} Sticky parameter values
+ */
+FiltersViewModel.prototype.getStickyParamsValues = function () {
+       var result = {};
+
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( this.groups, function ( name, model ) {
+               if ( model.isSticky() ) {
+                       $.extend( true, result, model.getParamRepresentation() );
+               }
+       } );
+
+       return result;
+};
+
+/**
+ * Analyze the groups and their filters and output an object representing
+ * the state of the parameters they represent.
+ *
+ * @param {Object} [filterDefinition] An object defining the filter values,
+ *  keyed by filter names.
+ * @return {Object} Parameter state object
+ */
+FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
+       var groupItemDefinition,
+               result = {},
+               groupItems = this.getFilterGroups();
+
+       if ( filterDefinition ) {
+               groupItemDefinition = {};
+               // Filter definition is "flat", but in effect
+               // each group needs to tell us its result based
+               // on the values in it. We need to split this list
+               // back into groupings so we can "feed" it to the
+               // loop below, and we need to expand it so it includes
+               // all filters (set to false)
                this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isHighlightSupported() ) {
-                               result[ filterItem.getName() + '_color' ] = null;
-                       }
+                       groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
+                       groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
                } );
+       }
+
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( groupItems, function ( group, model ) {
+               $.extend(
+                       result,
+                       model.getParamRepresentation(
+                               groupItemDefinition ?
+                                       groupItemDefinition[ group ] : null
+                       )
+               );
+       } );
+
+       return result;
+};
+
+/**
+ * This is the opposite of the #getParametersFromFilters method; this goes over
+ * the given parameters and translates into a selected/unselected value in the filters.
+ *
+ * @param {Object} params Parameters query object
+ * @return {Object} Filter state object
+ */
+FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
+       var groupMap = {},
+               model = this,
+               result = {};
+
+       // Go over the given parameters, break apart to groupings
+       // The resulting object represents the group with its parameter
+       // values. For example:
+       // {
+       //    group1: {
+       //       param1: "1",
+       //       param2: "0",
+       //       param3: "1"
+       //    },
+       //    group2: "param4|param5"
+       // }
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( params, function ( paramName, paramValue ) {
+               var groupName,
+                       itemOrGroup = model.parameterMap[ paramName ];
+
+               if ( itemOrGroup ) {
+                       groupName = itemOrGroup instanceof FilterItem ?
+                               itemOrGroup.getGroupName() : itemOrGroup.getName();
+
+                       groupMap[ groupName ] = groupMap[ groupName ] || {};
+                       groupMap[ groupName ][ paramName ] = paramValue;
+               }
+       } );
+
+       // Go over all groups, so we make sure we get the complete output
+       // even if the parameters don't include a certain group
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( this.groups, function ( groupName, groupModel ) {
+               result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
+       } );
+
+       return result;
+};
+
+/**
+ * Get the highlight parameters based on current filter configuration
+ *
+ * @return {Object} Object where keys are `<filter name>_color` and values
+ *                  are the selected highlight colors.
+ */
+FiltersViewModel.prototype.getHighlightParameters = function () {
+       var highlightEnabled = this.isHighlightEnabled(),
+               result = {};
+
+       this.getItems().forEach( function ( filterItem ) {
+               if ( filterItem.isHighlightSupported() ) {
+                       result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
+                               filterItem.getHighlightColor() :
+                               null;
+               }
+       } );
+
+       return result;
+};
+
+/**
+ * Get an object representing the complete empty state of highlights
+ *
+ * @return {Object} Object containing all the highlight parameters set to their negative value
+ */
+FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
+       var result = {};
+
+       this.getItems().forEach( function ( filterItem ) {
+               if ( filterItem.isHighlightSupported() ) {
+                       result[ filterItem.getName() + '_color' ] = null;
+               }
+       } );
 
-               return result;
-       };
-
-       /**
-        * Get an array of currently applied highlight colors
-        *
-        * @return {string[]} Currently applied highlight colors
-        */
-       FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
-               var result = [];
+       return result;
+};
 
-               if ( this.isHighlightEnabled() ) {
-                       this.getHighlightedItems().forEach( function ( filterItem ) {
-                               var color = filterItem.getHighlightColor();
+/**
+ * Get an array of currently applied highlight colors
+ *
+ * @return {string[]} Currently applied highlight colors
+ */
+FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
+       var result = [];
 
-                               if ( result.indexOf( color ) === -1 ) {
-                                       result.push( color );
-                               }
-                       } );
-               }
+       if ( this.isHighlightEnabled() ) {
+               this.getHighlightedItems().forEach( function ( filterItem ) {
+                       var color = filterItem.getHighlightColor();
 
-               return result;
-       };
-
-       /**
-        * Sanitize value group of a string_option groups type
-        * Remove duplicates and make sure to only use valid
-        * values.
-        *
-        * @private
-        * @param {string} groupName Group name
-        * @param {string[]} valueArray Array of values
-        * @return {string[]} Array of valid values
-        */
-       FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
-               var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
-                       return filterItem.getParamName();
+                       if ( result.indexOf( color ) === -1 ) {
+                               result.push( color );
+                       }
                } );
-
-               return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
-       };
-
-       /**
-        * Check whether no visible filter is selected.
-        *
-        * Filter groups that are hidden or sticky are not shown in the
-        * active filters area and therefore not included in this check.
-        *
-        * @return {boolean} No visible filter is selected
-        */
-       FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
-               // Check if there are either any selected items or any items
-               // that have highlight enabled
-               return !this.getItems().some( function ( filterItem ) {
-                       var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
-                               active = ( filterItem.isSelected() || filterItem.isHighlighted() );
-                       return visible && active;
+       }
+
+       return result;
+};
+
+/**
+ * Sanitize value group of a string_option groups type
+ * Remove duplicates and make sure to only use valid
+ * values.
+ *
+ * @private
+ * @param {string} groupName Group name
+ * @param {string[]} valueArray Array of values
+ * @return {string[]} Array of valid values
+ */
+FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
+       var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
+               return filterItem.getParamName();
+       } );
+
+       return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
+};
+
+/**
+ * Check whether no visible filter is selected.
+ *
+ * Filter groups that are hidden or sticky are not shown in the
+ * active filters area and therefore not included in this check.
+ *
+ * @return {boolean} No visible filter is selected
+ */
+FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
+       // Check if there are either any selected items or any items
+       // that have highlight enabled
+       return !this.getItems().some( function ( filterItem ) {
+               var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
+                       active = ( filterItem.isSelected() || filterItem.isHighlighted() );
+               return visible && active;
+       } );
+};
+
+/**
+ * Check whether the invert state is a valid one. A valid invert state is one where
+ * there are actual namespaces selected.
+ *
+ * This is done to compare states to previous ones that may have had the invert model
+ * selected but effectively had no namespaces, so are not effectively different than
+ * ones where invert is not selected.
+ *
+ * @return {boolean} Invert is effectively selected
+ */
+FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
+       return this.getInvertModel().isSelected() &&
+               this.findSelectedItems().some( function ( itemModel ) {
+                       return itemModel.getGroupModel().getName() === 'namespace';
                } );
-       };
-
-       /**
-        * Check whether the invert state is a valid one. A valid invert state is one where
-        * there are actual namespaces selected.
-        *
-        * This is done to compare states to previous ones that may have had the invert model
-        * selected but effectively had no namespaces, so are not effectively different than
-        * ones where invert is not selected.
-        *
-        * @return {boolean} Invert is effectively selected
-        */
-       FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
-               return this.getInvertModel().isSelected() &&
-                       this.findSelectedItems().some( function ( itemModel ) {
-                               return itemModel.getGroupModel().getName() === 'namespace';
-                       } );
-       };
-
-       /**
-        * Get the item that matches the given name
-        *
-        * @param {string} name Filter name
-        * @return {mw.rcfilters.dm.FilterItem} Filter item
-        */
-       FiltersViewModel.prototype.getItemByName = function ( name ) {
-               return this.getItems().filter( function ( item ) {
-                       return name === item.getName();
-               } )[ 0 ];
-       };
-
-       /**
-        * Set all filters to false or empty/all
-        * This is equivalent to display all.
-        */
-       FiltersViewModel.prototype.emptyAllFilters = function () {
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( !filterItem.getGroupModel().isSticky() ) {
-                               this.toggleFilterSelected( filterItem.getName(), false );
-                       }
-               }.bind( this ) );
-       };
-
-       /**
-        * Toggle selected state of one item
-        *
-        * @param {string} name Name of the filter item
-        * @param {boolean} [isSelected] Filter selected state
-        */
-       FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
-               var item = this.getItemByName( name );
-
-               if ( item ) {
-                       item.toggleSelected( isSelected );
+};
+
+/**
+ * Get the item that matches the given name
+ *
+ * @param {string} name Filter name
+ * @return {mw.rcfilters.dm.FilterItem} Filter item
+ */
+FiltersViewModel.prototype.getItemByName = function ( name ) {
+       return this.getItems().filter( function ( item ) {
+               return name === item.getName();
+       } )[ 0 ];
+};
+
+/**
+ * Set all filters to false or empty/all
+ * This is equivalent to display all.
+ */
+FiltersViewModel.prototype.emptyAllFilters = function () {
+       this.getItems().forEach( function ( filterItem ) {
+               if ( !filterItem.getGroupModel().isSticky() ) {
+                       this.toggleFilterSelected( filterItem.getName(), false );
                }
-       };
-
-       /**
-        * Toggle selected state of items by their names
-        *
-        * @param {Object} filterDef Filter definitions
-        */
-       FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
-               Object.keys( filterDef ).forEach( function ( name ) {
-                       this.toggleFilterSelected( name, filterDef[ name ] );
-               }.bind( this ) );
-       };
-
-       /**
-        * Get a group model from its name
-        *
-        * @param {string} groupName Group name
-        * @return {mw.rcfilters.dm.FilterGroup} Group model
-        */
-       FiltersViewModel.prototype.getGroup = function ( groupName ) {
-               return this.groups[ groupName ];
-       };
-
-       /**
-        * Get all filters within a specified group by its name
-        *
-        * @param {string} groupName Group name
-        * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
-        */
-       FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
-               return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
-       };
-
-       /**
-        * Find items whose labels match the given string
-        *
-        * @param {string} query Search string
-        * @param {boolean} [returnFlat] Return a flat array. If false, the result
-        *  is an object whose keys are the group names and values are an array of
-        *  filters per group. If set to true, returns an array of filters regardless
-        *  of their groups.
-        * @return {Object} An object of items to show
-        *  arranged by their group names
-        */
-       FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
-               var i, searchIsEmpty,
-                       groupTitle,
-                       result = {},
-                       flatResult = [],
-                       view = this.getViewByTrigger( query.substr( 0, 1 ) ),
-                       items = this.getFiltersByView( view );
-
-               // Normalize so we can search strings regardless of case and view
-               query = query.trim().toLowerCase();
-               if ( view !== 'default' ) {
-                       query = query.substr( 1 );
+       }.bind( this ) );
+};
+
+/**
+ * Toggle selected state of one item
+ *
+ * @param {string} name Name of the filter item
+ * @param {boolean} [isSelected] Filter selected state
+ */
+FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
+       var item = this.getItemByName( name );
+
+       if ( item ) {
+               item.toggleSelected( isSelected );
+       }
+};
+
+/**
+ * Toggle selected state of items by their names
+ *
+ * @param {Object} filterDef Filter definitions
+ */
+FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
+       Object.keys( filterDef ).forEach( function ( name ) {
+               this.toggleFilterSelected( name, filterDef[ name ] );
+       }.bind( this ) );
+};
+
+/**
+ * Get a group model from its name
+ *
+ * @param {string} groupName Group name
+ * @return {mw.rcfilters.dm.FilterGroup} Group model
+ */
+FiltersViewModel.prototype.getGroup = function ( groupName ) {
+       return this.groups[ groupName ];
+};
+
+/**
+ * Get all filters within a specified group by its name
+ *
+ * @param {string} groupName Group name
+ * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
+ */
+FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
+       return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
+};
+
+/**
+ * Find items whose labels match the given string
+ *
+ * @param {string} query Search string
+ * @param {boolean} [returnFlat] Return a flat array. If false, the result
+ *  is an object whose keys are the group names and values are an array of
+ *  filters per group. If set to true, returns an array of filters regardless
+ *  of their groups.
+ * @return {Object} An object of items to show
+ *  arranged by their group names
+ */
+FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
+       var i, searchIsEmpty,
+               groupTitle,
+               result = {},
+               flatResult = [],
+               view = this.getViewByTrigger( query.substr( 0, 1 ) ),
+               items = this.getFiltersByView( view );
+
+       // Normalize so we can search strings regardless of case and view
+       query = query.trim().toLowerCase();
+       if ( view !== 'default' ) {
+               query = query.substr( 1 );
+       }
+       // Trim again to also intercept cases where the spaces were after the trigger
+       // eg: '#   str'
+       query = query.trim();
+
+       // Check if the search if actually empty; this can be a problem when
+       // we use prefixes to denote different views
+       searchIsEmpty = query.length === 0;
+
+       // item label starting with the query string
+       for ( i = 0; i < items.length; i++ ) {
+               if (
+                       searchIsEmpty ||
+                       items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
+                       (
+                               // For tags, we want the parameter name to be included in the search
+                               view === 'tags' &&
+                               items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+                       )
+               ) {
+                       result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+                       result[ items[ i ].getGroupName() ].push( items[ i ] );
+                       flatResult.push( items[ i ] );
                }
-               // Trim again to also intercept cases where the spaces were after the trigger
-               // eg: '#   str'
-               query = query.trim();
+       }
 
-               // Check if the search if actually empty; this can be a problem when
-               // we use prefixes to denote different views
-               searchIsEmpty = query.length === 0;
-
-               // item label starting with the query string
+       if ( $.isEmptyObject( result ) ) {
+               // item containing the query string in their label, description, or group title
                for ( i = 0; i < items.length; i++ ) {
+                       groupTitle = items[ i ].getGroupModel().getTitle();
                        if (
                                searchIsEmpty ||
-                               items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
+                               items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
+                               items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
+                               groupTitle.toLowerCase().indexOf( query ) > -1 ||
                                (
                                        // For tags, we want the parameter name to be included in the search
                                        view === 'tags' &&
                                flatResult.push( items[ i ] );
                        }
                }
-
-               if ( $.isEmptyObject( result ) ) {
-                       // item containing the query string in their label, description, or group title
-                       for ( i = 0; i < items.length; i++ ) {
-                               groupTitle = items[ i ].getGroupModel().getTitle();
-                               if (
-                                       searchIsEmpty ||
-                                       items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
-                                       items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
-                                       groupTitle.toLowerCase().indexOf( query ) > -1 ||
-                                       (
-                                               // For tags, we want the parameter name to be included in the search
-                                               view === 'tags' &&
-                                               items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
-                                       )
-                               ) {
-                                       result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
-                                       result[ items[ i ].getGroupName() ].push( items[ i ] );
-                                       flatResult.push( items[ i ] );
-                               }
-                       }
+       }
+
+       return returnFlat ? flatResult : result;
+};
+
+/**
+ * Get items that are highlighted
+ *
+ * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
+ */
+FiltersViewModel.prototype.getHighlightedItems = function () {
+       return this.getItems().filter( function ( filterItem ) {
+               return filterItem.isHighlightSupported() &&
+                       filterItem.getHighlightColor();
+       } );
+};
+
+/**
+ * Get items that allow highlights even if they're not currently highlighted
+ *
+ * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
+ */
+FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
+       return this.getItems().filter( function ( filterItem ) {
+               return filterItem.isHighlightSupported();
+       } );
+};
+
+/**
+ * Get all selected items
+ *
+ * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+ */
+FiltersViewModel.prototype.findSelectedItems = function () {
+       var allSelected = [];
+
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+               allSelected = allSelected.concat( groupModel.findSelectedItems() );
+       } );
+
+       return allSelected;
+};
+
+/**
+ * Get the current view
+ *
+ * @return {string} Current view
+ */
+FiltersViewModel.prototype.getCurrentView = function () {
+       return this.currentView;
+};
+
+/**
+ * Get the label for the current view
+ *
+ * @param {string} viewName View name
+ * @return {string} Label for the current view
+ */
+FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
+       viewName = viewName || this.getCurrentView();
+
+       return this.views[ viewName ] && this.views[ viewName ].title;
+};
+
+/**
+ * Get the view that fits the given trigger
+ *
+ * @param {string} trigger Trigger
+ * @return {string} Name of view
+ */
+FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
+       var result = 'default';
+
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( this.views, function ( name, data ) {
+               if ( data.trigger === trigger ) {
+                       result = name;
                }
-
-               return returnFlat ? flatResult : result;
-       };
-
-       /**
-        * Get items that are highlighted
-        *
-        * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
-        */
-       FiltersViewModel.prototype.getHighlightedItems = function () {
-               return this.getItems().filter( function ( filterItem ) {
-                       return filterItem.isHighlightSupported() &&
-                               filterItem.getHighlightColor();
-               } );
-       };
-
-       /**
-        * Get items that allow highlights even if they're not currently highlighted
-        *
-        * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
-        */
-       FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
-               return this.getItems().filter( function ( filterItem ) {
-                       return filterItem.isHighlightSupported();
-               } );
-       };
-
-       /**
-        * Get all selected items
-        *
-        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
-        */
-       FiltersViewModel.prototype.findSelectedItems = function () {
-               var allSelected = [];
-
+       } );
+
+       return result;
+};
+
+/**
+ * Return a version of the given string that is without any
+ * view triggers.
+ *
+ * @param {string} str Given string
+ * @return {string} Result
+ */
+FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
+       if ( this.getViewFromString( str ) !== 'default' ) {
+               str = str.substr( 1 );
+       }
+
+       return str;
+};
+
+/**
+ * Get the view from the given string by a trigger, if it exists
+ *
+ * @param {string} str Given string
+ * @return {string} View name
+ */
+FiltersViewModel.prototype.getViewFromString = function ( str ) {
+       return this.getViewByTrigger( str.substr( 0, 1 ) );
+};
+
+/**
+ * Set the current search for the system.
+ * This also dictates what items and groups are visible according
+ * to the search in #findMatches
+ *
+ * @param {string} searchQuery Search query, including triggers
+ * @fires searchChange
+ */
+FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
+       var visibleGroups, visibleGroupNames;
+
+       if ( this.searchQuery !== searchQuery ) {
+               // Check if the view changed
+               this.switchView( this.getViewFromString( searchQuery ) );
+
+               visibleGroups = this.findMatches( searchQuery );
+               visibleGroupNames = Object.keys( visibleGroups );
+
+               // Update visibility of items and groups
                // eslint-disable-next-line no-jquery/no-each-util
                $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
-                       allSelected = allSelected.concat( groupModel.findSelectedItems() );
-               } );
-
-               return allSelected;
-       };
-
-       /**
-        * Get the current view
-        *
-        * @return {string} Current view
-        */
-       FiltersViewModel.prototype.getCurrentView = function () {
-               return this.currentView;
-       };
-
-       /**
-        * Get the label for the current view
-        *
-        * @param {string} viewName View name
-        * @return {string} Label for the current view
-        */
-       FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
-               viewName = viewName || this.getCurrentView();
-
-               return this.views[ viewName ] && this.views[ viewName ].title;
-       };
-
-       /**
-        * Get the view that fits the given trigger
-        *
-        * @param {string} trigger Trigger
-        * @return {string} Name of view
-        */
-       FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
-               var result = 'default';
-
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( this.views, function ( name, data ) {
-                       if ( data.trigger === trigger ) {
-                               result = name;
-                       }
+                       // Check if the group is visible at all
+                       groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
+                       groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
                } );
 
-               return result;
-       };
-
-       /**
-        * Return a version of the given string that is without any
-        * view triggers.
-        *
-        * @param {string} str Given string
-        * @return {string} Result
-        */
-       FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
-               if ( this.getViewFromString( str ) !== 'default' ) {
-                       str = str.substr( 1 );
-               }
-
-               return str;
-       };
-
-       /**
-        * Get the view from the given string by a trigger, if it exists
-        *
-        * @param {string} str Given string
-        * @return {string} View name
-        */
-       FiltersViewModel.prototype.getViewFromString = function ( str ) {
-               return this.getViewByTrigger( str.substr( 0, 1 ) );
-       };
-
-       /**
-        * Set the current search for the system.
-        * This also dictates what items and groups are visible according
-        * to the search in #findMatches
-        *
-        * @param {string} searchQuery Search query, including triggers
-        * @fires searchChange
-        */
-       FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
-               var visibleGroups, visibleGroupNames;
-
-               if ( this.searchQuery !== searchQuery ) {
-                       // Check if the view changed
-                       this.switchView( this.getViewFromString( searchQuery ) );
-
-                       visibleGroups = this.findMatches( searchQuery );
-                       visibleGroupNames = Object.keys( visibleGroups );
-
-                       // Update visibility of items and groups
-                       // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
-                               // Check if the group is visible at all
-                               groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
-                               groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
-                       } );
-
-                       this.searchQuery = searchQuery;
-                       this.emit( 'searchChange', this.searchQuery );
-               }
-       };
-
-       /**
-        * Get the current search
-        *
-        * @return {string} Current search query
-        */
-       FiltersViewModel.prototype.getSearch = function () {
-               return this.searchQuery;
-       };
-
-       /**
-        * Switch the current view
-        *
-        * @private
-        * @param {string} view View name
-        */
-       FiltersViewModel.prototype.switchView = function ( view ) {
-               if ( this.views[ view ] && this.currentView !== view ) {
-                       this.currentView = view;
-               }
-       };
-
-       /**
-        * Toggle the highlight feature on and off.
-        * Propagate the change to filter items.
-        *
-        * @param {boolean} enable Highlight should be enabled
-        * @fires highlightChange
-        */
-       FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
-               enable = enable === undefined ? !this.highlightEnabled : enable;
-
-               if ( this.highlightEnabled !== enable ) {
-                       this.highlightEnabled = enable;
-                       this.emit( 'highlightChange', this.highlightEnabled );
-               }
-       };
-
-       /**
-        * Check if the highlight feature is enabled
-        * @return {boolean}
-        */
-       FiltersViewModel.prototype.isHighlightEnabled = function () {
-               return !!this.highlightEnabled;
-       };
-
-       /**
-        * Toggle the inverted namespaces property on and off.
-        * Propagate the change to namespace filter items.
-        *
-        * @param {boolean} enable Inverted property is enabled
-        */
-       FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
-               this.toggleFilterSelected( this.getInvertModel().getName(), enable );
-       };
-
-       /**
-        * Get the model object that represents the 'invert' filter
-        *
-        * @return {mw.rcfilters.dm.FilterItem}
-        */
-       FiltersViewModel.prototype.getInvertModel = function () {
-               return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
-       };
-
-       /**
-        * Set highlight color for a specific filter item
-        *
-        * @param {string} filterName Name of the filter item
-        * @param {string} color Selected color
-        */
-       FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
-               this.getItemByName( filterName ).setHighlightColor( color );
-       };
-
-       /**
-        * Clear highlight for a specific filter item
-        *
-        * @param {string} filterName Name of the filter item
-        */
-       FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
-               this.getItemByName( filterName ).clearHighlightColor();
-       };
-
-       module.exports = FiltersViewModel;
-
-}() );
+               this.searchQuery = searchQuery;
+               this.emit( 'searchChange', this.searchQuery );
+       }
+};
+
+/**
+ * Get the current search
+ *
+ * @return {string} Current search query
+ */
+FiltersViewModel.prototype.getSearch = function () {
+       return this.searchQuery;
+};
+
+/**
+ * Switch the current view
+ *
+ * @private
+ * @param {string} view View name
+ */
+FiltersViewModel.prototype.switchView = function ( view ) {
+       if ( this.views[ view ] && this.currentView !== view ) {
+               this.currentView = view;
+       }
+};
+
+/**
+ * Toggle the highlight feature on and off.
+ * Propagate the change to filter items.
+ *
+ * @param {boolean} enable Highlight should be enabled
+ * @fires highlightChange
+ */
+FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
+       enable = enable === undefined ? !this.highlightEnabled : enable;
+
+       if ( this.highlightEnabled !== enable ) {
+               this.highlightEnabled = enable;
+               this.emit( 'highlightChange', this.highlightEnabled );
+       }
+};
+
+/**
+ * Check if the highlight feature is enabled
+ * @return {boolean}
+ */
+FiltersViewModel.prototype.isHighlightEnabled = function () {
+       return !!this.highlightEnabled;
+};
+
+/**
+ * Toggle the inverted namespaces property on and off.
+ * Propagate the change to namespace filter items.
+ *
+ * @param {boolean} enable Inverted property is enabled
+ */
+FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
+       this.toggleFilterSelected( this.getInvertModel().getName(), enable );
+};
+
+/**
+ * Get the model object that represents the 'invert' filter
+ *
+ * @return {mw.rcfilters.dm.FilterItem}
+ */
+FiltersViewModel.prototype.getInvertModel = function () {
+       return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
+};
+
+/**
+ * Set highlight color for a specific filter item
+ *
+ * @param {string} filterName Name of the filter item
+ * @param {string} color Selected color
+ */
+FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
+       this.getItemByName( filterName ).setHighlightColor( color );
+};
+
+/**
+ * Clear highlight for a specific filter item
+ *
+ * @param {string} filterName Name of the filter item
+ */
+FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
+       this.getItemByName( filterName ).clearHighlightColor();
+};
+
+module.exports = FiltersViewModel;
index 2dc578e..ae8ac5f 100644 (file)
-( function () {
-       /**
-        * RCFilter base item model
-        *
-        * @class mw.rcfilters.dm.ItemModel
-        * @mixins OO.EventEmitter
-        *
-        * @constructor
-        * @param {string} param Filter param name
-        * @param {Object} config Configuration object
-        * @cfg {string} [label] The label for the filter
-        * @cfg {string} [description] The description of the filter
-        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
-        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
-        *  with 'default' and 'inverted' as keys.
-        * @cfg {boolean} [active=true] The filter is active and affecting the result
-        * @cfg {boolean} [selected] The item is selected
-        * @cfg {*} [value] The value of this item
-        * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
-        *  identifier
-        * @cfg {string} [cssClass] The class identifying the results that match this filter
-        * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
-        *  added and considered in the view.
-        * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
-        */
-       var ItemModel = function MwRcfiltersDmItemModel( param, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               this.param = param;
-               this.namePrefix = config.namePrefix || 'item_';
-               this.name = this.namePrefix + param;
-
-               this.label = config.label || this.name;
-               this.labelPrefixKey = config.labelPrefixKey;
-               this.description = config.description || '';
-               this.setValue( config.value || config.selected );
-
-               this.identifiers = config.identifiers || [];
-
-               // Highlight
-               this.cssClass = config.cssClass;
-               this.highlightColor = config.defaultHighlightColor || null;
+/**
+ * RCFilter base item model
+ *
+ * @class mw.rcfilters.dm.ItemModel
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {string} param Filter param name
+ * @param {Object} config Configuration object
+ * @cfg {string} [label] The label for the filter
+ * @cfg {string} [description] The description of the filter
+ * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+ *  group. If the prefix has 'invert' state, the parameter is expected to be an object
+ *  with 'default' and 'inverted' as keys.
+ * @cfg {boolean} [active=true] The filter is active and affecting the result
+ * @cfg {boolean} [selected] The item is selected
+ * @cfg {*} [value] The value of this item
+ * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
+ *  identifier
+ * @cfg {string} [cssClass] The class identifying the results that match this filter
+ * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
+ *  added and considered in the view.
+ * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
+ */
+var ItemModel = function MwRcfiltersDmItemModel( param, config ) {
+       config = config || {};
+
+       // Mixin constructor
+       OO.EventEmitter.call( this );
+
+       this.param = param;
+       this.namePrefix = config.namePrefix || 'item_';
+       this.name = this.namePrefix + param;
+
+       this.label = config.label || this.name;
+       this.labelPrefixKey = config.labelPrefixKey;
+       this.description = config.description || '';
+       this.setValue( config.value || config.selected );
+
+       this.identifiers = config.identifiers || [];
+
+       // Highlight
+       this.cssClass = config.cssClass;
+       this.highlightColor = config.defaultHighlightColor || null;
+};
+
+/* Initialization */
+
+OO.initClass( ItemModel );
+OO.mixinClass( ItemModel, OO.EventEmitter );
+
+/* Events */
+
+/**
+ * @event update
+ *
+ * The state of this filter has changed
+ */
+
+/* Methods */
+
+/**
+ * Return the representation of the state of this item.
+ *
+ * @return {Object} State of the object
+ */
+ItemModel.prototype.getState = function () {
+       return {
+               selected: this.isSelected()
        };
-
-       /* Initialization */
-
-       OO.initClass( ItemModel );
-       OO.mixinClass( ItemModel, OO.EventEmitter );
-
-       /* Events */
-
-       /**
-        * @event update
-        *
-        * The state of this filter has changed
-        */
-
-       /* Methods */
-
-       /**
-        * Return the representation of the state of this item.
-        *
-        * @return {Object} State of the object
-        */
-       ItemModel.prototype.getState = function () {
-               return {
-                       selected: this.isSelected()
-               };
-       };
-
-       /**
-        * Get the name of this filter
-        *
-        * @return {string} Filter name
-        */
-       ItemModel.prototype.getName = function () {
-               return this.name;
-       };
-
-       /**
-        * Get the message key to use to wrap the label. This message takes the label as a parameter.
-        *
-        * @param {boolean} inverted Whether this item should be considered inverted
-        * @return {string|null} Message key, or null if no message
-        */
-       ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
-               if ( this.labelPrefixKey ) {
-                       if ( typeof this.labelPrefixKey === 'string' ) {
-                               return this.labelPrefixKey;
-                       }
-                       return this.labelPrefixKey[
-                               // Only use inverted-prefix if the item is selected
-                               // Highlight-only an inverted item makes no sense
-                               inverted && this.isSelected() ?
-                                       'inverted' : 'default'
-                       ];
+};
+
+/**
+ * Get the name of this filter
+ *
+ * @return {string} Filter name
+ */
+ItemModel.prototype.getName = function () {
+       return this.name;
+};
+
+/**
+ * Get the message key to use to wrap the label. This message takes the label as a parameter.
+ *
+ * @param {boolean} inverted Whether this item should be considered inverted
+ * @return {string|null} Message key, or null if no message
+ */
+ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
+       if ( this.labelPrefixKey ) {
+               if ( typeof this.labelPrefixKey === 'string' ) {
+                       return this.labelPrefixKey;
                }
-               return null;
-       };
-
-       /**
-        * Get the param name or value of this filter
-        *
-        * @return {string} Filter param name
-        */
-       ItemModel.prototype.getParamName = function () {
-               return this.param;
-       };
-
-       /**
-        * Get the message representing the state of this model.
-        *
-        * @return {string} State message
-        */
-       ItemModel.prototype.getStateMessage = function () {
-               // Display description
-               return this.getDescription();
-       };
-
-       /**
-        * Get the label of this filter
-        *
-        * @return {string} Filter label
-        */
-       ItemModel.prototype.getLabel = function () {
-               return this.label;
-       };
-
-       /**
-        * Get the description of this filter
-        *
-        * @return {string} Filter description
-        */
-       ItemModel.prototype.getDescription = function () {
-               return this.description;
-       };
-
-       /**
-        * Get the default value of this filter
-        *
-        * @return {boolean} Filter default
-        */
-       ItemModel.prototype.getDefault = function () {
-               return this.default;
-       };
-
-       /**
-        * Get the selected state of this filter
-        *
-        * @return {boolean} Filter is selected
-        */
-       ItemModel.prototype.isSelected = function () {
-               return !!this.value;
-       };
-
-       /**
-        * Toggle the selected state of the item
-        *
-        * @param {boolean} [isSelected] Filter is selected
-        * @fires update
-        */
-       ItemModel.prototype.toggleSelected = function ( isSelected ) {
-               isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
-               this.setValue( isSelected );
-       };
-
-       /**
-        * Get the value
-        *
-        * @return {*}
-        */
-       ItemModel.prototype.getValue = function () {
-               return this.value;
-       };
-
-       /**
-        * Convert a given value to the appropriate representation based on group type
-        *
-        * @param {*} value
-        * @return {*}
-        */
-       ItemModel.prototype.coerceValue = function ( value ) {
-               return this.getGroupModel().getType() === 'any_value' ? value : !!value;
-       };
-
-       /**
-        * Set the value
-        *
-        * @param {*} newValue
-        */
-       ItemModel.prototype.setValue = function ( newValue ) {
-               newValue = this.coerceValue( newValue );
-               if ( this.value !== newValue ) {
-                       this.value = newValue;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Set the highlight color
-        *
-        * @param {string|null} highlightColor
-        */
-       ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
-               if ( !this.isHighlightSupported() ) {
-                       return;
-               }
-               // If the highlight color on the item and in the parameter is null/undefined, return early.
-               if ( !this.highlightColor && !highlightColor ) {
-                       return;
-               }
-
-               if ( this.highlightColor !== highlightColor ) {
-                       this.highlightColor = highlightColor;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Clear the highlight color
-        */
-       ItemModel.prototype.clearHighlightColor = function () {
-               this.setHighlightColor( null );
-       };
-
-       /**
-        * Get the highlight color, or null if none is configured
-        *
-        * @return {string|null}
-        */
-       ItemModel.prototype.getHighlightColor = function () {
-               return this.highlightColor;
-       };
-
-       /**
-        * Get the CSS class that matches changes that fit this filter
-        * or null if none is configured
-        *
-        * @return {string|null}
-        */
-       ItemModel.prototype.getCssClass = function () {
-               return this.cssClass;
-       };
-
-       /**
-        * Get the item's identifiers
-        *
-        * @return {string[]}
-        */
-       ItemModel.prototype.getIdentifiers = function () {
-               return this.identifiers;
-       };
-
-       /**
-        * Check if the highlight feature is supported for this filter
-        *
-        * @return {boolean}
-        */
-       ItemModel.prototype.isHighlightSupported = function () {
-               return !!this.getCssClass();
-       };
-
-       /**
-        * Check if the filter is currently highlighted
-        *
-        * @return {boolean}
-        */
-       ItemModel.prototype.isHighlighted = function () {
-               return !!this.getHighlightColor();
-       };
-
-       module.exports = ItemModel;
-}() );
+               return this.labelPrefixKey[
+                       // Only use inverted-prefix if the item is selected
+                       // Highlight-only an inverted item makes no sense
+                       inverted && this.isSelected() ?
+                               'inverted' : 'default'
+               ];
+       }
+       return null;
+};
+
+/**
+ * Get the param name or value of this filter
+ *
+ * @return {string} Filter param name
+ */
+ItemModel.prototype.getParamName = function () {
+       return this.param;
+};
+
+/**
+ * Get the message representing the state of this model.
+ *
+ * @return {string} State message
+ */
+ItemModel.prototype.getStateMessage = function () {
+       // Display description
+       return this.getDescription();
+};
+
+/**
+ * Get the label of this filter
+ *
+ * @return {string} Filter label
+ */
+ItemModel.prototype.getLabel = function () {
+       return this.label;
+};
+
+/**
+ * Get the description of this filter
+ *
+ * @return {string} Filter description
+ */
+ItemModel.prototype.getDescription = function () {
+       return this.description;
+};
+
+/**
+ * Get the default value of this filter
+ *
+ * @return {boolean} Filter default
+ */
+ItemModel.prototype.getDefault = function () {
+       return this.default;
+};
+
+/**
+ * Get the selected state of this filter
+ *
+ * @return {boolean} Filter is selected
+ */
+ItemModel.prototype.isSelected = function () {
+       return !!this.value;
+};
+
+/**
+ * Toggle the selected state of the item
+ *
+ * @param {boolean} [isSelected] Filter is selected
+ * @fires update
+ */
+ItemModel.prototype.toggleSelected = function ( isSelected ) {
+       isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
+       this.setValue( isSelected );
+};
+
+/**
+ * Get the value
+ *
+ * @return {*}
+ */
+ItemModel.prototype.getValue = function () {
+       return this.value;
+};
+
+/**
+ * Convert a given value to the appropriate representation based on group type
+ *
+ * @param {*} value
+ * @return {*}
+ */
+ItemModel.prototype.coerceValue = function ( value ) {
+       return this.getGroupModel().getType() === 'any_value' ? value : !!value;
+};
+
+/**
+ * Set the value
+ *
+ * @param {*} newValue
+ */
+ItemModel.prototype.setValue = function ( newValue ) {
+       newValue = this.coerceValue( newValue );
+       if ( this.value !== newValue ) {
+               this.value = newValue;
+               this.emit( 'update' );
+       }
+};
+
+/**
+ * Set the highlight color
+ *
+ * @param {string|null} highlightColor
+ */
+ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
+       if ( !this.isHighlightSupported() ) {
+               return;
+       }
+       // If the highlight color on the item and in the parameter is null/undefined, return early.
+       if ( !this.highlightColor && !highlightColor ) {
+               return;
+       }
+
+       if ( this.highlightColor !== highlightColor ) {
+               this.highlightColor = highlightColor;
+               this.emit( 'update' );
+       }
+};
+
+/**
+ * Clear the highlight color
+ */
+ItemModel.prototype.clearHighlightColor = function () {
+       this.setHighlightColor( null );
+};
+
+/**
+ * Get the highlight color, or null if none is configured
+ *
+ * @return {string|null}
+ */
+ItemModel.prototype.getHighlightColor = function () {
+       return this.highlightColor;
+};
+
+/**
+ * Get the CSS class that matches changes that fit this filter
+ * or null if none is configured
+ *
+ * @return {string|null}
+ */
+ItemModel.prototype.getCssClass = function () {
+       return this.cssClass;
+};
+
+/**
+ * Get the item's identifiers
+ *
+ * @return {string[]}
+ */
+ItemModel.prototype.getIdentifiers = function () {
+       return this.identifiers;
+};
+
+/**
+ * Check if the highlight feature is supported for this filter
+ *
+ * @return {boolean}
+ */
+ItemModel.prototype.isHighlightSupported = function () {
+       return !!this.getCssClass();
+};
+
+/**
+ * Check if the filter is currently highlighted
+ *
+ * @return {boolean}
+ */
+ItemModel.prototype.isHighlighted = function () {
+       return !!this.getHighlightColor();
+};
+
+module.exports = ItemModel;
index aa407b9..19de282 100644 (file)
-( function () {
-       var SavedQueryItemModel = require( './SavedQueryItemModel.js' ),
-               SavedQueriesModel;
-
-       /**
-        * View model for saved queries
-        *
-        * @class mw.rcfilters.dm.SavedQueriesModel
-        * @mixins OO.EventEmitter
-        * @mixins OO.EmitterList
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
-        * @param {Object} [config] Configuration options
-        * @cfg {string} [default] Default query ID
-        */
-       SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-               OO.EmitterList.call( this );
-
-               this.default = config.default;
-               this.filtersModel = filtersModel;
-               this.converted = false;
-
-               // Events
-               this.aggregate( { update: 'itemUpdate' } );
-       };
-
-       /* Initialization */
-
-       OO.initClass( SavedQueriesModel );
-       OO.mixinClass( SavedQueriesModel, OO.EventEmitter );
-       OO.mixinClass( SavedQueriesModel, OO.EmitterList );
-
-       /* Events */
-
-       /**
-        * @event initialize
-        *
-        * Model is initialized
-        */
-
-       /**
-        * @event itemUpdate
-        * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
-        *
-        * An item has changed
-        */
-
-       /**
-        * @event default
-        * @param {string} New default ID
-        *
-        * The default has changed
-        */
-
-       /* Methods */
-
-       /**
-        * Initialize the saved queries model by reading it from the user's settings.
-        * The structure of the saved queries is:
-        * {
-        *    version: (string) Version number; if version 2, the query represents
-        *             parameters. Otherwise, the older version represented filters
-        *             and needs to be readjusted,
-        *    default: (string) Query ID
-        *    queries:{
-        *       query_id_1: {
-        *          data:{
-        *             filters: (Object) Minimal definition of the filters
-        *             highlights: (Object) Definition of the highlights
-        *          },
-        *          label: (optional) Name of this query
-        *       }
-        *    }
-        * }
-        *
-        * @param {Object} [savedQueries] An object with the saved queries with
-        *  the above structure.
-        * @fires initialize
-        */
-       SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
-               var model = this;
-
-               savedQueries = savedQueries || {};
-
-               this.clearItems();
-               this.default = null;
-               this.converted = false;
-
-               if ( savedQueries.version !== '2' ) {
-                       // Old version dealt with filter names. We need to migrate to the new structure
-                       // The new structure:
-                       // {
-                       //   version: (string) '2',
-                       //   default: (string) Query ID,
-                       //   queries: {
-                       //     query_id: {
-                       //       label: (string) Name of the query
-                       //       data: {
-                       //         params: (object) Representing all the parameter states
-                       //         highlights: (object) Representing all the filter highlight states
-                       //     }
-                       //   }
-                       // }
-                       // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( savedQueries.queries || {}, function ( id, obj ) {
-                               if ( obj.data && obj.data.filters ) {
-                                       obj.data = model.convertToParameters( obj.data );
-                               }
-                       } );
-
-                       this.converted = true;
-                       savedQueries.version = '2';
-               }
-
-               // Initialize the query items
+var SavedQueryItemModel = require( './SavedQueryItemModel.js' ),
+       SavedQueriesModel;
+
+/**
+ * View model for saved queries
+ *
+ * @class mw.rcfilters.dm.SavedQueriesModel
+ * @mixins OO.EventEmitter
+ * @mixins OO.EmitterList
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [default] Default query ID
+ */
+SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
+       config = config || {};
+
+       // Mixin constructor
+       OO.EventEmitter.call( this );
+       OO.EmitterList.call( this );
+
+       this.default = config.default;
+       this.filtersModel = filtersModel;
+       this.converted = false;
+
+       // Events
+       this.aggregate( { update: 'itemUpdate' } );
+};
+
+/* Initialization */
+
+OO.initClass( SavedQueriesModel );
+OO.mixinClass( SavedQueriesModel, OO.EventEmitter );
+OO.mixinClass( SavedQueriesModel, OO.EmitterList );
+
+/* Events */
+
+/**
+ * @event initialize
+ *
+ * Model is initialized
+ */
+
+/**
+ * @event itemUpdate
+ * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
+ *
+ * An item has changed
+ */
+
+/**
+ * @event default
+ * @param {string} New default ID
+ *
+ * The default has changed
+ */
+
+/* Methods */
+
+/**
+ * Initialize the saved queries model by reading it from the user's settings.
+ * The structure of the saved queries is:
+ * {
+ *    version: (string) Version number; if version 2, the query represents
+ *             parameters. Otherwise, the older version represented filters
+ *             and needs to be readjusted,
+ *    default: (string) Query ID
+ *    queries:{
+ *       query_id_1: {
+ *          data:{
+ *             filters: (Object) Minimal definition of the filters
+ *             highlights: (Object) Definition of the highlights
+ *          },
+ *          label: (optional) Name of this query
+ *       }
+ *    }
+ * }
+ *
+ * @param {Object} [savedQueries] An object with the saved queries with
+ *  the above structure.
+ * @fires initialize
+ */
+SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
+       var model = this;
+
+       savedQueries = savedQueries || {};
+
+       this.clearItems();
+       this.default = null;
+       this.converted = false;
+
+       if ( savedQueries.version !== '2' ) {
+               // Old version dealt with filter names. We need to migrate to the new structure
+               // The new structure:
+               // {
+               //   version: (string) '2',
+               //   default: (string) Query ID,
+               //   queries: {
+               //     query_id: {
+               //       label: (string) Name of the query
+               //       data: {
+               //         params: (object) Representing all the parameter states
+               //         highlights: (object) Representing all the filter highlight states
+               //     }
+               //   }
+               // }
                // eslint-disable-next-line no-jquery/no-each-util
                $.each( savedQueries.queries || {}, function ( id, obj ) {
-                       var normalizedData = obj.data,
-                               isDefault = String( savedQueries.default ) === String( id );
-
-                       if ( normalizedData && normalizedData.params ) {
-                               // Backwards-compat fix: Remove sticky parameters from
-                               // the given data, if they exist
-                               normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );
-
-                               // Correct the invert state for effective selection
-                               if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
-                                       delete normalizedData.params.invert;
-                               }
-
-                               model.cleanupHighlights( normalizedData );
-
-                               id = String( id );
-
-                               // Skip the addNewQuery method because we don't want to unnecessarily manipulate
-                               // the given saved queries unless we literally intend to (like in backwards compat fixes)
-                               // And the addNewQuery method also uses a minimization routine that checks for the
-                               // validity of items and minimizes the query. This isn't necessary for queries loaded
-                               // from the backend, and has the risk of removing values if they're temporarily
-                               // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
-                               model.addItems( [
-                                       new SavedQueryItemModel(
-                                               id,
-                                               obj.label,
-                                               normalizedData,
-                                               { default: isDefault }
-                                       )
-                               ] );
-
-                               if ( isDefault ) {
-                                       model.default = id;
-                               }
+                       if ( obj.data && obj.data.filters ) {
+                               obj.data = model.convertToParameters( obj.data );
                        }
                } );
 
-               this.emit( 'initialize' );
-       };
-
-       /**
-        * Clean up highlight parameters.
-        * 'highlight' used to be stored, it's not inferred based on the presence of absence of
-        * filter colors.
-        *
-        * @param {Object} data Saved query data
-        */
-       SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
-               if (
-                       data.params.highlight === '0' &&
-                       data.highlights && Object.keys( data.highlights ).length
-               ) {
-                       data.highlights = {};
-               }
-               delete data.params.highlight;
-       };
-
-       /**
-        * Convert from representation of filters to representation of parameters
-        *
-        * @param {Object} data Query data
-        * @return {Object} New converted query data
-        */
-       SavedQueriesModel.prototype.convertToParameters = function ( data ) {
-               var newData = {},
-                       defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
-                       fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
-                       highlightEnabled = data.highlights.highlight;
-
-               delete data.highlights.highlight;
-
-               // Filters
-               newData.params = this.filtersModel.getMinimizedParamRepresentation(
-                       this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
-               );
+               this.converted = true;
+               savedQueries.version = '2';
+       }
 
-               // Highlights: appending _color to keys
-               newData.highlights = {};
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( data.highlights, function ( highlightedFilterName, value ) {
-                       if ( value ) {
-                               newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
-                       }
-               } );
+       // Initialize the query items
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( savedQueries.queries || {}, function ( id, obj ) {
+               var normalizedData = obj.data,
+                       isDefault = String( savedQueries.default ) === String( id );
 
-               // Add highlight
-               newData.params.highlight = String( Number( highlightEnabled || 0 ) );
-
-               return newData;
-       };
-
-       /**
-        * Add a query item
-        *
-        * @param {string} label Label for the new query
-        * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
-        * @param {boolean} isDefault Item is default
-        * @param {string} [id] Query ID, if exists. If this isn't given, a random
-        *  new ID will be created.
-        * @return {string} ID of the newly added query
-        */
-       SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
-               var normalizedData = { params: {}, highlights: {} },
-                       highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
-                       randomID = String( id || ( new Date() ).getTime() ),
-                       data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
-
-               // Split highlight/params
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( data, function ( param, value ) {
-                       if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
-                               normalizedData.highlights[ param ] = value;
-                       } else {
-                               normalizedData.params[ param ] = value;
+               if ( normalizedData && normalizedData.params ) {
+                       // Backwards-compat fix: Remove sticky parameters from
+                       // the given data, if they exist
+                       normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );
+
+                       // Correct the invert state for effective selection
+                       if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
+                               delete normalizedData.params.invert;
                        }
-               } );
 
-               // Correct the invert state for effective selection
-               if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
-                       delete normalizedData.params.invert;
+                       model.cleanupHighlights( normalizedData );
+
+                       id = String( id );
+
+                       // Skip the addNewQuery method because we don't want to unnecessarily manipulate
+                       // the given saved queries unless we literally intend to (like in backwards compat fixes)
+                       // And the addNewQuery method also uses a minimization routine that checks for the
+                       // validity of items and minimizes the query. This isn't necessary for queries loaded
+                       // from the backend, and has the risk of removing values if they're temporarily
+                       // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
+                       model.addItems( [
+                               new SavedQueryItemModel(
+                                       id,
+                                       obj.label,
+                                       normalizedData,
+                                       { default: isDefault }
+                               )
+                       ] );
+
+                       if ( isDefault ) {
+                               model.default = id;
+                       }
                }
-
-               // Add item
-               this.addItems( [
-                       new SavedQueryItemModel(
-                               randomID,
-                               label,
-                               normalizedData,
-                               { default: isDefault }
-                       )
-               ] );
-
-               if ( isDefault ) {
-                       this.setDefault( randomID );
+       } );
+
+       this.emit( 'initialize' );
+};
+
+/**
+ * Clean up highlight parameters.
+ * 'highlight' used to be stored, it's not inferred based on the presence of absence of
+ * filter colors.
+ *
+ * @param {Object} data Saved query data
+ */
+SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
+       if (
+               data.params.highlight === '0' &&
+               data.highlights && Object.keys( data.highlights ).length
+       ) {
+               data.highlights = {};
+       }
+       delete data.params.highlight;
+};
+
+/**
+ * Convert from representation of filters to representation of parameters
+ *
+ * @param {Object} data Query data
+ * @return {Object} New converted query data
+ */
+SavedQueriesModel.prototype.convertToParameters = function ( data ) {
+       var newData = {},
+               defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
+               fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
+               highlightEnabled = data.highlights.highlight;
+
+       delete data.highlights.highlight;
+
+       // Filters
+       newData.params = this.filtersModel.getMinimizedParamRepresentation(
+               this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
+       );
+
+       // Highlights: appending _color to keys
+       newData.highlights = {};
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( data.highlights, function ( highlightedFilterName, value ) {
+               if ( value ) {
+                       newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
                }
-
-               return randomID;
-       };
-
-       /**
-        * Remove query from model
-        *
-        * @param {string} queryID Query ID
-        */
-       SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
-               var query = this.getItemByID( queryID );
-
-               if ( query ) {
-                       // Check if this item was the default
-                       if ( String( this.getDefault() ) === String( queryID ) ) {
-                               // Nulify the default
-                               this.setDefault( null );
-                       }
-
-                       this.removeItems( [ query ] );
+       } );
+
+       // Add highlight
+       newData.params.highlight = String( Number( highlightEnabled || 0 ) );
+
+       return newData;
+};
+
+/**
+ * Add a query item
+ *
+ * @param {string} label Label for the new query
+ * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
+ * @param {boolean} isDefault Item is default
+ * @param {string} [id] Query ID, if exists. If this isn't given, a random
+ *  new ID will be created.
+ * @return {string} ID of the newly added query
+ */
+SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
+       var normalizedData = { params: {}, highlights: {} },
+               highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
+               randomID = String( id || ( new Date() ).getTime() ),
+               data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
+
+       // Split highlight/params
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( data, function ( param, value ) {
+               if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
+                       normalizedData.highlights[ param ] = value;
+               } else {
+                       normalizedData.params[ param ] = value;
                }
-       };
-
-       /**
-        * Get an item that matches the requested query
-        *
-        * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
-        * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
-        */
-       SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
-               // Minimize before comparison
-               fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
-
-               // Correct the invert state for effective selection
-               if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
-                       delete fullQueryComparison.invert;
+       } );
+
+       // Correct the invert state for effective selection
+       if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
+               delete normalizedData.params.invert;
+       }
+
+       // Add item
+       this.addItems( [
+               new SavedQueryItemModel(
+                       randomID,
+                       label,
+                       normalizedData,
+                       { default: isDefault }
+               )
+       ] );
+
+       if ( isDefault ) {
+               this.setDefault( randomID );
+       }
+
+       return randomID;
+};
+
+/**
+ * Remove query from model
+ *
+ * @param {string} queryID Query ID
+ */
+SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
+       var query = this.getItemByID( queryID );
+
+       if ( query ) {
+               // Check if this item was the default
+               if ( String( this.getDefault() ) === String( queryID ) ) {
+                       // Nulify the default
+                       this.setDefault( null );
                }
 
-               return this.getItems().filter( function ( item ) {
-                       return OO.compare(
-                               item.getCombinedData(),
-                               fullQueryComparison
-                       );
-               } )[ 0 ];
-       };
-
-       /**
-        * Get query by its identifier
-        *
-        * @param {string} queryID Query identifier
-        * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
-        *  the search. Undefined if not found.
-        */
-       SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
-               return this.getItems().filter( function ( item ) {
-                       return item.getID() === queryID;
-               } )[ 0 ];
-       };
-
-       /**
-        * Get the full data representation of the default query, if it exists
-        *
-        * @return {Object|null} Representation of the default params if exists.
-        *  Null if default doesn't exist or if the user is not logged in.
-        */
-       SavedQueriesModel.prototype.getDefaultParams = function () {
-               return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
-       };
-
-       /**
-        * Get a full parameter representation of an item data
-        *
-        * @param  {Object} queryID Query ID
-        * @return {Object} Parameter representation
-        */
-       SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
-               var item = this.getItemByID( queryID ),
-                       data = item ? item.getData() : {};
-
-               return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
-       };
-
-       /**
-        * Build a full parameter representation given item data and model sticky values state
-        *
-        * @param  {Object} data Item data
-        * @return {Object} Full param representation
-        */
-       SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
-               data = data || {};
-               // Return parameter representation
-               return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
-                       data.params,
-                       data.highlights
-               ) );
-       };
-
-       /**
-        * Get the object representing the state of the entire model and items
-        *
-        * @return {Object} Object representing the state of the model and items
-        */
-       SavedQueriesModel.prototype.getState = function () {
-               var obj = { queries: {}, version: '2' };
-
-               // Translate the items to the saved object
+               this.removeItems( [ query ] );
+       }
+};
+
+/**
+ * Get an item that matches the requested query
+ *
+ * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
+ * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
+ */
+SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
+       // Minimize before comparison
+       fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
+
+       // Correct the invert state for effective selection
+       if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
+               delete fullQueryComparison.invert;
+       }
+
+       return this.getItems().filter( function ( item ) {
+               return OO.compare(
+                       item.getCombinedData(),
+                       fullQueryComparison
+               );
+       } )[ 0 ];
+};
+
+/**
+ * Get query by its identifier
+ *
+ * @param {string} queryID Query identifier
+ * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
+ *  the search. Undefined if not found.
+ */
+SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
+       return this.getItems().filter( function ( item ) {
+               return item.getID() === queryID;
+       } )[ 0 ];
+};
+
+/**
+ * Get the full data representation of the default query, if it exists
+ *
+ * @return {Object|null} Representation of the default params if exists.
+ *  Null if default doesn't exist or if the user is not logged in.
+ */
+SavedQueriesModel.prototype.getDefaultParams = function () {
+       return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
+};
+
+/**
+ * Get a full parameter representation of an item data
+ *
+ * @param  {Object} queryID Query ID
+ * @return {Object} Parameter representation
+ */
+SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
+       var item = this.getItemByID( queryID ),
+               data = item ? item.getData() : {};
+
+       return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
+};
+
+/**
+ * Build a full parameter representation given item data and model sticky values state
+ *
+ * @param  {Object} data Item data
+ * @return {Object} Full param representation
+ */
+SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
+       data = data || {};
+       // Return parameter representation
+       return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
+               data.params,
+               data.highlights
+       ) );
+};
+
+/**
+ * Get the object representing the state of the entire model and items
+ *
+ * @return {Object} Object representing the state of the model and items
+ */
+SavedQueriesModel.prototype.getState = function () {
+       var obj = { queries: {}, version: '2' };
+
+       // Translate the items to the saved object
+       this.getItems().forEach( function ( item ) {
+               obj.queries[ item.getID() ] = item.getState();
+       } );
+
+       if ( this.getDefault() ) {
+               obj.default = this.getDefault();
+       }
+
+       return obj;
+};
+
+/**
+ * Set a default query. Null to unset default.
+ *
+ * @param {string} itemID Query identifier
+ * @fires default
+ */
+SavedQueriesModel.prototype.setDefault = function ( itemID ) {
+       if ( this.default !== itemID ) {
+               this.default = itemID;
+
+               // Set for individual itens
                this.getItems().forEach( function ( item ) {
-                       obj.queries[ item.getID() ] = item.getState();
+                       item.toggleDefault( item.getID() === itemID );
                } );
 
-               if ( this.getDefault() ) {
-                       obj.default = this.getDefault();
-               }
-
-               return obj;
-       };
-
-       /**
-        * Set a default query. Null to unset default.
-        *
-        * @param {string} itemID Query identifier
-        * @fires default
-        */
-       SavedQueriesModel.prototype.setDefault = function ( itemID ) {
-               if ( this.default !== itemID ) {
-                       this.default = itemID;
-
-                       // Set for individual itens
-                       this.getItems().forEach( function ( item ) {
-                               item.toggleDefault( item.getID() === itemID );
-                       } );
-
-                       this.emit( 'default', itemID );
-               }
-       };
-
-       /**
-        * Get the default query ID
-        *
-        * @return {string} Default query identifier
-        */
-       SavedQueriesModel.prototype.getDefault = function () {
-               return this.default;
-       };
-
-       /**
-        * Check if the saved queries were converted
-        *
-        * @return {boolean} Saved queries were converted from the previous
-        *  version to the new version
-        */
-       SavedQueriesModel.prototype.isConverted = function () {
-               return this.converted;
-       };
-
-       module.exports = SavedQueriesModel;
-}() );
+               this.emit( 'default', itemID );
+       }
+};
+
+/**
+ * Get the default query ID
+ *
+ * @return {string} Default query identifier
+ */
+SavedQueriesModel.prototype.getDefault = function () {
+       return this.default;
+};
+
+/**
+ * Check if the saved queries were converted
+ *
+ * @return {boolean} Saved queries were converted from the previous
+ *  version to the new version
+ */
+SavedQueriesModel.prototype.isConverted = function () {
+       return this.converted;
+};
+
+module.exports = SavedQueriesModel;
index 1774391..27e93e3 100644 (file)
-( function () {
-       /**
-        * View model for a single saved query
-        *
-        * @class mw.rcfilters.dm.SavedQueryItemModel
-        * @mixins OO.EventEmitter
-        *
-        * @constructor
-        * @param {string} id Unique identifier
-        * @param {string} label Saved query label
-        * @param {Object} data Saved query data
-        * @param {Object} [config] Configuration options
-        * @cfg {boolean} [default] This item is the default
-        */
-       var SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               this.id = id;
-               this.label = label;
-               this.data = data;
-               this.default = !!config.default;
+/**
+ * View model for a single saved query
+ *
+ * @class mw.rcfilters.dm.SavedQueryItemModel
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {string} id Unique identifier
+ * @param {string} label Saved query label
+ * @param {Object} data Saved query data
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [default] This item is the default
+ */
+var SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
+       config = config || {};
+
+       // Mixin constructor
+       OO.EventEmitter.call( this );
+
+       this.id = id;
+       this.label = label;
+       this.data = data;
+       this.default = !!config.default;
+};
+
+/* Initialization */
+
+OO.initClass( SavedQueryItemModel );
+OO.mixinClass( SavedQueryItemModel, OO.EventEmitter );
+
+/* Events */
+
+/**
+ * @event update
+ *
+ * Model has been updated
+ */
+
+/* Methods */
+
+/**
+ * Get an object representing the state of this item
+ *
+ * @return {Object} Object representing the current data state
+ *  of the object
+ */
+SavedQueryItemModel.prototype.getState = function () {
+       return {
+               data: this.getData(),
+               label: this.getLabel()
        };
-
-       /* Initialization */
-
-       OO.initClass( SavedQueryItemModel );
-       OO.mixinClass( SavedQueryItemModel, OO.EventEmitter );
-
-       /* Events */
-
-       /**
-        * @event update
-        *
-        * Model has been updated
-        */
-
-       /* Methods */
-
-       /**
-        * Get an object representing the state of this item
-        *
-        * @return {Object} Object representing the current data state
-        *  of the object
-        */
-       SavedQueryItemModel.prototype.getState = function () {
-               return {
-                       data: this.getData(),
-                       label: this.getLabel()
-               };
-       };
-
-       /**
-        * Get the query's identifier
-        *
-        * @return {string} Query identifier
-        */
-       SavedQueryItemModel.prototype.getID = function () {
-               return this.id;
-       };
-
-       /**
-        * Get query label
-        *
-        * @return {string} Query label
-        */
-       SavedQueryItemModel.prototype.getLabel = function () {
-               return this.label;
-       };
-
-       /**
-        * Update the query label
-        *
-        * @param {string} newLabel New label
-        */
-       SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
-               if ( newLabel && this.label !== newLabel ) {
-                       this.label = newLabel;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Get query data
-        *
-        * @return {Object} Object representing parameter and highlight data
-        */
-       SavedQueryItemModel.prototype.getData = function () {
-               return this.data;
-       };
-
-       /**
-        * Get the combined data of this item as a flat object of parameters
-        *
-        * @return {Object} Combined parameter data
-        */
-       SavedQueryItemModel.prototype.getCombinedData = function () {
-               return $.extend( true, {}, this.data.params, this.data.highlights );
-       };
-
-       /**
-        * Check whether this item is the default
-        *
-        * @return {boolean} Query is set to be default
-        */
-       SavedQueryItemModel.prototype.isDefault = function () {
-               return this.default;
-       };
-
-       /**
-        * Toggle the default state of this query item
-        *
-        * @param {boolean} isDefault Query is default
-        */
-       SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
-               isDefault = isDefault === undefined ? !this.default : isDefault;
-
-               if ( this.default !== isDefault ) {
-                       this.default = isDefault;
-                       this.emit( 'update' );
-               }
-       };
-
-       module.exports = SavedQueryItemModel;
-}() );
+};
+
+/**
+ * Get the query's identifier
+ *
+ * @return {string} Query identifier
+ */
+SavedQueryItemModel.prototype.getID = function () {
+       return this.id;
+};
+
+/**
+ * Get query label
+ *
+ * @return {string} Query label
+ */
+SavedQueryItemModel.prototype.getLabel = function () {
+       return this.label;
+};
+
+/**
+ * Update the query label
+ *
+ * @param {string} newLabel New label
+ */
+SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
+       if ( newLabel && this.label !== newLabel ) {
+               this.label = newLabel;
+               this.emit( 'update' );
+       }
+};
+
+/**
+ * Get query data
+ *
+ * @return {Object} Object representing parameter and highlight data
+ */
+SavedQueryItemModel.prototype.getData = function () {
+       return this.data;
+};
+
+/**
+ * Get the combined data of this item as a flat object of parameters
+ *
+ * @return {Object} Combined parameter data
+ */
+SavedQueryItemModel.prototype.getCombinedData = function () {
+       return $.extend( true, {}, this.data.params, this.data.highlights );
+};
+
+/**
+ * Check whether this item is the default
+ *
+ * @return {boolean} Query is set to be default
+ */
+SavedQueryItemModel.prototype.isDefault = function () {
+       return this.default;
+};
+
+/**
+ * Toggle the default state of this query item
+ *
+ * @param {boolean} isDefault Query is default
+ */
+SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
+       isDefault = isDefault === undefined ? !this.default : isDefault;
+
+       if ( this.default !== isDefault ) {
+               this.default = isDefault;
+               this.emit( 'update' );
+       }
+};
+
+module.exports = SavedQueryItemModel;
index a69dc55..4e5e0fe 100644 (file)
 /*!
  * JavaScript for Special:RecentChanges
  */
-( function () {
-
-       mw.rcfilters.HighlightColors = require( './HighlightColors.js' );
-       mw.rcfilters.ui.MainWrapperWidget = require( './ui/MainWrapperWidget.js' );
-
-       /**
-        * Get list of namespaces and remove unused ones
-        *
-        * @member mw.rcfilters
-        * @private
-        *
-        * @param {Array} unusedNamespaces Names of namespaces to remove
-        * @return {Array} Filtered array of namespaces
-        */
-       function getNamespaces( unusedNamespaces ) {
-               var i, length, name, id,
-                       namespaceIds = mw.config.get( 'wgNamespaceIds' ),
-                       namespaces = mw.config.get( 'wgFormattedNamespaces' );
-
-               for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
-                       name = unusedNamespaces[ i ];
-                       id = namespaceIds[ name.toLowerCase() ];
-                       delete namespaces[ id ];
-               }
-
-               return namespaces;
+mw.rcfilters.HighlightColors = require( './HighlightColors.js' );
+mw.rcfilters.ui.MainWrapperWidget = require( './ui/MainWrapperWidget.js' );
+
+/**
+ * Get list of namespaces and remove unused ones
+ *
+ * @member mw.rcfilters
+ * @private
+ *
+ * @param {Array} unusedNamespaces Names of namespaces to remove
+ * @return {Array} Filtered array of namespaces
+ */
+function getNamespaces( unusedNamespaces ) {
+       var i, length, name, id,
+               namespaceIds = mw.config.get( 'wgNamespaceIds' ),
+               namespaces = mw.config.get( 'wgFormattedNamespaces' );
+
+       for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
+               name = unusedNamespaces[ i ];
+               id = namespaceIds[ name.toLowerCase() ];
+               delete namespaces[ id ];
        }
 
-       /**
-        * @member mw.rcfilters
-        * @private
-        */
-       function init() {
-               var $topSection,
-                       mainWrapperWidget,
-                       conditionalViews = {},
-                       $initialFieldset = $( 'fieldset.cloptions' ),
-                       savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
-                       daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
-                       limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
-                       activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
-                       initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
-                       filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
-                       changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
-                       savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
-                       specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
-                       controller = new mw.rcfilters.Controller(
-                               filtersModel, changesListModel, savedQueriesModel,
-                               {
-                                       savedQueriesPreferenceName: savedQueriesPreferenceName,
-                                       daysPreferenceName: daysPreferenceName,
-                                       limitPreferenceName: limitPreferenceName,
-                                       collapsedPreferenceName: activeFiltersCollapsedName,
-                                       normalizeTarget: specialPage === 'Recentchangeslinked'
-                               }
-                       );
-
-               // TODO: The changesListWrapperWidget should be able to initialize
-               // after the model is ready.
-
-               if ( specialPage === 'Recentchanges' ) {
-                       $topSection = $( '.mw-recentchanges-toplinks' ).detach();
-               } else if ( specialPage === 'Watchlist' ) {
-                       $( '#contentSub, form#mw-watchlist-resetbutton' ).remove();
-                       $topSection = $( '.watchlistDetails' ).detach().contents();
-               } else if ( specialPage === 'Recentchangeslinked' ) {
-                       conditionalViews.recentChangesLinked = {
-                               groups: [
-                                       {
-                                               name: 'page',
-                                               type: 'any_value',
-                                               title: '',
-                                               hidden: true,
-                                               sticky: true,
-                                               filters: [
-                                                       {
-                                                               name: 'target',
-                                                               default: ''
-                                                       }
-                                               ]
-                                       },
-                                       {
-                                               name: 'toOrFrom',
-                                               type: 'boolean',
-                                               title: '',
-                                               hidden: true,
-                                               sticky: true,
-                                               filters: [
-                                                       {
-                                                               name: 'showlinkedto',
-                                                               default: false
-                                                       }
-                                               ]
-                                       }
-                               ]
-                       };
-               }
+       return namespaces;
+}
 
-               mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
-                       controller,
-                       filtersModel,
-                       savedQueriesModel,
-                       changesListModel,
+/**
+ * @member mw.rcfilters
+ * @private
+ */
+function init() {
+       var $topSection,
+               mainWrapperWidget,
+               conditionalViews = {},
+               $initialFieldset = $( 'fieldset.cloptions' ),
+               savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
+               daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
+               limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
+               activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
+               initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
+               filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+               changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
+               savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
+               specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
+               controller = new mw.rcfilters.Controller(
+                       filtersModel, changesListModel, savedQueriesModel,
                        {
-                               $wrapper: $( 'body' ),
-                               $topSection: $topSection,
-                               $filtersContainer: $( '.rcfilters-container' ),
-                               $changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
-                               $formContainer: $initialFieldset,
-                               collapsed: initialCollapsedState
+                               savedQueriesPreferenceName: savedQueriesPreferenceName,
+                               daysPreferenceName: daysPreferenceName,
+                               limitPreferenceName: limitPreferenceName,
+                               collapsedPreferenceName: activeFiltersCollapsedName,
+                               normalizeTarget: specialPage === 'Recentchangeslinked'
                        }
                );
 
-               // Remove the -loading class that may have been added on the server side.
-               // If we are in fact going to load a default saved query, this .initialize()
-               // call will do that and add the -loading class right back.
-               $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
-
-               controller.initialize(
-                       mw.config.get( 'wgStructuredChangeFilters' ),
-                       // All namespaces without Media namespace
-                       getNamespaces( [ 'Media' ] ),
-                       require( './config.json' ).RCFiltersChangeTags,
-                       conditionalViews
-               );
+       // TODO: The changesListWrapperWidget should be able to initialize
+       // after the model is ready.
+
+       if ( specialPage === 'Recentchanges' ) {
+               $topSection = $( '.mw-recentchanges-toplinks' ).detach();
+       } else if ( specialPage === 'Watchlist' ) {
+               $( '#contentSub, form#mw-watchlist-resetbutton' ).remove();
+               $topSection = $( '.watchlistDetails' ).detach().contents();
+       } else if ( specialPage === 'Recentchangeslinked' ) {
+               conditionalViews.recentChangesLinked = {
+                       groups: [
+                               {
+                                       name: 'page',
+                                       type: 'any_value',
+                                       title: '',
+                                       hidden: true,
+                                       sticky: true,
+                                       filters: [
+                                               {
+                                                       name: 'target',
+                                                       default: ''
+                                               }
+                                       ]
+                               },
+                               {
+                                       name: 'toOrFrom',
+                                       type: 'boolean',
+                                       title: '',
+                                       hidden: true,
+                                       sticky: true,
+                                       filters: [
+                                               {
+                                                       name: 'showlinkedto',
+                                                       default: false
+                                               }
+                                       ]
+                               }
+                       ]
+               };
+       }
 
-               mainWrapperWidget.initFormWidget( specialPage );
+       mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
+               controller,
+               filtersModel,
+               savedQueriesModel,
+               changesListModel,
+               {
+                       $wrapper: $( 'body' ),
+                       $topSection: $topSection,
+                       $filtersContainer: $( '.rcfilters-container' ),
+                       $changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
+                       $formContainer: $initialFieldset,
+                       collapsed: initialCollapsedState
+               }
+       );
 
-               $( 'a.mw-helplink' ).attr(
-                       'href',
-                       'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
-               );
+       // Remove the -loading class that may have been added on the server side.
+       // If we are in fact going to load a default saved query, this .initialize()
+       // call will do that and add the -loading class right back.
+       $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
 
-               controller.replaceUrl();
+       controller.initialize(
+               mw.config.get( 'wgStructuredChangeFilters' ),
+               // All namespaces without Media namespace
+               getNamespaces( [ 'Media' ] ),
+               require( './config.json' ).RCFiltersChangeTags,
+               conditionalViews
+       );
 
-               mainWrapperWidget.setTopSection( specialPage );
+       mainWrapperWidget.initFormWidget( specialPage );
 
-               /**
-                * Fired when initialization of the filtering interface for changes list is complete.
-                *
-                * @event structuredChangeFilters_ui_initialized
-                * @member mw.hook
-                */
-               mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
-       }
+       $( 'a.mw-helplink' ).attr(
+               'href',
+               'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
+       );
 
-       // Import i18n messages from config
-       mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) );
+       controller.replaceUrl();
 
-       // Early execute of init
-       if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
-               init();
-       } else {
-               $( init );
-       }
+       mainWrapperWidget.setTopSection( specialPage );
+
+       /**
+        * Fired when initialization of the filtering interface for changes list is complete.
+        *
+        * @event structuredChangeFilters_ui_initialized
+        * @member mw.hook
+        */
+       mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
+}
+
+// Import i18n messages from config
+mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) );
 
-       module.exports = mw.rcfilters;
+// Early execute of init
+if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
+       init();
+} else {
+       $( init );
+}
 
-}() );
+module.exports = mw.rcfilters;
index b32fb38..5bf9916 100644 (file)
@@ -1,61 +1,59 @@
-( function () {
-       /**
-        * @class
-        * @singleton
-        */
-       mw.rcfilters = {
-               Controller: require( './Controller.js' ),
-               UriProcessor: require( './UriProcessor.js' ),
-               dm: {
-                       ChangesListViewModel: require( './dm/ChangesListViewModel.js' ),
-                       FilterGroup: require( './dm/FilterGroup.js' ),
-                       FilterItem: require( './dm/FilterItem.js' ),
-                       FiltersViewModel: require( './dm/FiltersViewModel.js' ),
-                       ItemModel: require( './dm/ItemModel.js' ),
-                       SavedQueriesModel: require( './dm/SavedQueriesModel.js' ),
-                       SavedQueryItemModel: require( './dm/SavedQueryItemModel.js' )
-               },
-               ui: {},
-               utils: {
-                       addArrayElementsUnique: function ( arr, elements ) {
-                               elements = Array.isArray( elements ) ? elements : [ elements ];
-
-                               elements.forEach( function ( element ) {
-                                       if ( arr.indexOf( element ) === -1 ) {
-                                               arr.push( element );
-                                       }
-                               } );
+/**
+ * @class
+ * @singleton
+ */
+mw.rcfilters = {
+       Controller: require( './Controller.js' ),
+       UriProcessor: require( './UriProcessor.js' ),
+       dm: {
+               ChangesListViewModel: require( './dm/ChangesListViewModel.js' ),
+               FilterGroup: require( './dm/FilterGroup.js' ),
+               FilterItem: require( './dm/FilterItem.js' ),
+               FiltersViewModel: require( './dm/FiltersViewModel.js' ),
+               ItemModel: require( './dm/ItemModel.js' ),
+               SavedQueriesModel: require( './dm/SavedQueriesModel.js' ),
+               SavedQueryItemModel: require( './dm/SavedQueryItemModel.js' )
+       },
+       ui: {},
+       utils: {
+               addArrayElementsUnique: function ( arr, elements ) {
+                       elements = Array.isArray( elements ) ? elements : [ elements ];
 
-                               return arr;
-                       },
-                       normalizeParamOptions: function ( givenOptions, legalOptions ) {
-                               var result = [];
-
-                               if ( givenOptions.indexOf( 'all' ) > -1 ) {
-                                       // If anywhere in the values there's 'all', we
-                                       // treat it as if only 'all' was selected.
-                                       // Example: param=valid1,valid2,all
-                                       // Result: param=all
-                                       return [ 'all' ];
+                       elements.forEach( function ( element ) {
+                               if ( arr.indexOf( element ) === -1 ) {
+                                       arr.push( element );
                                }
+                       } );
 
-                               // Get rid of any dupe and invalid parameter, only output
-                               // valid ones
-                               // Example: param=valid1,valid2,invalid1,valid1
-                               // Result: param=valid1,valid2
-                               givenOptions.forEach( function ( value ) {
-                                       if (
-                                               legalOptions.indexOf( value ) > -1 &&
-                                               result.indexOf( value ) === -1
-                                       ) {
-                                               result.push( value );
-                                       }
-                               } );
+                       return arr;
+               },
+               normalizeParamOptions: function ( givenOptions, legalOptions ) {
+                       var result = [];
 
-                               return result;
+                       if ( givenOptions.indexOf( 'all' ) > -1 ) {
+                               // If anywhere in the values there's 'all', we
+                               // treat it as if only 'all' was selected.
+                               // Example: param=valid1,valid2,all
+                               // Result: param=all
+                               return [ 'all' ];
                        }
+
+                       // Get rid of any dupe and invalid parameter, only output
+                       // valid ones
+                       // Example: param=valid1,valid2,invalid1,valid1
+                       // Result: param=valid1,valid2
+                       givenOptions.forEach( function ( value ) {
+                               if (
+                                       legalOptions.indexOf( value ) > -1 &&
+                                       result.indexOf( value ) === -1
+                               ) {
+                                       result.push( value );
+                               }
+                       } );
+
+                       return result;
                }
-       };
+       }
+};
 
-       module.exports = mw.rcfilters;
-}() );
+module.exports = mw.rcfilters;
index 23b05e8..4764bd8 100644 (file)
-( function () {
-       var ChangesLimitPopupWidget = require( './ChangesLimitPopupWidget.js' ),
-               DatePopupWidget = require( './DatePopupWidget.js' ),
-               ChangesLimitAndDateButtonWidget;
-
-       /**
-        * Widget defining the button controlling the popup for the number of results
-        *
-        * @class mw.rcfilters.ui.ChangesLimitAndDateButtonWidget
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
-               config = config || {};
-
-               // Parent
-               ChangesLimitAndDateButtonWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-
-               this.$overlay = config.$overlay || this.$element;
-
-               this.button = null;
-               this.limitGroupModel = null;
-               this.groupByPageItemModel = null;
-               this.daysGroupModel = null;
-
-               this.model.connect( this, {
-                       initialize: 'onModelInitialize'
+var ChangesLimitPopupWidget = require( './ChangesLimitPopupWidget.js' ),
+       DatePopupWidget = require( './DatePopupWidget.js' ),
+       ChangesLimitAndDateButtonWidget;
+
+/**
+ * Widget defining the button controlling the popup for the number of results
+ *
+ * @class mw.rcfilters.ui.ChangesLimitAndDateButtonWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} [config] Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
+       config = config || {};
+
+       // Parent
+       ChangesLimitAndDateButtonWidget.parent.call( this, config );
+
+       this.controller = controller;
+       this.model = model;
+
+       this.$overlay = config.$overlay || this.$element;
+
+       this.button = null;
+       this.limitGroupModel = null;
+       this.groupByPageItemModel = null;
+       this.daysGroupModel = null;
+
+       this.model.connect( this, {
+               initialize: 'onModelInitialize'
+       } );
+
+       this.$element
+               .addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
+};
+
+/* Initialization */
+
+OO.inheritClass( ChangesLimitAndDateButtonWidget, OO.ui.Widget );
+
+/**
+ * Respond to model initialize event
+ */
+ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
+       var changesLimitPopupWidget, selectedItem, currentValue, datePopupWidget,
+               displayGroupModel = this.model.getGroup( 'display' );
+
+       this.limitGroupModel = this.model.getGroup( 'limit' );
+       this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
+       this.daysGroupModel = this.model.getGroup( 'days' );
+
+       // HACK: We need the model to be ready before we populate the button
+       // and the widget, because we require the filter items for the
+       // limit and their events. This addition is only done after the
+       // model is initialized.
+       // Note: This will be fixed soon!
+       if ( this.limitGroupModel && this.daysGroupModel ) {
+               changesLimitPopupWidget = new ChangesLimitPopupWidget(
+                       this.limitGroupModel,
+                       this.groupByPageItemModel
+               );
+
+               datePopupWidget = new DatePopupWidget(
+                       this.daysGroupModel,
+                       {
+                               label: mw.msg( 'rcfilters-date-popup-title' )
+                       }
+               );
+
+               selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
+               currentValue = ( selectedItem && selectedItem.getLabel() ) ||
+                       mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );
+
+               this.button = new OO.ui.PopupButtonWidget( {
+                       icon: 'settings',
+                       indicator: 'down',
+                       label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
+                       $overlay: this.$overlay,
+                       popup: {
+                               width: 300,
+                               padded: false,
+                               anchor: false,
+                               align: 'backwards',
+                               $autoCloseIgnore: this.$overlay,
+                               $content: $( '<div>' ).append(
+                                       // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
+                                       changesLimitPopupWidget.$element,
+                                       datePopupWidget.$element
+                               )
+                       }
                } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( ChangesLimitAndDateButtonWidget, OO.ui.Widget );
-
-       /**
-        * Respond to model initialize event
-        */
-       ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
-               var changesLimitPopupWidget, selectedItem, currentValue, datePopupWidget,
-                       displayGroupModel = this.model.getGroup( 'display' );
-
-               this.limitGroupModel = this.model.getGroup( 'limit' );
-               this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
-               this.daysGroupModel = this.model.getGroup( 'days' );
-
-               // HACK: We need the model to be ready before we populate the button
-               // and the widget, because we require the filter items for the
-               // limit and their events. This addition is only done after the
-               // model is initialized.
-               // Note: This will be fixed soon!
-               if ( this.limitGroupModel && this.daysGroupModel ) {
-                       changesLimitPopupWidget = new ChangesLimitPopupWidget(
-                               this.limitGroupModel,
-                               this.groupByPageItemModel
-                       );
-
-                       datePopupWidget = new DatePopupWidget(
-                               this.daysGroupModel,
-                               {
-                                       label: mw.msg( 'rcfilters-date-popup-title' )
-                               }
-                       );
-
-                       selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
-                       currentValue = ( selectedItem && selectedItem.getLabel() ) ||
-                               mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );
-
-                       this.button = new OO.ui.PopupButtonWidget( {
-                               icon: 'settings',
-                               indicator: 'down',
-                               label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
-                               $overlay: this.$overlay,
-                               popup: {
-                                       width: 300,
-                                       padded: false,
-                                       anchor: false,
-                                       align: 'backwards',
-                                       $autoCloseIgnore: this.$overlay,
-                                       $content: $( '<div>' ).append(
-                                               // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
-                                               changesLimitPopupWidget.$element,
-                                               datePopupWidget.$element
-                                       )
-                               }
-                       } );
-                       this.updateButtonLabel();
-
-                       // Events
-                       this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
-                       this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
-                       changesLimitPopupWidget.connect( this, {
-                               limit: 'onPopupLimit',
-                               groupByPage: 'onPopupGroupByPage'
-                       } );
-                       datePopupWidget.connect( this, { days: 'onPopupDays' } );
-
-                       this.$element.append( this.button.$element );
-               }
-       };
-
-       /**
-        * Respond to popup limit change event
-        *
-        * @param {string} filterName Chosen filter name
-        */
-       ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
-               var item = this.limitGroupModel.getItemByName( filterName );
-
-               this.controller.toggleFilterSelect( filterName, true );
-               this.controller.updateLimitDefault( item.getParamName() );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to popup limit change event
-        *
-        * @param {boolean} isGrouped The result set is grouped by page
-        */
-       ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
-               this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
-               this.controller.updateGroupByPageDefault( isGrouped );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to popup limit change event
-        *
-        * @param {string} filterName Chosen filter name
-        */
-       ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
-               var item = this.daysGroupModel.getItemByName( filterName );
-
-               this.controller.toggleFilterSelect( filterName, true );
-               this.controller.updateDaysDefault( item.getParamName() );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to limit choose event
-        *
-        * @param {string} filterName Filter name
-        */
-       ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
-               var message,
-                       limit = this.limitGroupModel.findSelectedItems()[ 0 ],
-                       label = limit && limit.getLabel(),
-                       days = this.daysGroupModel.findSelectedItems()[ 0 ],
-                       daysParamName = Number( days.getParamName() ) < 1 ?
-                               'rcfilters-days-show-hours' :
-                               'rcfilters-days-show-days';
-
-               // Update the label
-               if ( label && days ) {
-                       message = mw.msg( 'rcfilters-limit-and-date-label', label,
-                               mw.msg( daysParamName, days.getLabel() )
-                       );
-                       this.button.setLabel( message );
-               }
-       };
-
-       module.exports = ChangesLimitAndDateButtonWidget;
-
-}() );
+               this.updateButtonLabel();
+
+               // Events
+               this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
+               this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
+               changesLimitPopupWidget.connect( this, {
+                       limit: 'onPopupLimit',
+                       groupByPage: 'onPopupGroupByPage'
+               } );
+               datePopupWidget.connect( this, { days: 'onPopupDays' } );
+
+               this.$element.append( this.button.$element );
+       }
+};
+
+/**
+ * Respond to popup limit change event
+ *
+ * @param {string} filterName Chosen filter name
+ */
+ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
+       var item = this.limitGroupModel.getItemByName( filterName );
+
+       this.controller.toggleFilterSelect( filterName, true );
+       this.controller.updateLimitDefault( item.getParamName() );
+       this.button.popup.toggle( false );
+};
+
+/**
+ * Respond to popup limit change event
+ *
+ * @param {boolean} isGrouped The result set is grouped by page
+ */
+ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
+       this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
+       this.controller.updateGroupByPageDefault( isGrouped );
+       this.button.popup.toggle( false );
+};
+
+/**
+ * Respond to popup limit change event
+ *
+ * @param {string} filterName Chosen filter name
+ */
+ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
+       var item = this.daysGroupModel.getItemByName( filterName );
+
+       this.controller.toggleFilterSelect( filterName, true );
+       this.controller.updateDaysDefault( item.getParamName() );
+       this.button.popup.toggle( false );
+};
+
+/**
+ * Respond to limit choose event
+ *
+ * @param {string} filterName Filter name
+ */
+ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
+       var message,
+               limit = this.limitGroupModel.findSelectedItems()[ 0 ],
+               label = limit && limit.getLabel(),
+               days = this.daysGroupModel.findSelectedItems()[ 0 ],
+               daysParamName = Number( days.getParamName() ) < 1 ?
+                       'rcfilters-days-show-hours' :
+                       'rcfilters-days-show-days';
+
+       // Update the label
+       if ( label && days ) {
+               message = mw.msg( 'rcfilters-limit-and-date-label', label,
+                       mw.msg( daysParamName, days.getLabel() )
+               );
+               this.button.setLabel( message );
+       }
+};
+
+module.exports = ChangesLimitAndDateButtonWidget;
index d78c42b..a0c0d80 100644 (file)
@@ -1,84 +1,82 @@
-( function () {
-       var ValuePickerWidget = require( './ValuePickerWidget.js' ),
-               ChangesLimitPopupWidget;
+var ValuePickerWidget = require( './ValuePickerWidget.js' ),
+       ChangesLimitPopupWidget;
 
-       /**
       * Widget defining the popup to choose number of results
       *
       * @class mw.rcfilters.ui.ChangesLimitPopupWidget
       * @extends OO.ui.Widget
       *
       * @constructor
       * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
       * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
       * @param {Object} [config] Configuration object
       */
-       ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
-               config = config || {};
+/**
+ * Widget defining the popup to choose number of results
+ *
+ * @class mw.rcfilters.ui.ChangesLimitPopupWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
+ * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
+ * @param {Object} [config] Configuration object
+ */
+ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
+       config = config || {};
 
-               // Parent
-               ChangesLimitPopupWidget.parent.call( this, config );
+       // Parent
+       ChangesLimitPopupWidget.parent.call( this, config );
 
-               this.limitModel = limitModel;
-               this.groupByPageItemModel = groupByPageItemModel;
+       this.limitModel = limitModel;
+       this.groupByPageItemModel = groupByPageItemModel;
 
-               this.valuePicker = new ValuePickerWidget(
-                       this.limitModel,
-                       {
-                               label: mw.msg( 'rcfilters-limit-title' )
-                       }
-               );
+       this.valuePicker = new ValuePickerWidget(
+               this.limitModel,
+               {
+                       label: mw.msg( 'rcfilters-limit-title' )
+               }
+       );
 
-               this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
-                       selected: this.groupByPageItemModel.isSelected()
-               } );
+       this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
+               selected: this.groupByPageItemModel.isSelected()
+       } );
 
-               // Events
-               this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
-               this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
-               this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
+       // Events
+       this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
+       this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
+       this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
 
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
-                       .append(
-                               this.valuePicker.$element,
-                               new OO.ui.FieldLayout(
-                                       this.groupByPageCheckbox,
-                                       {
-                                               align: 'inline',
-                                               label: mw.msg( 'rcfilters-group-results-by-page' )
-                                       }
-                               ).$element
-                       );
-       };
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
+               .append(
+                       this.valuePicker.$element,
+                       new OO.ui.FieldLayout(
+                               this.groupByPageCheckbox,
+                               {
+                                       align: 'inline',
+                                       label: mw.msg( 'rcfilters-group-results-by-page' )
+                               }
+                       ).$element
+               );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget );
+OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget );
 
-       /* Events */
+/* Events */
 
-       /**
       * @event limit
       * @param {string} name Item name
       *
       * A limit item was chosen
       */
+/**
+ * @event limit
+ * @param {string} name Item name
+ *
+ * A limit item was chosen
+ */
 
-       /**
       * @event groupByPage
       * @param {boolean} isGrouped The results are grouped by page
       *
       * Results are grouped by page
       */
+/**
+ * @event groupByPage
+ * @param {boolean} isGrouped The results are grouped by page
+ *
+ * Results are grouped by page
+ */
 
-       /**
       * Respond to group by page model update
       */
-       ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
-               this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
-       };
+/**
+ * Respond to group by page model update
+ */
+ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
+       this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
+};
 
-       module.exports = ChangesLimitPopupWidget;
-}() );
+module.exports = ChangesLimitPopupWidget;
index ba7f4d1..09b802e 100644 (file)
-( function () {
-       /**
-        * List of changes
-        *
-        * @class mw.rcfilters.ui.ChangesListWrapperWidget
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
-        * @param {mw.rcfilters.Controller} controller
-        * @param {jQuery} $changesListRoot Root element of the changes list to attach to
-        * @param {Object} [config] Configuration object
-        */
-       var ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
-               filtersViewModel,
-               changesListViewModel,
-               controller,
-               $changesListRoot,
-               config
-       ) {
-               config = $.extend( {}, config, {
-                       $element: $changesListRoot
-               } );
-
-               // Parent
-               ChangesListWrapperWidget.parent.call( this, config );
-
-               this.filtersViewModel = filtersViewModel;
-               this.changesListViewModel = changesListViewModel;
-               this.controller = controller;
-               this.highlightClasses = null;
-
-               // Events
-               this.filtersViewModel.connect( this, {
-                       itemUpdate: 'onItemUpdate',
-                       highlightChange: 'onHighlightChange'
-               } );
-               this.changesListViewModel.connect( this, {
-                       invalidate: 'onModelInvalidate',
-                       update: 'onModelUpdate'
-               } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
-                       // We handle our own display/hide of the empty results message
-                       // We keep the timeout class here and remove it later, since at this
-                       // stage it is still needed to identify that the timeout occurred.
-                       .removeClass( 'mw-changeslist-empty' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( ChangesListWrapperWidget, OO.ui.Widget );
-
-       /**
-        * Get all available highlight classes
-        *
-        * @return {string[]} An array of available highlight class names
-        */
-       ChangesListWrapperWidget.prototype.getHighlightClasses = function () {
-               if ( !this.highlightClasses || !this.highlightClasses.length ) {
-                       this.highlightClasses = this.filtersViewModel.getItemsSupportingHighlights()
-                               .map( function ( filterItem ) {
-                                       return filterItem.getCssClass();
-                               } );
-               }
-
-               return this.highlightClasses;
-       };
-
-       /**
-        * Respond to the highlight feature being toggled on and off
-        *
-        * @param {boolean} highlightEnabled
-        */
-       ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
-               if ( highlightEnabled ) {
-                       this.applyHighlight();
+/**
+ * List of changes
+ *
+ * @class mw.rcfilters.ui.ChangesListWrapperWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
+ * @param {mw.rcfilters.Controller} controller
+ * @param {jQuery} $changesListRoot Root element of the changes list to attach to
+ * @param {Object} [config] Configuration object
+ */
+var ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
+       filtersViewModel,
+       changesListViewModel,
+       controller,
+       $changesListRoot,
+       config
+) {
+       config = $.extend( {}, config, {
+               $element: $changesListRoot
+       } );
+
+       // Parent
+       ChangesListWrapperWidget.parent.call( this, config );
+
+       this.filtersViewModel = filtersViewModel;
+       this.changesListViewModel = changesListViewModel;
+       this.controller = controller;
+       this.highlightClasses = null;
+
+       // Events
+       this.filtersViewModel.connect( this, {
+               itemUpdate: 'onItemUpdate',
+               highlightChange: 'onHighlightChange'
+       } );
+       this.changesListViewModel.connect( this, {
+               invalidate: 'onModelInvalidate',
+               update: 'onModelUpdate'
+       } );
+
+       this.$element
+               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
+               // We handle our own display/hide of the empty results message
+               // We keep the timeout class here and remove it later, since at this
+               // stage it is still needed to identify that the timeout occurred.
+               .removeClass( 'mw-changeslist-empty' );
+};
+
+/* Initialization */
+
+OO.inheritClass( ChangesListWrapperWidget, OO.ui.Widget );
+
+/**
+ * Get all available highlight classes
+ *
+ * @return {string[]} An array of available highlight class names
+ */
+ChangesListWrapperWidget.prototype.getHighlightClasses = function () {
+       if ( !this.highlightClasses || !this.highlightClasses.length ) {
+               this.highlightClasses = this.filtersViewModel.getItemsSupportingHighlights()
+                       .map( function ( filterItem ) {
+                               return filterItem.getCssClass();
+                       } );
+       }
+
+       return this.highlightClasses;
+};
+
+/**
+ * Respond to the highlight feature being toggled on and off
+ *
+ * @param {boolean} highlightEnabled
+ */
+ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
+       if ( highlightEnabled ) {
+               this.applyHighlight();
+       } else {
+               this.clearHighlight();
+       }
+};
+
+/**
+ * Respond to a filter item model update
+ */
+ChangesListWrapperWidget.prototype.onItemUpdate = function () {
+       if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
+               // this.controller.isInitialized() is still false during page load,
+               // we don't want to clear/apply highlights at this stage.
+               this.clearHighlight();
+               this.applyHighlight();
+       }
+};
+
+/**
+ * Respond to changes list model invalidate
+ */
+ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
+       $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
+};
+
+/**
+ * Respond to changes list model update
+ *
+ * @param {jQuery|string} $changesListContent The content of the updated changes list
+ * @param {jQuery} $fieldset The content of the updated fieldset
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
+ * @param {boolean} from Timestamp of the new changes
+ */
+ChangesListWrapperWidget.prototype.onModelUpdate = function (
+       $changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
+) {
+       var conflictItem,
+               $message = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
+               isEmpty = $changesListContent === 'NO_RESULTS',
+               // For enhanced mode, we have to load these modules, which are
+               // not loaded for the 'regular' mode in the backend
+               loaderPromise = mw.user.options.get( 'usenewrc' ) ?
+                       mw.loader.using( [ 'mediawiki.special.changeslist.enhanced', 'mediawiki.icon' ] ) :
+                       $.Deferred().resolve(),
+               widget = this;
+
+       this.$element.toggleClass( 'mw-changeslist', !isEmpty );
+       if ( isEmpty ) {
+               this.$element.empty();
+
+               if ( this.filtersViewModel.hasConflict() ) {
+                       conflictItem = this.filtersViewModel.getFirstConflictedItem();
+
+                       $message
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
+                                               .text( mw.message( 'rcfilters-noresults-conflict' ).text() ),
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
+                                               .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() )
+                               );
                } else {
-                       this.clearHighlight();
-               }
-       };
+                       $message
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
+                                               .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
+                               );
 
-       /**
-        * Respond to a filter item model update
-        */
-       ChangesListWrapperWidget.prototype.onItemUpdate = function () {
-               if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
-                       // this.controller.isInitialized() is still false during page load,
-                       // we don't want to clear/apply highlights at this stage.
-                       this.clearHighlight();
-                       this.applyHighlight();
+                       // remove all classes matching mw-changeslist-*
+                       this.$element.removeClass( function ( elementIndex, allClasses ) {
+                               return allClasses
+                                       .split( ' ' )
+                                       .filter( function ( className ) {
+                                               return className.indexOf( 'mw-changeslist-' ) === 0;
+                                       } )
+                                       .join( ' ' );
+                       );
                }
-       };
 
-       /**
-        * Respond to changes list model invalidate
-        */
-       ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
-               $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
-       };
+               this.$element.append( $message );
+       } else {
+               if ( !isInitialDOM ) {
+                       this.$element.empty().append( $changesListContent );
 
-       /**
-        * Respond to changes list model update
-        *
-        * @param {jQuery|string} $changesListContent The content of the updated changes list
-        * @param {jQuery} $fieldset The content of the updated fieldset
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
-        * @param {boolean} from Timestamp of the new changes
-        */
-       ChangesListWrapperWidget.prototype.onModelUpdate = function (
-               $changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
-       ) {
-               var conflictItem,
-                       $message = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
-                       isEmpty = $changesListContent === 'NO_RESULTS',
-                       // For enhanced mode, we have to load these modules, which are
-                       // not loaded for the 'regular' mode in the backend
-                       loaderPromise = mw.user.options.get( 'usenewrc' ) ?
-                               mw.loader.using( [ 'mediawiki.special.changeslist.enhanced', 'mediawiki.icon' ] ) :
-                               $.Deferred().resolve(),
-                       widget = this;
-
-               this.$element.toggleClass( 'mw-changeslist', !isEmpty );
-               if ( isEmpty ) {
-                       this.$element.empty();
-
-                       if ( this.filtersViewModel.hasConflict() ) {
-                               conflictItem = this.filtersViewModel.getFirstConflictedItem();
-
-                               $message
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
-                                                       .text( mw.message( 'rcfilters-noresults-conflict' ).text() ),
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
-                                                       .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() )
-                                       );
-                       } else {
-                               $message
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
-                                                       .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
-                                       );
-
-                               // remove all classes matching mw-changeslist-*
-                               this.$element.removeClass( function ( elementIndex, allClasses ) {
-                                       return allClasses
-                                               .split( ' ' )
-                                               .filter( function ( className ) {
-                                                       return className.indexOf( 'mw-changeslist-' ) === 0;
-                                               } )
-                                               .join( ' ' );
-                               } );
+                       if ( from ) {
+                               this.emphasizeNewChanges( from );
                        }
-
-                       this.$element.append( $message );
-               } else {
-                       if ( !isInitialDOM ) {
-                               this.$element.empty().append( $changesListContent );
-
-                               if ( from ) {
-                                       this.emphasizeNewChanges( from );
-                               }
-                       }
-
-                       // Apply highlight
-                       this.applyHighlight();
-
                }
 
-               this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
+               // Apply highlight
+               this.applyHighlight();
 
-               loaderPromise.done( function () {
-                       if ( !isInitialDOM && !isEmpty ) {
-                               // Make sure enhanced RC re-initializes correctly
-                               mw.hook( 'wikipage.content' ).fire( widget.$element );
-                       }
+       }
 
-                       $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
-               } );
-       };
+       this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
 
-       /** Toggles overlay class on changes list
-        *
-        * @param {boolean} isVisible True if overlay should be visible
-        */
-       ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
-               this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
-       };
+       loaderPromise.done( function () {
+               if ( !isInitialDOM && !isEmpty ) {
+                       // Make sure enhanced RC re-initializes correctly
+                       mw.hook( 'wikipage.content' ).fire( widget.$element );
+               }
 
-       /**
-        * Map a reason for having no results to its message key
-        *
-        * @param {string} reason One of the NO_RESULTS_* "constant" that represent
-        *   a reason for having no results
-        * @return {string} Key for the message that explains why there is no results in this case
-        */
-       ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
-               var reasonMsgKeyMap = {
-                       NO_RESULTS_NORMAL: 'recentchanges-noresult',
-                       NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
-                       NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
-                       NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
-                       NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
-               };
-               return reasonMsgKeyMap[ reason ];
+               $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
+       } );
+};
+
+/** Toggles overlay class on changes list
+ *
+ * @param {boolean} isVisible True if overlay should be visible
+ */
+ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
+       this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
+};
+
+/**
+ * Map a reason for having no results to its message key
+ *
+ * @param {string} reason One of the NO_RESULTS_* "constant" that represent
+ *   a reason for having no results
+ * @return {string} Key for the message that explains why there is no results in this case
+ */
+ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
+       var reasonMsgKeyMap = {
+               NO_RESULTS_NORMAL: 'recentchanges-noresult',
+               NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
+               NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
+               NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
+               NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
        };
-
-       /**
-        * Emphasize the elements (or groups) newer than the 'from' parameter
-        * @param {string} from Anything newer than this is considered 'new'
-        */
-       ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
-               var $firstNew,
-                       $indicator,
-                       $newChanges = $( [] ),
-                       selector = this.inEnhancedMode() ?
-                               'table.mw-enhanced-rc[data-mw-ts]' :
-                               'li[data-mw-ts]',
-                       set = this.$element.find( selector ),
-                       length = set.length;
-
-               set.each( function ( index ) {
-                       var $this = $( this ),
-                               ts = $this.data( 'mw-ts' );
-
-                       if ( ts >= from ) {
-                               $newChanges = $newChanges.add( $this );
-                               $firstNew = $this;
-
-                               // guards against putting the marker after the last element
-                               if ( index === ( length - 1 ) ) {
-                                       $firstNew = null;
-                               }
+       return reasonMsgKeyMap[ reason ];
+};
+
+/**
+ * Emphasize the elements (or groups) newer than the 'from' parameter
+ * @param {string} from Anything newer than this is considered 'new'
+ */
+ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
+       var $firstNew,
+               $indicator,
+               $newChanges = $( [] ),
+               selector = this.inEnhancedMode() ?
+                       'table.mw-enhanced-rc[data-mw-ts]' :
+                       'li[data-mw-ts]',
+               set = this.$element.find( selector ),
+               length = set.length;
+
+       set.each( function ( index ) {
+               var $this = $( this ),
+                       ts = $this.data( 'mw-ts' );
+
+               if ( ts >= from ) {
+                       $newChanges = $newChanges.add( $this );
+                       $firstNew = $this;
+
+                       // guards against putting the marker after the last element
+                       if ( index === ( length - 1 ) ) {
+                               $firstNew = null;
                        }
-               } );
-
-               if ( $firstNew ) {
-                       $indicator = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
-
-                       $firstNew.after( $indicator );
                }
-
-               // FIXME: Use CSS transition
-               // eslint-disable-next-line no-jquery/no-fade
-               $newChanges
-                       .hide()
-                       .fadeIn( 1000 );
-       };
-
-       /**
-        * In enhanced mode, we need to check whether the grouped results all have the
-        * same active highlights in order to see whether the "parent" of the group should
-        * be grey or highlighted normally.
-        *
-        * This is called every time highlights are applied.
-        */
-       ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
-               var activeHighlightClasses,
-                       $enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc.mw-collapsible' );
-
-               activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
-                       return 'mw-rcfilters-highlight-color-' + color;
+       } );
+
+       if ( $firstNew ) {
+               $indicator = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
+
+               $firstNew.after( $indicator );
+       }
+
+       // FIXME: Use CSS transition
+       // eslint-disable-next-line no-jquery/no-fade
+       $newChanges
+               .hide()
+               .fadeIn( 1000 );
+};
+
+/**
+ * In enhanced mode, we need to check whether the grouped results all have the
+ * same active highlights in order to see whether the "parent" of the group should
+ * be grey or highlighted normally.
+ *
+ * This is called every time highlights are applied.
+ */
+ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
+       var activeHighlightClasses,
+               $enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc.mw-collapsible' );
+
+       activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
+               return 'mw-rcfilters-highlight-color-' + color;
+       } );
+
+       // Go over top pages and their children, and figure out if all sub-pages have the
+       // same highlights between themselves. If they do, the parent should be highlighted
+       // with all colors. If classes are different, the parent should receive a grey
+       // background
+       $enhancedTopPageCell.each( function () {
+               var firstChildClasses, $rowsWithDifferentHighlights,
+                       $table = $( this );
+
+               // Collect the relevant classes from the first nested child
+               firstChildClasses = activeHighlightClasses.filter( function ( className ) {
+                       return $table.find( 'tr:nth-child(2)' ).hasClass( className );
                } );
-
-               // Go over top pages and their children, and figure out if all sub-pages have the
-               // same highlights between themselves. If they do, the parent should be highlighted
-               // with all colors. If classes are different, the parent should receive a grey
-               // background
-               $enhancedTopPageCell.each( function () {
-                       var firstChildClasses, $rowsWithDifferentHighlights,
-                               $table = $( this );
-
-                       // Collect the relevant classes from the first nested child
-                       firstChildClasses = activeHighlightClasses.filter( function ( className ) {
-                               return $table.find( 'tr:nth-child(2)' ).hasClass( className );
+               // Filter the non-head rows and see if they all have the same classes
+               // to the first row
+               $rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
+                       var classesInThisRow,
+                               $this = $( this );
+
+                       classesInThisRow = activeHighlightClasses.filter( function ( className ) {
+                               return $this.hasClass( className );
                        } );
-                       // Filter the non-head rows and see if they all have the same classes
-                       // to the first row
-                       $rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
-                               var classesInThisRow,
-                                       $this = $( this );
 
-                               classesInThisRow = activeHighlightClasses.filter( function ( className ) {
-                                       return $this.hasClass( className );
-                               } );
-
-                               return !OO.compare( firstChildClasses, classesInThisRow );
-                       } );
-
-                       // If classes are different, tag the row for using grey color
-                       $table.find( 'tr:first-child' )
-                               .toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
+                       return !OO.compare( firstChildClasses, classesInThisRow );
                } );
-       };
-
-       /**
-        * @return {boolean} Whether the changes are grouped by page
-        */
-       ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
-               var uri = new mw.Uri();
-               return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
-                       ( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
-       };
-
-       /**
-        * Apply color classes based on filters highlight configuration
-        */
-       ChangesListWrapperWidget.prototype.applyHighlight = function () {
-               if ( !this.filtersViewModel.isHighlightEnabled() ) {
-                       return;
-               }
-
-               this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
-                       var $elements = this.$element.find( '.' + filterItem.getCssClass() );
 
-                       // Add highlight class to all highlighted list items
-                       $elements
-                               .addClass(
-                                       'mw-rcfilters-highlighted ' +
-                                       'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
-                               );
-
-                       // Track the filters for each item in .data( 'highlightedFilters' )
-                       $elements.each( function () {
-                               var filters = $( this ).data( 'highlightedFilters' );
-                               if ( !filters ) {
-                                       filters = [];
-                                       $( this ).data( 'highlightedFilters', filters );
-                               }
-                               if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
-                                       filters.push( filterItem.getLabel() );
-                               }
-                       } );
-               }.bind( this ) );
-               // Apply a title to each highlighted item, with a list of filters
-               this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
+               // If classes are different, tag the row for using grey color
+               $table.find( 'tr:first-child' )
+                       .toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
+       } );
+};
+
+/**
+ * @return {boolean} Whether the changes are grouped by page
+ */
+ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
+       var uri = new mw.Uri();
+       return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
+               ( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
+};
+
+/**
+ * Apply color classes based on filters highlight configuration
+ */
+ChangesListWrapperWidget.prototype.applyHighlight = function () {
+       if ( !this.filtersViewModel.isHighlightEnabled() ) {
+               return;
+       }
+
+       this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
+               var $elements = this.$element.find( '.' + filterItem.getCssClass() );
+
+               // Add highlight class to all highlighted list items
+               $elements
+                       .addClass(
+                               'mw-rcfilters-highlighted ' +
+                               'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
+                       );
+
+               // Track the filters for each item in .data( 'highlightedFilters' )
+               $elements.each( function () {
                        var filters = $( this ).data( 'highlightedFilters' );
-
-                       if ( filters && filters.length ) {
-                               $( this ).attr( 'title', mw.msg(
-                                       'rcfilters-highlighted-filters-list',
-                                       filters.join( mw.msg( 'comma-separator' ) )
-                               ) );
+                       if ( !filters ) {
+                               filters = [];
+                               $( this ).data( 'highlightedFilters', filters );
+                       }
+                       if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
+                               filters.push( filterItem.getLabel() );
                        }
-
                } );
-               if ( this.inEnhancedMode() ) {
-                       this.updateEnhancedParentHighlight();
+       }.bind( this ) );
+       // Apply a title to each highlighted item, with a list of filters
+       this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
+               var filters = $( this ).data( 'highlightedFilters' );
+
+               if ( filters && filters.length ) {
+                       $( this ).attr( 'title', mw.msg(
+                               'rcfilters-highlighted-filters-list',
+                               filters.join( mw.msg( 'comma-separator' ) )
+                       ) );
                }
 
-               // Turn on highlights
-               this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
-       };
+       } );
+       if ( this.inEnhancedMode() ) {
+               this.updateEnhancedParentHighlight();
+       }
+
+       // Turn on highlights
+       this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
+};
+
+/**
+ * Remove all color classes
+ */
+ChangesListWrapperWidget.prototype.clearHighlight = function () {
+       // Remove highlight classes
+       mw.rcfilters.HighlightColors.forEach( function ( color ) {
+               this.$element
+                       .find( '.mw-rcfilters-highlight-color-' + color )
+                       .removeClass( 'mw-rcfilters-highlight-color-' + color );
+       }.bind( this ) );
 
-       /**
-        * Remove all color classes
-        */
-       ChangesListWrapperWidget.prototype.clearHighlight = function () {
-               // Remove highlight classes
-               mw.rcfilters.HighlightColors.forEach( function ( color ) {
-                       this.$element
-                               .find( '.mw-rcfilters-highlight-color-' + color )
-                               .removeClass( 'mw-rcfilters-highlight-color-' + color );
-               }.bind( this ) );
-
-               this.$element.find( '.mw-rcfilters-highlighted' )
-                       .removeAttr( 'title' )
-                       .removeData( 'highlightedFilters' )
-                       .removeClass( 'mw-rcfilters-highlighted' );
-
-               // Remove grey from enhanced rows
-               this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
-                       .removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );
-
-               // Turn off highlights
-               this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
-       };
+       this.$element.find( '.mw-rcfilters-highlighted' )
+               .removeAttr( 'title' )
+               .removeData( 'highlightedFilters' )
+               .removeClass( 'mw-rcfilters-highlighted' );
+
+       // Remove grey from enhanced rows
+       this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
+               .removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );
+
+       // Turn off highlights
+       this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
+};
 
-       module.exports = ChangesListWrapperWidget;
-}() );
+module.exports = ChangesListWrapperWidget;
index 490d54e..b6e21cf 100644 (file)
@@ -1,66 +1,64 @@
-( function () {
-       /**
-        * A widget representing a single toggle filter
-        *
-        * @class mw.rcfilters.ui.CheckboxInputWidget
-        * @extends OO.ui.CheckboxInputWidget
-        *
-        * @constructor
-        * @param {Object} config Configuration object
-        */
-       var CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
-               config = config || {};
+/**
+ * A widget representing a single toggle filter
+ *
+ * @class mw.rcfilters.ui.CheckboxInputWidget
+ * @extends OO.ui.CheckboxInputWidget
+ *
+ * @constructor
+ * @param {Object} config Configuration object
+ */
+var CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
+       config = config || {};
 
-               // Parent
-               CheckboxInputWidget.parent.call( this, config );
+       // Parent
+       CheckboxInputWidget.parent.call( this, config );
 
-               // Event
-               this.$input
-                       // HACK: This widget just pretends to be a checkbox for visual purposes.
-                       // In reality, all actions - setting to true or false, etc - are
-                       // decided by the model, and executed by the controller. This means
-                       // that we want to let the controller and model make the decision
-                       // of whether to check/uncheck this checkboxInputWidget, and for that,
-                       // we have to bypass the browser action that checks/unchecks it during
-                       // click.
-                       .on( 'click', false )
-                       .on( 'change', this.onUserChange.bind( this ) );
-       };
+       // Event
+       this.$input
+               // HACK: This widget just pretends to be a checkbox for visual purposes.
+               // In reality, all actions - setting to true or false, etc - are
+               // decided by the model, and executed by the controller. This means
+               // that we want to let the controller and model make the decision
+               // of whether to check/uncheck this checkboxInputWidget, and for that,
+               // we have to bypass the browser action that checks/unchecks it during
+               // click.
+               .on( 'click', false )
+               .on( 'change', this.onUserChange.bind( this ) );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget );
+OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget );
 
-       /* Events */
+/* Events */
 
-       /**
       * @event userChange
       * @param {boolean} Current state of the checkbox
       *
       * The user has checked or unchecked this checkbox
       */
+/**
+ * @event userChange
+ * @param {boolean} Current state of the checkbox
+ *
+ * The user has checked or unchecked this checkbox
+ */
 
-       /* Methods */
+/* Methods */
 
-       /**
       * @inheritdoc
       */
-       CheckboxInputWidget.prototype.onEdit = function () {
-               // Similarly to preventing defaults in 'click' event, we want
-               // to prevent this widget from deciding anything about its own
-               // state; it emits a change event and the model and controller
-               // make a decision about what its select state is.
-               // onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
-               // so we really want to prevent that from messing with what
-               // the model decides the state of the widget is.
-       };
+/**
+ * @inheritdoc
+ */
+CheckboxInputWidget.prototype.onEdit = function () {
+       // Similarly to preventing defaults in 'click' event, we want
+       // to prevent this widget from deciding anything about its own
+       // state; it emits a change event and the model and controller
+       // make a decision about what its select state is.
+       // onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
+       // so we really want to prevent that from messing with what
+       // the model decides the state of the widget is.
+};
 
-       /**
       * Respond to checkbox change by a user and emit 'userChange'.
       */
-       CheckboxInputWidget.prototype.onUserChange = function () {
-               this.emit( 'userChange', this.$input.prop( 'checked' ) );
-       };
+/**
+ * Respond to checkbox change by a user and emit 'userChange'.
+ */
+CheckboxInputWidget.prototype.onUserChange = function () {
+       this.emit( 'userChange', this.$input.prop( 'checked' ) );
+};
 
-       module.exports = CheckboxInputWidget;
-}() );
+module.exports = CheckboxInputWidget;
index 1ac0d49..226821c 100644 (file)
@@ -1,72 +1,70 @@
-( function () {
-       var ValuePickerWidget = require( './ValuePickerWidget.js' ),
-               DatePopupWidget;
+var ValuePickerWidget = require( './ValuePickerWidget.js' ),
+       DatePopupWidget;
 
-       /**
       * Widget defining the popup to choose date for the results
       *
       * @class mw.rcfilters.ui.DatePopupWidget
       * @extends OO.ui.Widget
       *
       * @constructor
       * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days'
       * @param {Object} [config] Configuration object
       */
-       DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) {
-               config = config || {};
+/**
+ * Widget defining the popup to choose date for the results
+ *
+ * @class mw.rcfilters.ui.DatePopupWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days'
+ * @param {Object} [config] Configuration object
+ */
+DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) {
+       config = config || {};
 
-               // Parent
-               DatePopupWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, config );
+       // Parent
+       DatePopupWidget.parent.call( this, config );
+       // Mixin constructors
+       OO.ui.mixin.LabelElement.call( this, config );
 
-               this.model = model;
+       this.model = model;
 
-               this.hoursValuePicker = new ValuePickerWidget(
-                       this.model,
-                       {
-                               classes: [ 'mw-rcfilters-ui-datePopupWidget-hours' ],
-                               label: mw.msg( 'rcfilters-hours-title' ),
-                               itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) < 1; }
-                       }
-               );
-               this.daysValuePicker = new ValuePickerWidget(
-                       this.model,
-                       {
-                               classes: [ 'mw-rcfilters-ui-datePopupWidget-days' ],
-                               label: mw.msg( 'rcfilters-days-title' ),
-                               itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) >= 1; }
-                       }
-               );
+       this.hoursValuePicker = new ValuePickerWidget(
+               this.model,
+               {
+                       classes: [ 'mw-rcfilters-ui-datePopupWidget-hours' ],
+                       label: mw.msg( 'rcfilters-hours-title' ),
+                       itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) < 1; }
+               }
+       );
+       this.daysValuePicker = new ValuePickerWidget(
+               this.model,
+               {
+                       classes: [ 'mw-rcfilters-ui-datePopupWidget-days' ],
+                       label: mw.msg( 'rcfilters-days-title' ),
+                       itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) >= 1; }
+               }
+       );
 
-               // Events
-               this.hoursValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
-               this.daysValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
+       // Events
+       this.hoursValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
+       this.daysValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
 
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-datePopupWidget' )
-                       .append(
-                               this.$label
-                                       .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
-                               this.hoursValuePicker.$element,
-                               this.daysValuePicker.$element
-                       );
-       };
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-datePopupWidget' )
+               .append(
+                       this.$label
+                               .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
+                       this.hoursValuePicker.$element,
+                       this.daysValuePicker.$element
+               );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( DatePopupWidget, OO.ui.Widget );
-       OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement );
+OO.inheritClass( DatePopupWidget, OO.ui.Widget );
+OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement );
 
-       /* Events */
+/* Events */
 
-       /**
       * @event days
       * @param {string} name Item name
       *
       * A days item was chosen
       */
+/**
+ * @event days
+ * @param {string} name Item name
+ *
+ * A days item was chosen
+ */
 
-       module.exports = DatePopupWidget;
-}() );
+module.exports = DatePopupWidget;
index 1327755..fb591d0 100644 (file)
@@ -1,85 +1,83 @@
-( function () {
-       /**
-        * A button to configure highlight for a filter item
-        *
-        * @class mw.rcfilters.ui.FilterItemHighlightButton
-        * @extends OO.ui.PopupButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FilterItem} model Filter item model
-        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
-        * @param {Object} [config] Configuration object
-        */
-       var FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
-               config = config || {};
-
-               // Parent
-               FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
-                       icon: 'highlight',
-                       indicator: 'down'
-               } ) );
-
-               this.controller = controller;
-               this.model = model;
-               this.popup = highlightPopup;
-
-               // Event
-               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
-               // This lives inside a MenuOptionWidget, which intercepts mousedown
-               // to select the item. We want to prevent that when we click the highlight
-               // button
-               this.$element.on( 'mousedown', function ( e ) {
-                       e.stopPropagation();
-               } );
-
-               this.updateUiBasedOnModel();
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( FilterItemHighlightButton, OO.ui.PopupButtonWidget );
-
-       /* Static Properties */
-
-       /**
-        * @static
-        */
-       FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;
-
-       /* Methods */
-
-       FilterItemHighlightButton.prototype.onAction = function () {
-               this.popup.setAssociatedButton( this );
-               this.popup.setFilterItem( this.model );
-
-               // Parent method
-               FilterItemHighlightButton.parent.prototype.onAction.call( this );
-       };
-
-       /**
-        * Respond to item model update event
-        */
-       FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
-               var currentColor = this.model.getHighlightColor(),
-                       widget = this;
-
-               this.$icon.toggleClass(
-                       'mw-rcfilters-ui-filterItemHighlightButton-circle',
-                       currentColor !== null
-               );
-
-               mw.rcfilters.HighlightColors.forEach( function ( c ) {
-                       widget.$icon
-                               .toggleClass(
-                                       'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
-                                       c === currentColor
-                               );
-               } );
-       };
-
-       module.exports = FilterItemHighlightButton;
-}() );
+/**
+ * A button to configure highlight for a filter item
+ *
+ * @class mw.rcfilters.ui.FilterItemHighlightButton
+ * @extends OO.ui.PopupButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FilterItem} model Filter item model
+ * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
+ * @param {Object} [config] Configuration object
+ */
+var FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
+       config = config || {};
+
+       // Parent
+       FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
+               icon: 'highlight',
+               indicator: 'down'
+       } ) );
+
+       this.controller = controller;
+       this.model = model;
+       this.popup = highlightPopup;
+
+       // Event
+       this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+       // This lives inside a MenuOptionWidget, which intercepts mousedown
+       // to select the item. We want to prevent that when we click the highlight
+       // button
+       this.$element.on( 'mousedown', function ( e ) {
+               e.stopPropagation();
+       } );
+
+       this.updateUiBasedOnModel();
+
+       this.$element
+               .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterItemHighlightButton, OO.ui.PopupButtonWidget );
+
+/* Static Properties */
+
+/**
+ * @static
+ */
+FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;
+
+/* Methods */
+
+FilterItemHighlightButton.prototype.onAction = function () {
+       this.popup.setAssociatedButton( this );
+       this.popup.setFilterItem( this.model );
+
+       // Parent method
+       FilterItemHighlightButton.parent.prototype.onAction.call( this );
+};
+
+/**
+ * Respond to item model update event
+ */
+FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
+       var currentColor = this.model.getHighlightColor(),
+               widget = this;
+
+       this.$icon.toggleClass(
+               'mw-rcfilters-ui-filterItemHighlightButton-circle',
+               currentColor !== null
+       );
+
+       mw.rcfilters.HighlightColors.forEach( function ( c ) {
+               widget.$icon
+                       .toggleClass(
+                               'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
+                               c === currentColor
+                       );
+       } );
+};
+
+module.exports = FilterItemHighlightButton;
index 1396341..3735af2 100644 (file)
-( function () {
-       /**
-        * Menu header for the RCFilters filters menu
-        *
-        * @class mw.rcfilters.ui.FilterMenuHeaderWidget
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       var FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.$overlay = config.$overlay || this.$element;
-
-               // Parent
-               FilterMenuHeaderWidget.parent.call( this, config );
-               OO.ui.mixin.LabelElement.call( this, $.extend( {
-                       label: mw.msg( 'rcfilters-filterlist-title' ),
-                       $label: $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
-               }, config ) );
-
-               // "Back" to default view button
-               this.backButton = new OO.ui.ButtonWidget( {
-                       icon: 'previous',
-                       framed: false,
-                       title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
-               } );
-               this.backButton.toggle( this.model.getCurrentView() !== 'default' );
-
-               // Help icon for Tagged edits
-               this.helpIcon = new OO.ui.ButtonWidget( {
-                       icon: 'helpNotice',
-                       framed: false,
-                       title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
-                       href: mw.util.getUrl( 'Special:Tags' ),
-                       target: '_blank'
-               } );
-               this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );
-
-               // Highlight button
-               this.highlightButton = new OO.ui.ToggleButtonWidget( {
-                       icon: 'highlight',
-                       label: mw.message( 'rcfilters-highlightbutton-title' ).text(),
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
-               } );
-
-               // Invert namespaces button
-               this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
-                       icon: '',
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
-               } );
-               this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
-
-               // Events
-               this.backButton.connect( this, { click: 'onBackButtonClick' } );
-               this.highlightButton
-                       .connect( this, { click: 'onHighlightButtonClick' } );
-               this.invertNamespacesButton
-                       .connect( this, { click: 'onInvertNamespacesButtonClick' } );
-               this.model.connect( this, {
-                       highlightChange: 'onModelHighlightChange',
-                       searchChange: 'onModelSearchChange',
-                       initialize: 'onModelInitialize'
-               } );
-               this.view = this.model.getCurrentView();
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
-                                                                       .append( this.backButton.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
-                                                                       .append( this.$label, this.helpIcon.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
-                                                                       .append( this.invertNamespacesButton.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
-                                                                       .append( this.highlightButton.$element )
-                                                       )
-                                       )
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( FilterMenuHeaderWidget, OO.ui.Widget );
-       OO.mixinClass( FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );
-
-       /* Methods */
-
-       /**
-        * Respond to model initialization event
-        *
-        * Note: need to wait for initialization before getting the invertModel
-        * and registering its update event. Creating all the models before the UI
-        * would help with that.
-        */
-       FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
-               this.invertModel = this.model.getInvertModel();
-               this.updateInvertButton();
-               this.invertModel.connect( this, { update: 'updateInvertButton' } );
-       };
-
-       /**
-        * Respond to model update event
-        */
-       FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
-               var currentView = this.model.getCurrentView();
-
-               if ( this.view !== currentView ) {
-                       this.setLabel( this.model.getViewTitle( currentView ) );
-
-                       this.invertNamespacesButton.toggle( currentView === 'namespaces' );
-                       this.backButton.toggle( currentView !== 'default' );
-                       this.helpIcon.toggle( currentView === 'tags' );
-                       this.view = currentView;
-               }
-       };
-
-       /**
-        * Respond to model highlight change event
-        *
-        * @param {boolean} highlightEnabled Highlight is enabled
-        */
-       FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
-               this.highlightButton.setActive( highlightEnabled );
-       };
-
-       /**
-        * Update the state of the invert button
-        */
-       FilterMenuHeaderWidget.prototype.updateInvertButton = function () {
-               this.invertNamespacesButton.setActive( this.invertModel.isSelected() );
-               this.invertNamespacesButton.setLabel(
-                       this.invertModel.isSelected() ?
-                               mw.msg( 'rcfilters-exclude-button-on' ) :
-                               mw.msg( 'rcfilters-exclude-button-off' )
+/**
+ * Menu header for the RCFilters filters menu
+ *
+ * @class mw.rcfilters.ui.FilterMenuHeaderWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+var FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
+       config = config || {};
+
+       this.controller = controller;
+       this.model = model;
+       this.$overlay = config.$overlay || this.$element;
+
+       // Parent
+       FilterMenuHeaderWidget.parent.call( this, config );
+       OO.ui.mixin.LabelElement.call( this, $.extend( {
+               label: mw.msg( 'rcfilters-filterlist-title' ),
+               $label: $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
+       }, config ) );
+
+       // "Back" to default view button
+       this.backButton = new OO.ui.ButtonWidget( {
+               icon: 'previous',
+               framed: false,
+               title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
+               classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
+       } );
+       this.backButton.toggle( this.model.getCurrentView() !== 'default' );
+
+       // Help icon for Tagged edits
+       this.helpIcon = new OO.ui.ButtonWidget( {
+               icon: 'helpNotice',
+               framed: false,
+               title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
+               classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
+               href: mw.util.getUrl( 'Special:Tags' ),
+               target: '_blank'
+       } );
+       this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );
+
+       // Highlight button
+       this.highlightButton = new OO.ui.ToggleButtonWidget( {
+               icon: 'highlight',
+               label: mw.message( 'rcfilters-highlightbutton-title' ).text(),
+               classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
+       } );
+
+       // Invert namespaces button
+       this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
+               icon: '',
+               classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
+       } );
+       this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
+
+       // Events
+       this.backButton.connect( this, { click: 'onBackButtonClick' } );
+       this.highlightButton
+               .connect( this, { click: 'onHighlightButtonClick' } );
+       this.invertNamespacesButton
+               .connect( this, { click: 'onInvertNamespacesButtonClick' } );
+       this.model.connect( this, {
+               highlightChange: 'onModelHighlightChange',
+               searchChange: 'onModelSearchChange',
+               initialize: 'onModelInitialize'
+       } );
+       this.view = this.model.getCurrentView();
+
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
+               .append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-table' )
+                               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-row' )
+                                               .append(
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
+                                                               .append( this.backButton.$element ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
+                                                               .append( this.$label, this.helpIcon.$element ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
+                                                               .append( this.invertNamespacesButton.$element ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
+                                                               .append( this.highlightButton.$element )
+                                               )
+                               )
                );
-       };
-
-       FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
-               this.controller.switchView( 'default' );
-       };
-
-       /**
-        * Respond to highlight button click
-        */
-       FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
-               this.controller.toggleHighlight();
-       };
-
-       /**
-        * Respond to highlight button click
-        */
-       FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
-               this.controller.toggleInvertedNamespaces();
-       };
-
-       module.exports = FilterMenuHeaderWidget;
-}() );
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterMenuHeaderWidget, OO.ui.Widget );
+OO.mixinClass( FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );
+
+/* Methods */
+
+/**
+ * Respond to model initialization event
+ *
+ * Note: need to wait for initialization before getting the invertModel
+ * and registering its update event. Creating all the models before the UI
+ * would help with that.
+ */
+FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
+       this.invertModel = this.model.getInvertModel();
+       this.updateInvertButton();
+       this.invertModel.connect( this, { update: 'updateInvertButton' } );
+};
+
+/**
+ * Respond to model update event
+ */
+FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
+       var currentView = this.model.getCurrentView();
+
+       if ( this.view !== currentView ) {
+               this.setLabel( this.model.getViewTitle( currentView ) );
+
+               this.invertNamespacesButton.toggle( currentView === 'namespaces' );
+               this.backButton.toggle( currentView !== 'default' );
+               this.helpIcon.toggle( currentView === 'tags' );
+               this.view = currentView;
+       }
+};
+
+/**
+ * Respond to model highlight change event
+ *
+ * @param {boolean} highlightEnabled Highlight is enabled
+ */
+FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
+       this.highlightButton.setActive( highlightEnabled );
+};
+
+/**
+ * Update the state of the invert button
+ */
+FilterMenuHeaderWidget.prototype.updateInvertButton = function () {
+       this.invertNamespacesButton.setActive( this.invertModel.isSelected() );
+       this.invertNamespacesButton.setLabel(
+               this.invertModel.isSelected() ?
+                       mw.msg( 'rcfilters-exclude-button-on' ) :
+                       mw.msg( 'rcfilters-exclude-button-off' )
+       );
+};
+
+FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
+       this.controller.switchView( 'default' );
+};
+
+/**
+ * Respond to highlight button click
+ */
+FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
+       this.controller.toggleHighlight();
+};
+
+/**
+ * Respond to highlight button click
+ */
+FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
+       this.controller.toggleInvertedNamespaces();
+};
+
+module.exports = FilterMenuHeaderWidget;
index 4080f4d..b4b0e9d 100644 (file)
@@ -1,96 +1,94 @@
-( function () {
-       var ItemMenuOptionWidget = require( './ItemMenuOptionWidget.js' ),
-               FilterMenuOptionWidget;
+var ItemMenuOptionWidget = require( './ItemMenuOptionWidget.js' ),
+       FilterMenuOptionWidget;
 
-       /**
       * A widget representing a single toggle filter
       *
       * @class mw.rcfilters.ui.FilterMenuOptionWidget
       * @extends mw.rcfilters.ui.ItemMenuOptionWidget
       *
       * @constructor
       * @param {mw.rcfilters.Controller} controller RCFilters controller
       * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
       * @param {mw.rcfilters.dm.FilterItem} invertModel
       * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
       * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
       * @param {Object} config Configuration object
       */
-       FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
-               controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
-       ) {
-               config = config || {};
+/**
+ * A widget representing a single toggle filter
+ *
+ * @class mw.rcfilters.ui.FilterMenuOptionWidget
+ * @extends mw.rcfilters.ui.ItemMenuOptionWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.FilterItem} invertModel
+ * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
+ * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
+ * @param {Object} config Configuration object
+ */
+FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
+       controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
+) {
+       config = config || {};
 
-               this.controller = controller;
-               this.invertModel = invertModel;
-               this.model = itemModel;
+       this.controller = controller;
+       this.invertModel = invertModel;
+       this.model = itemModel;
 
-               // Parent
-               FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
+       // Parent
+       FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
 
-               // Event
-               this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
+       // Event
+       this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
 
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
-       };
+       this.$element
+               .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
+};
 
-       /* Initialization */
-       OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget );
+/* Initialization */
+OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget );
 
-       /* Static properties */
+/* Static properties */
 
-       // We do our own scrolling to top
-       FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+// We do our own scrolling to top
+FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
 
-       /* Methods */
+/* Methods */
 
-       /**
       * @inheritdoc
       */
-       FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
-               // Parent
-               FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
+/**
+ * @inheritdoc
+ */
+FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+       // Parent
+       FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
 
-               this.setCurrentMuteState();
-       };
+       this.setCurrentMuteState();
+};
 
-       /**
       * Respond to item group model update event
       */
-       FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
-               this.setCurrentMuteState();
-       };
+/**
+ * Respond to item group model update event
+ */
+FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
+       this.setCurrentMuteState();
+};
 
-       /**
-        * Set the current muted view of the widget based on its state
-        */
-       FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
-               if (
-                       this.model.getGroupModel().getView() === 'namespaces' &&
-                       this.invertModel.isSelected()
-               ) {
-                       // This is an inverted behavior than the other rules, specifically
-                       // for inverted namespaces
-                       this.setFlags( {
-                               muted: this.model.isSelected()
-                       } );
-               } else {
-                       this.setFlags( {
-                               muted: (
-                                       this.model.isConflicted() ||
-                                       (
-                                               // Item is also muted when any of the items in its group is active
-                                               this.model.getGroupModel().isActive() &&
-                                               // But it isn't selected
-                                               !this.model.isSelected() &&
-                                               // And also not included
-                                               !this.model.isIncluded()
-                                       )
+/**
+ * Set the current muted view of the widget based on its state
+ */
+FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
+       if (
+               this.model.getGroupModel().getView() === 'namespaces' &&
+               this.invertModel.isSelected()
+       ) {
+               // This is an inverted behavior than the other rules, specifically
+               // for inverted namespaces
+               this.setFlags( {
+                       muted: this.model.isSelected()
+               } );
+       } else {
+               this.setFlags( {
+                       muted: (
+                               this.model.isConflicted() ||
+                               (
+                                       // Item is also muted when any of the items in its group is active
+                                       this.model.getGroupModel().isActive() &&
+                                       // But it isn't selected
+                                       !this.model.isSelected() &&
+                                       // And also not included
+                                       !this.model.isIncluded()
                                )
-                       } );
-               }
-       };
+                       )
+               } );
+       }
+};
 
-       module.exports = FilterMenuOptionWidget;
-}() );
+module.exports = FilterMenuOptionWidget;
index 5b9e359..abcce81 100644 (file)
-( function () {
-       /**
-        * A widget representing a menu section for filter groups
-        *
-        * @class mw.rcfilters.ui.FilterMenuSectionOptionWidget
-        * @extends OO.ui.MenuSectionOptionWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] Overlay
-        */
-       var FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
-               var whatsThisMessages,
-                       $header = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
-                       $popupContent = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.$overlay = config.$overlay || this.$element;
-
-               // Parent
-               FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
-                       label: this.model.getTitle(),
-                       $label: $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
-               }, config ) );
-
-               $header.append( this.$label );
-
-               if ( this.model.hasWhatsThis() ) {
-                       whatsThisMessages = this.model.getWhatsThis();
-
-                       // Create popup
-                       if ( whatsThisMessages.header ) {
-                               $popupContent.append(
-                                       ( new OO.ui.LabelWidget( {
-                                               label: mw.msg( whatsThisMessages.header ),
-                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
-                                       } ) ).$element
-                               );
-                       }
-                       if ( whatsThisMessages.body ) {
-                               $popupContent.append(
-                                       ( new OO.ui.LabelWidget( {
-                                               label: mw.msg( whatsThisMessages.body ),
-                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
-                                       } ) ).$element
-                               );
-                       }
-                       if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
-                               $popupContent.append(
-                                       ( new OO.ui.ButtonWidget( {
-                                               framed: false,
-                                               flags: [ 'progressive' ],
-                                               href: whatsThisMessages.url,
-                                               label: mw.msg( whatsThisMessages.linkText ),
-                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
-                                       } ) ).$element
-                               );
-                       }
-
-                       // Add button
-                       this.whatsThisButton = new OO.ui.PopupButtonWidget( {
-                               framed: false,
-                               label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
-                               $overlay: this.$overlay,
-                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
-                               flags: [ 'progressive' ],
-                               popup: {
-                                       padded: false,
-                                       align: 'center',
-                                       position: 'above',
-                                       $content: $popupContent,
-                                       classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
-                               }
-                       } );
-
-                       $header
-                               .append( this.whatsThisButton.$element );
+/**
+ * A widget representing a menu section for filter groups
+ *
+ * @class mw.rcfilters.ui.FilterMenuSectionOptionWidget
+ * @extends OO.ui.MenuSectionOptionWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] Overlay
+ */
+var FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
+       var whatsThisMessages,
+               $header = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
+               $popupContent = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );
+
+       config = config || {};
+
+       this.controller = controller;
+       this.model = model;
+       this.$overlay = config.$overlay || this.$element;
+
+       // Parent
+       FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
+               label: this.model.getTitle(),
+               $label: $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
+       }, config ) );
+
+       $header.append( this.$label );
+
+       if ( this.model.hasWhatsThis() ) {
+               whatsThisMessages = this.model.getWhatsThis();
+
+               // Create popup
+               if ( whatsThisMessages.header ) {
+                       $popupContent.append(
+                               ( new OO.ui.LabelWidget( {
+                                       label: mw.msg( whatsThisMessages.header ),
+                                       classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
+                               } ) ).$element
+                       );
+               }
+               if ( whatsThisMessages.body ) {
+                       $popupContent.append(
+                               ( new OO.ui.LabelWidget( {
+                                       label: mw.msg( whatsThisMessages.body ),
+                                       classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
+                               } ) ).$element
+                       );
+               }
+               if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
+                       $popupContent.append(
+                               ( new OO.ui.ButtonWidget( {
+                                       framed: false,
+                                       flags: [ 'progressive' ],
+                                       href: whatsThisMessages.url,
+                                       label: mw.msg( whatsThisMessages.linkText ),
+                                       classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
+                               } ) ).$element
+                       );
                }
 
-               // Events
-               this.model.connect( this, { update: 'updateUiBasedOnState' } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
-                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
-                       .append( $header );
-               this.updateUiBasedOnState();
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
-               this.$element.toggleClass(
-                       'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
-                       this.model.isActive()
-               );
-               this.toggle( this.model.isVisible() );
-       };
-
-       /**
-        * Get the group name
-        *
-        * @return {string} Group name
-        */
-       FilterMenuSectionOptionWidget.prototype.getName = function () {
-               return this.model.getName();
-       };
-
-       module.exports = FilterMenuSectionOptionWidget;
-
-}() );
+               // Add button
+               this.whatsThisButton = new OO.ui.PopupButtonWidget( {
+                       framed: false,
+                       label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
+                       $overlay: this.$overlay,
+                       classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
+                       flags: [ 'progressive' ],
+                       popup: {
+                               padded: false,
+                               align: 'center',
+                               position: 'above',
+                               $content: $popupContent,
+                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
+                       }
+               } );
+
+               $header
+                       .append( this.whatsThisButton.$element );
+       }
+
+       // Events
+       this.model.connect( this, { update: 'updateUiBasedOnState' } );
+
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
+               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
+               .append( $header );
+       this.updateUiBasedOnState();
+};
+
+/* Initialize */
+
+OO.inheritClass( FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );
+
+/* Methods */
+
+/**
+ * Respond to model update event
+ */
+FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
+       this.$element.toggleClass(
+               'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
+               this.model.isActive()
+       );
+       this.toggle( this.model.isVisible() );
+};
+
+/**
+ * Get the group name
+ *
+ * @return {string} Group name
+ */
+FilterMenuSectionOptionWidget.prototype.getName = function () {
+       return this.model.getName();
+};
+
+module.exports = FilterMenuSectionOptionWidget;
index bda898b..98eea71 100644 (file)
@@ -1,50 +1,48 @@
-( function () {
-       var TagItemWidget = require( './TagItemWidget.js' ),
-               FilterTagItemWidget;
-
-       /**
-        * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
-        *
-        * @class mw.rcfilters.ui.FilterTagItemWidget
-        * @extends mw.rcfilters.ui.TagItemWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.FilterItem} invertModel
-        * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
-        * @param {Object} config Configuration object
-        */
-       FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
-               controller, filtersViewModel, invertModel, itemModel, config
-       ) {
-               config = config || {};
-
-               FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( FilterTagItemWidget, TagItemWidget );
-
-       /* Methods */
-
-       /**
-        * @inheritdoc
-        */
-       FilterTagItemWidget.prototype.setCurrentMuteState = function () {
-               this.setFlags( {
-                       muted: (
-                               !this.itemModel.isSelected() ||
-                               this.itemModel.isIncluded() ||
-                               this.itemModel.isFullyCovered()
-                       ),
-                       invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
-               } );
-       };
-
-       module.exports = FilterTagItemWidget;
-}() );
+var TagItemWidget = require( './TagItemWidget.js' ),
+       FilterTagItemWidget;
+
+/**
+ * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
+ *
+ * @class mw.rcfilters.ui.FilterTagItemWidget
+ * @extends mw.rcfilters.ui.TagItemWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.FilterItem} invertModel
+ * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
+ * @param {Object} config Configuration object
+ */
+FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
+       controller, filtersViewModel, invertModel, itemModel, config
+) {
+       config = config || {};
+
+       FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );
+
+       this.$element
+               .addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterTagItemWidget, TagItemWidget );
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ */
+FilterTagItemWidget.prototype.setCurrentMuteState = function () {
+       this.setFlags( {
+               muted: (
+                       !this.itemModel.isSelected() ||
+                       this.itemModel.isIncluded() ||
+                       this.itemModel.isFullyCovered()
+               ),
+               invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
+       } );
+};
+
+module.exports = FilterTagItemWidget;
index dc6fc12..085e22b 100644 (file)
-( function () {
-       var ViewSwitchWidget = require( './ViewSwitchWidget.js' ),
-               SaveFiltersPopupButtonWidget = require( './SaveFiltersPopupButtonWidget.js' ),
-               MenuSelectWidget = require( './MenuSelectWidget.js' ),
-               FilterTagItemWidget = require( './FilterTagItemWidget.js' ),
-               FilterTagMultiselectWidget;
-
-       /**
-        * List displaying all filter groups
-        *
-        * @class mw.rcfilters.ui.FilterTagMultiselectWidget
-        * @extends OO.ui.MenuTagMultiselectWidget
-        * @mixins OO.ui.mixin.PendingElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
-        *  system. If not given, falls back to this widget's $element
-        * @cfg {boolean} [collapsed] Filter area is collapsed
-        */
-       FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
-               var rcFiltersRow,
-                       title = new OO.ui.LabelWidget( {
-                               label: mw.msg( 'rcfilters-activefilters' ),
-                               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
-                       } ),
-                       $contentWrapper = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.queriesModel = savedQueriesModel;
-               this.$overlay = config.$overlay || this.$element;
-               this.$wrapper = config.$wrapper || this.$element;
-               this.matchingQuery = null;
-               this.currentView = this.model.getCurrentView();
-               this.collapsed = false;
-
-               // Parent
-               FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
-                       label: mw.msg( 'rcfilters-filterlist-title' ),
-                       placeholder: mw.msg( 'rcfilters-empty-filter' ),
-                       inputPosition: 'outline',
-                       allowArbitrary: false,
-                       allowDisplayInvalidTags: false,
-                       allowReordering: false,
-                       $overlay: this.$overlay,
-                       menu: {
-                               // Our filtering is done through the model
-                               filterFromInput: false,
-                               hideWhenOutOfView: false,
-                               hideOnChoose: false,
-                               width: 650,
-                               footers: [
-                                       {
-                                               name: 'viewSelect',
-                                               sticky: false,
-                                               // View select menu, appears on default view only
-                                               $element: $( '<div>' )
-                                                       .append( new ViewSwitchWidget( this.controller, this.model ).$element ),
-                                               views: [ 'default' ]
-                                       },
-                                       {
-                                               name: 'feedback',
-                                               // Feedback footer, appears on all views
-                                               $element: $( '<div>' )
-                                                       .append(
-                                                               new OO.ui.ButtonWidget( {
-                                                                       framed: false,
-                                                                       icon: 'feedback',
-                                                                       flags: [ 'progressive' ],
-                                                                       label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
-                                                                       href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
-                                                               } ).$element
-                                                       )
-                                       }
-                               ]
-                       },
-                       input: {
-                               icon: 'menu',
-                               placeholder: mw.msg( 'rcfilters-search-placeholder' )
-                       }
-               }, config ) );
-
-               this.savedQueryTitle = new OO.ui.LabelWidget( {
-                       label: '',
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
-               } );
-
-               this.resetButton = new OO.ui.ButtonWidget( {
-                       framed: false,
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
-               } );
-
-               this.hideShowButton = new OO.ui.ButtonWidget( {
-                       framed: false,
-                       flags: [ 'progressive' ],
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
-               } );
-               this.toggleCollapsed( !!config.collapsed );
-
-               if ( !mw.user.isAnon() ) {
-                       this.saveQueryButton = new SaveFiltersPopupButtonWidget(
-                               this.controller,
-                               this.queriesModel,
+var ViewSwitchWidget = require( './ViewSwitchWidget.js' ),
+       SaveFiltersPopupButtonWidget = require( './SaveFiltersPopupButtonWidget.js' ),
+       MenuSelectWidget = require( './MenuSelectWidget.js' ),
+       FilterTagItemWidget = require( './FilterTagItemWidget.js' ),
+       FilterTagMultiselectWidget;
+
+/**
+ * List displaying all filter groups
+ *
+ * @class mw.rcfilters.ui.FilterTagMultiselectWidget
+ * @extends OO.ui.MenuTagMultiselectWidget
+ * @mixins OO.ui.mixin.PendingElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+ *  system. If not given, falls back to this widget's $element
+ * @cfg {boolean} [collapsed] Filter area is collapsed
+ */
+FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
+       var rcFiltersRow,
+               title = new OO.ui.LabelWidget( {
+                       label: mw.msg( 'rcfilters-activefilters' ),
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
+               } ),
+               $contentWrapper = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
+
+       config = config || {};
+
+       this.controller = controller;
+       this.model = model;
+       this.queriesModel = savedQueriesModel;
+       this.$overlay = config.$overlay || this.$element;
+       this.$wrapper = config.$wrapper || this.$element;
+       this.matchingQuery = null;
+       this.currentView = this.model.getCurrentView();
+       this.collapsed = false;
+
+       // Parent
+       FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
+               label: mw.msg( 'rcfilters-filterlist-title' ),
+               placeholder: mw.msg( 'rcfilters-empty-filter' ),
+               inputPosition: 'outline',
+               allowArbitrary: false,
+               allowDisplayInvalidTags: false,
+               allowReordering: false,
+               $overlay: this.$overlay,
+               menu: {
+                       // Our filtering is done through the model
+                       filterFromInput: false,
+                       hideWhenOutOfView: false,
+                       hideOnChoose: false,
+                       width: 650,
+                       footers: [
+                               {
+                                       name: 'viewSelect',
+                                       sticky: false,
+                                       // View select menu, appears on default view only
+                                       $element: $( '<div>' )
+                                               .append( new ViewSwitchWidget( this.controller, this.model ).$element ),
+                                       views: [ 'default' ]
+                               },
                                {
-                                       $overlay: this.$overlay
+                                       name: 'feedback',
+                                       // Feedback footer, appears on all views
+                                       $element: $( '<div>' )
+                                               .append(
+                                                       new OO.ui.ButtonWidget( {
+                                                               framed: false,
+                                                               icon: 'feedback',
+                                                               flags: [ 'progressive' ],
+                                                               label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
+                                                               href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
+                                                       } ).$element
+                                               )
                                }
-                       );
-
-                       this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
-                               e.stopPropagation();
-                       } );
-
-                       this.saveQueryButton.connect( this, {
-                               click: 'onSaveQueryButtonClick',
-                               saveCurrent: 'setSavedQueryVisibility'
-                       } );
-                       this.queriesModel.connect( this, {
-                               itemUpdate: 'onSavedQueriesItemUpdate',
-                               initialize: 'onSavedQueriesInitialize',
-                               default: 'reevaluateResetRestoreState'
-                       } );
+                       ]
+               },
+               input: {
+                       icon: 'menu',
+                       placeholder: mw.msg( 'rcfilters-search-placeholder' )
                }
+       }, config ) );
+
+       this.savedQueryTitle = new OO.ui.LabelWidget( {
+               label: '',
+               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
+       } );
+
+       this.resetButton = new OO.ui.ButtonWidget( {
+               framed: false,
+               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
+       } );
+
+       this.hideShowButton = new OO.ui.ButtonWidget( {
+               framed: false,
+               flags: [ 'progressive' ],
+               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
+       } );
+       this.toggleCollapsed( !!config.collapsed );
+
+       if ( !mw.user.isAnon() ) {
+               this.saveQueryButton = new SaveFiltersPopupButtonWidget(
+                       this.controller,
+                       this.queriesModel,
+                       {
+                               $overlay: this.$overlay
+                       }
+               );
 
-               this.emptyFilterMessage = new OO.ui.LabelWidget( {
-                       label: mw.msg( 'rcfilters-empty-filter' ),
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
-               } );
-               this.$content.append( this.emptyFilterMessage.$element );
-
-               // Events
-               this.resetButton.connect( this, { click: 'onResetButtonClick' } );
-               this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
-               // Stop propagation for mousedown, so that the widget doesn't
-               // trigger the focus on the input and scrolls up when we click the reset button
-               this.resetButton.$element.on( 'mousedown', function ( e ) {
-                       e.stopPropagation();
-               } );
-               this.hideShowButton.$element.on( 'mousedown', function ( e ) {
+               this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
                        e.stopPropagation();
                } );
-               this.model.connect( this, {
-                       initialize: 'onModelInitialize',
-                       update: 'onModelUpdate',
-                       searchChange: 'onModelSearchChange',
-                       itemUpdate: 'onModelItemUpdate',
-                       highlightChange: 'onModelHighlightChange'
-               } );
-               this.input.connect( this, { change: 'onInputChange' } );
-
-               // The filter list and button should appear side by side regardless of how
-               // wide the button is; the button also changes its width depending
-               // on language and its state, so the safest way to present both side
-               // by side is with a table layout
-               rcFiltersRow = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-row' )
-                       .append(
-                               this.$content
-                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
-                       );
 
-               if ( !mw.user.isAnon() ) {
-                       rcFiltersRow.append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
-                                       .append( this.saveQueryButton.$element )
-                       );
-               }
-
-               // Add a selector at the right of the input
-               this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
-                       items: [
-                               new OO.ui.ButtonOptionWidget( {
-                                       framed: false,
-                                       data: 'namespaces',
-                                       icon: 'article',
-                                       label: mw.msg( 'namespaces' ),
-                                       title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
-                               } ),
-                               new OO.ui.ButtonOptionWidget( {
-                                       framed: false,
-                                       data: 'tags',
-                                       icon: 'tag',
-                                       label: mw.msg( 'tags-title' ),
-                                       title: mw.msg( 'rcfilters-view-tags-tooltip' )
-                               } )
-                       ]
+               this.saveQueryButton.connect( this, {
+                       click: 'onSaveQueryButtonClick',
+                       saveCurrent: 'setSavedQueryVisibility'
                } );
-
-               // Rearrange the UI so the select widget is at the right of the input
-               this.$element.append(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-table' )
-                               .append(
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-row' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
-                                               .append(
-                                                       $( '<div>' )
-                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
-                                                               .append( this.input.$element ),
-                                                       $( '<div>' )
-                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
-                                                               .append( this.viewsSelectWidget.$element )
-                                               )
-                               )
+               this.queriesModel.connect( this, {
+                       itemUpdate: 'onSavedQueriesItemUpdate',
+                       initialize: 'onSavedQueriesInitialize',
+                       default: 'reevaluateResetRestoreState'
+               } );
+       }
+
+       this.emptyFilterMessage = new OO.ui.LabelWidget( {
+               label: mw.msg( 'rcfilters-empty-filter' ),
+               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
+       } );
+       this.$content.append( this.emptyFilterMessage.$element );
+
+       // Events
+       this.resetButton.connect( this, { click: 'onResetButtonClick' } );
+       this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
+       // Stop propagation for mousedown, so that the widget doesn't
+       // trigger the focus on the input and scrolls up when we click the reset button
+       this.resetButton.$element.on( 'mousedown', function ( e ) {
+               e.stopPropagation();
+       } );
+       this.hideShowButton.$element.on( 'mousedown', function ( e ) {
+               e.stopPropagation();
+       } );
+       this.model.connect( this, {
+               initialize: 'onModelInitialize',
+               update: 'onModelUpdate',
+               searchChange: 'onModelSearchChange',
+               itemUpdate: 'onModelItemUpdate',
+               highlightChange: 'onModelHighlightChange'
+       } );
+       this.input.connect( this, { change: 'onInputChange' } );
+
+       // The filter list and button should appear side by side regardless of how
+       // wide the button is; the button also changes its width depending
+       // on language and its state, so the safest way to present both side
+       // by side is with a table layout
+       rcFiltersRow = $( '<div>' )
+               .addClass( 'mw-rcfilters-ui-row' )
+               .append(
+                       this.$content
+                               .addClass( 'mw-rcfilters-ui-cell' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
                );
 
-               // Event
-               this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
-
+       if ( !mw.user.isAnon() ) {
                rcFiltersRow.append(
                        $( '<div>' )
                                .addClass( 'mw-rcfilters-ui-cell' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
-                               .append( this.resetButton.$element )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
+                               .append( this.saveQueryButton.$element )
                );
-
-               // Build the content
-               $contentWrapper.append(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
-                               .append(
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
-                                               .append( title.$element ),
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
-                                               .append( this.savedQueryTitle.$element ),
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
-                                               .append(
-                                                       this.hideShowButton.$element
-                                               )
-                               ),
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-table' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
-                               .append( rcFiltersRow )
-               );
-
-               // Initialize
-               this.$handle.append( $contentWrapper );
-               this.emptyFilterMessage.toggle( this.isEmpty() );
-               this.savedQueryTitle.toggle( false );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
-
-               this.reevaluateResetRestoreState();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
-
-       /* Methods */
-
-       /**
-        * Override parent method to avoid unnecessary resize events.
-        */
-       FilterTagMultiselectWidget.prototype.updateIfHeightChanged = function () { };
-
-       /**
-        * Respond to view select widget choose event
-        *
-        * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
-        */
-       FilterTagMultiselectWidget.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget ) {
-               this.controller.switchView( buttonOptionWidget.getData() );
-               this.viewsSelectWidget.selectItem( null );
+       }
+
+       // Add a selector at the right of the input
+       this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
+               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
+               items: [
+                       new OO.ui.ButtonOptionWidget( {
+                               framed: false,
+                               data: 'namespaces',
+                               icon: 'article',
+                               label: mw.msg( 'namespaces' ),
+                               title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
+                       } ),
+                       new OO.ui.ButtonOptionWidget( {
+                               framed: false,
+                               data: 'tags',
+                               icon: 'tag',
+                               label: mw.msg( 'tags-title' ),
+                               title: mw.msg( 'rcfilters-view-tags-tooltip' )
+                       } )
+               ]
+       } );
+
+       // Rearrange the UI so the select widget is at the right of the input
+       this.$element.append(
+               $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-table' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-row' )
+                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
+                                                       .append( this.input.$element ),
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
+                                                       .append( this.viewsSelectWidget.$element )
+                                       )
+                       )
+       );
+
+       // Event
+       this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
+
+       rcFiltersRow.append(
+               $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-cell' )
+                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
+                       .append( this.resetButton.$element )
+       );
+
+       // Build the content
+       $contentWrapper.append(
+               $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
+                                       .append( title.$element ),
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
+                                       .append( this.savedQueryTitle.$element ),
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
+                                       .append(
+                                               this.hideShowButton.$element
+                                       )
+                       ),
+               $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-table' )
+                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
+                       .append( rcFiltersRow )
+       );
+
+       // Initialize
+       this.$handle.append( $contentWrapper );
+       this.emptyFilterMessage.toggle( this.isEmpty() );
+       this.savedQueryTitle.toggle( false );
+
+       this.$element
+               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
+
+       this.reevaluateResetRestoreState();
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
+
+/* Methods */
+
+/**
+ * Override parent method to avoid unnecessary resize events.
+ */
+FilterTagMultiselectWidget.prototype.updateIfHeightChanged = function () { };
+
+/**
+ * Respond to view select widget choose event
+ *
+ * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
+ */
+FilterTagMultiselectWidget.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget ) {
+       this.controller.switchView( buttonOptionWidget.getData() );
+       this.viewsSelectWidget.selectItem( null );
+       this.focus();
+};
+
+/**
+ * Respond to model search change event
+ *
+ * @param {string} value Search value
+ */
+FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
+       this.input.setValue( value );
+};
+
+/**
+ * Respond to input change event
+ *
+ * @param {string} value Value of the input
+ */
+FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
+       this.controller.setSearch( value );
+};
+
+/**
+ * Respond to query button click
+ */
+FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
+       this.getMenu().toggle( false );
+};
+
+/**
+ * Respond to save query model initialization
+ */
+FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
+       this.setSavedQueryVisibility();
+};
+
+/**
+ * Respond to save query item change. Mainly this is done to update the label in case
+ * a query item has been edited
+ *
+ * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
+ */
+FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
+       if ( this.matchingQuery === item ) {
+               // This means we just edited the item that is currently matched
+               this.savedQueryTitle.setLabel( item.getLabel() );
+       }
+};
+
+/**
+ * Respond to menu toggle
+ *
+ * @param {boolean} isVisible Menu is visible
+ */
+FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
+       // Parent
+       FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
+
+       if ( isVisible ) {
                this.focus();
-       };
-
-       /**
-        * Respond to model search change event
-        *
-        * @param {string} value Search value
-        */
-       FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
-               this.input.setValue( value );
-       };
-
-       /**
-        * Respond to input change event
-        *
-        * @param {string} value Value of the input
-        */
-       FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
-               this.controller.setSearch( value );
-       };
-
-       /**
-        * Respond to query button click
-        */
-       FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
-               this.getMenu().toggle( false );
-       };
-
-       /**
-        * Respond to save query model initialization
-        */
-       FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
-               this.setSavedQueryVisibility();
-       };
-
-       /**
-        * Respond to save query item change. Mainly this is done to update the label in case
-        * a query item has been edited
-        *
-        * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
-        */
-       FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
-               if ( this.matchingQuery === item ) {
-                       // This means we just edited the item that is currently matched
-                       this.savedQueryTitle.setLabel( item.getLabel() );
-               }
-       };
-
-       /**
-        * Respond to menu toggle
-        *
-        * @param {boolean} isVisible Menu is visible
-        */
-       FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
-               // Parent
-               FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
-
-               if ( isVisible ) {
-                       this.focus();
-
-                       mw.hook( 'RcFilters.popup.open' ).fire();
-
-                       if ( !this.getMenu().findSelectedItem() ) {
-                               // If there are no selected items, scroll menu to top
-                               // This has to be in a setTimeout so the menu has time
-                               // to be positioned and fixed
-                               setTimeout(
-                                       function () {
-                                               this.getMenu().scrollToTop();
-                                       }.bind( this )
-                               );
-                       }
-               } else {
-                       // Clear selection
-                       this.selectTag( null );
 
-                       // Clear the search
-                       this.controller.setSearch( '' );
+               mw.hook( 'RcFilters.popup.open' ).fire();
 
-                       // Log filter grouping
-                       this.controller.trackFilterGroupings( 'filtermenu' );
-
-                       this.blur();
-               }
-
-               this.input.setIcon( isVisible ? 'search' : 'menu' );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       FilterTagMultiselectWidget.prototype.onInputFocus = function () {
-               // Parent
-               FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
-
-               // Only scroll to top of the viewport if:
-               // - The widget is more than 20px from the top
-               // - The widget is not above the top of the viewport (do not scroll downwards)
-               //   (This isn't represented because >20 is, anyways and always, bigger than 0)
-               this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       FilterTagMultiselectWidget.prototype.doInputEscape = function () {
-               // Parent
-               FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
-
-               // Blur the input
-               this.input.$input.trigger( 'blur' );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
-               if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
-                       this.menu.toggle();
-
-                       return false;
-               }
-       };
-
-       /**
-        * @inheritdoc
-        */
-       FilterTagMultiselectWidget.prototype.onChangeTags = function () {
-               // If initialized, call parent method.
-               if ( this.controller.isInitialized() ) {
-                       FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
-               }
-
-               this.emptyFilterMessage.toggle( this.isEmpty() );
-       };
-
-       /**
-        * Respond to model initialize event
-        */
-       FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
-               this.setSavedQueryVisibility();
-       };
-
-       /**
-        * Respond to model update event
-        */
-       FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
-               this.updateElementsForView();
-       };
-
-       /**
-        * Update the elements in the widget to the current view
-        */
-       FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
-               var view = this.model.getCurrentView(),
-                       inputValue = this.input.getValue().trim(),
-                       inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
-
-               if ( inputView !== 'default' ) {
-                       // We have a prefix already, remove it
-                       inputValue = inputValue.substr( 1 );
-               }
-
-               if ( inputView !== view ) {
-                       // Add the correct prefix
-                       inputValue = this.model.getViewTrigger( view ) + inputValue;
-               }
-
-               // Update input
-               this.input.setValue( inputValue );
-
-               if ( this.currentView !== view ) {
-                       this.scrollToTop( this.$element );
-                       this.currentView = view;
-               }
-       };
-
-       /**
-        * Set the visibility of the saved query button
-        */
-       FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
-               if ( mw.user.isAnon() ) {
-                       return;
-               }
-
-               this.matchingQuery = this.controller.findQueryMatchingCurrentState();
-
-               this.savedQueryTitle.setLabel(
-                       this.matchingQuery ? this.matchingQuery.getLabel() : ''
-               );
-               this.savedQueryTitle.toggle( !!this.matchingQuery );
-               this.saveQueryButton.setDisabled( !!this.matchingQuery );
-               this.saveQueryButton.setTitle( !this.matchingQuery ?
-                       mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
-                       mw.msg( 'rcfilters-savedqueries-already-saved' ) );
-
-               if ( this.matchingQuery ) {
-                       this.emphasize();
+               if ( !this.getMenu().findSelectedItem() ) {
+                       // If there are no selected items, scroll menu to top
+                       // This has to be in a setTimeout so the menu has time
+                       // to be positioned and fixed
+                       setTimeout(
+                               function () {
+                                       this.getMenu().scrollToTop();
+                               }.bind( this )
+                       );
                }
-       };
-
-       /**
-        * Respond to model itemUpdate event
-        * fixme: when a new state is applied to the model this function is called 60+ times in a row
-        *
-        * @param {mw.rcfilters.dm.FilterItem} item Filter item model
-        */
-       FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
-               if ( !item.getGroupModel().isHidden() ) {
-                       if (
-                               item.isSelected() ||
-                               (
-                                       this.model.isHighlightEnabled() &&
-                                       item.getHighlightColor()
-                               )
-                       ) {
-                               this.addTag( item.getName(), item.getLabel() );
-                       } else {
-                               // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
-                               if ( this.findItemFromData( item.getName() ) !== null ) {
-                                       this.removeTagByData( item.getName() );
-                               }
+       } else {
+               // Clear selection
+               this.selectTag( null );
+
+               // Clear the search
+               this.controller.setSearch( '' );
+
+               // Log filter grouping
+               this.controller.trackFilterGroupings( 'filtermenu' );
+
+               this.blur();
+       }
+
+       this.input.setIcon( isVisible ? 'search' : 'menu' );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onInputFocus = function () {
+       // Parent
+       FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
+
+       // Only scroll to top of the viewport if:
+       // - The widget is more than 20px from the top
+       // - The widget is not above the top of the viewport (do not scroll downwards)
+       //   (This isn't represented because >20 is, anyways and always, bigger than 0)
+       this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.doInputEscape = function () {
+       // Parent
+       FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
+
+       // Blur the input
+       this.input.$input.trigger( 'blur' );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
+       if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
+               this.menu.toggle();
+
+               return false;
+       }
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onChangeTags = function () {
+       // If initialized, call parent method.
+       if ( this.controller.isInitialized() ) {
+               FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
+       }
+
+       this.emptyFilterMessage.toggle( this.isEmpty() );
+};
+
+/**
+ * Respond to model initialize event
+ */
+FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
+       this.setSavedQueryVisibility();
+};
+
+/**
+ * Respond to model update event
+ */
+FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
+       this.updateElementsForView();
+};
+
+/**
+ * Update the elements in the widget to the current view
+ */
+FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
+       var view = this.model.getCurrentView(),
+               inputValue = this.input.getValue().trim(),
+               inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
+
+       if ( inputView !== 'default' ) {
+               // We have a prefix already, remove it
+               inputValue = inputValue.substr( 1 );
+       }
+
+       if ( inputView !== view ) {
+               // Add the correct prefix
+               inputValue = this.model.getViewTrigger( view ) + inputValue;
+       }
+
+       // Update input
+       this.input.setValue( inputValue );
+
+       if ( this.currentView !== view ) {
+               this.scrollToTop( this.$element );
+               this.currentView = view;
+       }
+};
+
+/**
+ * Set the visibility of the saved query button
+ */
+FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
+       if ( mw.user.isAnon() ) {
+               return;
+       }
+
+       this.matchingQuery = this.controller.findQueryMatchingCurrentState();
+
+       this.savedQueryTitle.setLabel(
+               this.matchingQuery ? this.matchingQuery.getLabel() : ''
+       );
+       this.savedQueryTitle.toggle( !!this.matchingQuery );
+       this.saveQueryButton.setDisabled( !!this.matchingQuery );
+       this.saveQueryButton.setTitle( !this.matchingQuery ?
+               mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
+               mw.msg( 'rcfilters-savedqueries-already-saved' ) );
+
+       if ( this.matchingQuery ) {
+               this.emphasize();
+       }
+};
+
+/**
+ * Respond to model itemUpdate event
+ * fixme: when a new state is applied to the model this function is called 60+ times in a row
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item Filter item model
+ */
+FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
+       if ( !item.getGroupModel().isHidden() ) {
+               if (
+                       item.isSelected() ||
+                       (
+                               this.model.isHighlightEnabled() &&
+                               item.getHighlightColor()
+                       )
+               ) {
+                       this.addTag( item.getName(), item.getLabel() );
+               } else {
+                       // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
+                       if ( this.findItemFromData( item.getName() ) !== null ) {
+                               this.removeTagByData( item.getName() );
                        }
                }
-
-               this.setSavedQueryVisibility();
-
-               // Re-evaluate reset state
-               this.reevaluateResetRestoreState();
-       };
-
-       /**
-        * @inheritdoc
-        */
-       FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
-               return (
-                       this.model.getItemByName( data ) &&
-                       !this.isDuplicateData( data )
-               );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
-               this.controller.toggleFilterSelect( item.model.getName() );
-
-               // Select the tag if it exists, or reset selection otherwise
-               this.selectTag( this.findItemFromData( item.model.getName() ) );
-
-               this.focus();
-       };
-
-       /**
-        * Respond to highlightChange event
-        *
-        * @param {boolean} isHighlightEnabled Highlight is enabled
-        */
-       FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
-               var highlightedItems = this.model.getHighlightedItems();
-
-               if ( isHighlightEnabled ) {
-                       // Add capsule widgets
-                       highlightedItems.forEach( function ( filterItem ) {
-                               this.addTag( filterItem.getName(), filterItem.getLabel() );
-                       }.bind( this ) );
-               } else {
-                       // Remove capsule widgets if they're not selected
-                       highlightedItems.forEach( function ( filterItem ) {
-                               if ( !filterItem.isSelected() ) {
-                                       // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
-                                       if ( this.findItemFromData( filterItem.getName() ) !== null ) {
-                                               this.removeTagByData( filterItem.getName() );
-                                       }
+       }
+
+       this.setSavedQueryVisibility();
+
+       // Re-evaluate reset state
+       this.reevaluateResetRestoreState();
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
+       return (
+               this.model.getItemByName( data ) &&
+               !this.isDuplicateData( data )
+       );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
+       this.controller.toggleFilterSelect( item.model.getName() );
+
+       // Select the tag if it exists, or reset selection otherwise
+       this.selectTag( this.findItemFromData( item.model.getName() ) );
+
+       this.focus();
+};
+
+/**
+ * Respond to highlightChange event
+ *
+ * @param {boolean} isHighlightEnabled Highlight is enabled
+ */
+FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
+       var highlightedItems = this.model.getHighlightedItems();
+
+       if ( isHighlightEnabled ) {
+               // Add capsule widgets
+               highlightedItems.forEach( function ( filterItem ) {
+                       this.addTag( filterItem.getName(), filterItem.getLabel() );
+               }.bind( this ) );
+       } else {
+               // Remove capsule widgets if they're not selected
+               highlightedItems.forEach( function ( filterItem ) {
+                       if ( !filterItem.isSelected() ) {
+                               // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
+                               if ( this.findItemFromData( filterItem.getName() ) !== null ) {
+                                       this.removeTagByData( filterItem.getName() );
                                }
-                       }.bind( this ) );
-               }
-
-               this.setSavedQueryVisibility();
-       };
-
-       /**
-        * @inheritdoc
-        */
-       FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
-               var menuOption = this.menu.getItemFromModel( tagItem.getModel() );
-
-               this.menu.setUserSelecting( true );
-               // Parent method
-               FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
-
-               // Switch view
-               this.controller.resetSearchForView( tagItem.getView() );
-
-               this.selectTag( tagItem );
-               this.scrollToTop( menuOption.$element );
-
-               this.menu.setUserSelecting( false );
-       };
-
-       /**
-        * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
-        * If no items are given, reset selection from all.
-        *
-        * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
-        *  omit to deselect all
-        */
-       FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
-               var i, len, selected;
-
-               for ( i = 0, len = this.items.length; i < len; i++ ) {
-                       selected = this.items[ i ] === item;
-                       if ( this.items[ i ].isSelected() !== selected ) {
-                               this.items[ i ].toggleSelected( selected );
                        }
+               }.bind( this ) );
+       }
+
+       this.setSavedQueryVisibility();
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
+       var menuOption = this.menu.getItemFromModel( tagItem.getModel() );
+
+       this.menu.setUserSelecting( true );
+       // Parent method
+       FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
+
+       // Switch view
+       this.controller.resetSearchForView( tagItem.getView() );
+
+       this.selectTag( tagItem );
+       this.scrollToTop( menuOption.$element );
+
+       this.menu.setUserSelecting( false );
+};
+
+/**
+ * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
+ * If no items are given, reset selection from all.
+ *
+ * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
+ *  omit to deselect all
+ */
+FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
+       var i, len, selected;
+
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               selected = this.items[ i ] === item;
+               if ( this.items[ i ].isSelected() !== selected ) {
+                       this.items[ i ].toggleSelected( selected );
                }
-       };
-       /**
-        * @inheritdoc
-        */
-       FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
-               // Parent method
-               FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
-
-               this.controller.clearFilter( tagItem.getName() );
-
-               tagItem.destroy();
-       };
-
-       /**
-        * Respond to click event on the reset button
-        */
-       FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
-               if ( this.model.areVisibleFiltersEmpty() ) {
-                       // Reset to default filters
-                       this.controller.resetToDefaults();
-               } else {
-                       // Reset to have no filters
-                       this.controller.emptyFilters();
-               }
-       };
-
-       /**
-        * Respond to hide/show button click
-        */
-       FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
-               this.toggleCollapsed();
-       };
-
-       /**
-        * Toggle the collapsed state of the filters widget
-        *
-        * @param {boolean} isCollapsed Widget is collapsed
-        */
-       FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
-               isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;
-
-               this.collapsed = isCollapsed;
-
-               if ( isCollapsed ) {
-                       // If we are collapsing, close the menu, in case it was open
-                       // We should make sure the menu closes before the rest of the elements
-                       // are hidden, otherwise there is an unknown error in jQuery as ooui
-                       // sets and unsets properties on the input (which is hidden at that point)
-                       this.menu.toggle( false );
-               }
-               this.input.setDisabled( isCollapsed );
-               this.hideShowButton.setLabel( mw.msg(
-                       isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
-               ) );
-               this.hideShowButton.setTitle( mw.msg(
-                       isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
-               ) );
-
-               // Toggle the wrapper class, so we have min height values correctly throughout
-               this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );
-
-               // Save the state
-               this.controller.updateCollapsedState( isCollapsed );
-       };
-
-       /**
-        * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
-        */
-       FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
-               var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
-                       currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
-                       hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
-
-               this.resetButton.setIcon(
-                       currFiltersAreEmpty ? 'history' : 'trash'
-               );
-
-               this.resetButton.setLabel(
-                       currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
-               );
-               this.resetButton.setTitle(
-                       currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
-               );
-
-               this.resetButton.toggle( !hideResetButton );
-               this.emptyFilterMessage.toggle( currFiltersAreEmpty );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
-               return new MenuSelectWidget(
+       }
+};
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
+       // Parent method
+       FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
+
+       this.controller.clearFilter( tagItem.getName() );
+
+       tagItem.destroy();
+};
+
+/**
+ * Respond to click event on the reset button
+ */
+FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
+       if ( this.model.areVisibleFiltersEmpty() ) {
+               // Reset to default filters
+               this.controller.resetToDefaults();
+       } else {
+               // Reset to have no filters
+               this.controller.emptyFilters();
+       }
+};
+
+/**
+ * Respond to hide/show button click
+ */
+FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
+       this.toggleCollapsed();
+};
+
+/**
+ * Toggle the collapsed state of the filters widget
+ *
+ * @param {boolean} isCollapsed Widget is collapsed
+ */
+FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
+       isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;
+
+       this.collapsed = isCollapsed;
+
+       if ( isCollapsed ) {
+               // If we are collapsing, close the menu, in case it was open
+               // We should make sure the menu closes before the rest of the elements
+               // are hidden, otherwise there is an unknown error in jQuery as ooui
+               // sets and unsets properties on the input (which is hidden at that point)
+               this.menu.toggle( false );
+       }
+       this.input.setDisabled( isCollapsed );
+       this.hideShowButton.setLabel( mw.msg(
+               isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
+       ) );
+       this.hideShowButton.setTitle( mw.msg(
+               isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
+       ) );
+
+       // Toggle the wrapper class, so we have min height values correctly throughout
+       this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );
+
+       // Save the state
+       this.controller.updateCollapsedState( isCollapsed );
+};
+
+/**
+ * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
+ */
+FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
+       var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
+               currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
+               hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
+
+       this.resetButton.setIcon(
+               currFiltersAreEmpty ? 'history' : 'trash'
+       );
+
+       this.resetButton.setLabel(
+               currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
+       );
+       this.resetButton.setTitle(
+               currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
+       );
+
+       this.resetButton.toggle( !hideResetButton );
+       this.emptyFilterMessage.toggle( currFiltersAreEmpty );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
+       return new MenuSelectWidget(
+               this.controller,
+               this.model,
+               menuConfig
+       );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
+       var filterItem = this.model.getItemByName( data );
+
+       if ( filterItem ) {
+               return new FilterTagItemWidget(
                        this.controller,
                        this.model,
-                       menuConfig
+                       this.model.getInvertModel(),
+                       filterItem,
+                       {
+                               $overlay: this.$overlay
+                       }
                );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
-               var filterItem = this.model.getItemByName( data );
-
-               if ( filterItem ) {
-                       return new FilterTagItemWidget(
-                               this.controller,
-                               this.model,
-                               this.model.getInvertModel(),
-                               filterItem,
-                               {
-                                       $overlay: this.$overlay
-                               }
-                       );
-               }
-       };
-
-       FilterTagMultiselectWidget.prototype.emphasize = function () {
-               if (
-                       !this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
-               ) {
+       }
+};
+
+FilterTagMultiselectWidget.prototype.emphasize = function () {
+       if (
+               !this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
+       ) {
+               this.$handle
+                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
+                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+
+               setTimeout( function () {
                        this.$handle
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+                               .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
 
                        setTimeout( function () {
                                this.$handle
-                                       .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
-
-                               setTimeout( function () {
-                                       this.$handle
-                                               .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
-                               }.bind( this ), 1000 );
-                       }.bind( this ), 500 );
-
-               }
-       };
-       /**
-        * Scroll the element to top within its container
-        *
-        * @private
-        * @param {jQuery} $element Element to position
-        * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
-        *  much space (in pixels) above the widget.
-        * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
-        * @param {number} [threshold.min] Minimum distance above the element
-        * @param {number} [threshold.max] Minimum distance below the element
-        */
-       FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
-               var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
-                       pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
-                       containerScrollTop = $( container ).scrollTop(),
-                       effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
-                       newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );
-
-               // Scroll to item
-               if (
-                       threshold === undefined ||
+                                       .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+                       }.bind( this ), 1000 );
+               }.bind( this ), 500 );
+
+       }
+};
+/**
+ * Scroll the element to top within its container
+ *
+ * @private
+ * @param {jQuery} $element Element to position
+ * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
+ *  much space (in pixels) above the widget.
+ * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
+ * @param {number} [threshold.min] Minimum distance above the element
+ * @param {number} [threshold.max] Minimum distance below the element
+ */
+FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
+       var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
+               pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
+               containerScrollTop = $( container ).scrollTop(),
+               effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
+               newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );
+
+       // Scroll to item
+       if (
+               threshold === undefined ||
+               (
+                       (
+                               threshold.min === undefined ||
+                               newScrollTop - containerScrollTop >= threshold.min
+                       ) &&
                        (
-                               (
-                                       threshold.min === undefined ||
-                                       newScrollTop - containerScrollTop >= threshold.min
-                               ) &&
-                               (
-                                       threshold.max === undefined ||
-                                       newScrollTop - containerScrollTop <= threshold.max
-                               )
+                               threshold.max === undefined ||
+                               newScrollTop - containerScrollTop <= threshold.max
                        )
-               ) {
-                       $( container ).animate( {
-                               scrollTop: newScrollTop
-                       } );
-               }
-       };
+               )
+       ) {
+               $( container ).animate( {
+                       scrollTop: newScrollTop
+               } );
+       }
+};
 
-       module.exports = FilterTagMultiselectWidget;
-}() );
+module.exports = FilterTagMultiselectWidget;
index cb297f6..ce9656e 100644 (file)
-( function () {
-       var FilterTagMultiselectWidget = require( './FilterTagMultiselectWidget.js' ),
-               LiveUpdateButtonWidget = require( './LiveUpdateButtonWidget.js' ),
-               ChangesLimitAndDateButtonWidget = require( './ChangesLimitAndDateButtonWidget.js' ),
-               FilterWrapperWidget;
-
-       /**
-        * List displaying all filter groups
-        *
-        * @class mw.rcfilters.ui.FilterWrapperWidget
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.PendingElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {Object} [config] Configuration object
-        * @cfg {Object} [filters] A definition of the filter groups in this list
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
-        *  system. If not given, falls back to this widget's $element
-        * @cfg {boolean} [collapsed] Filter area is collapsed
-        */
-       FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
-               controller, model, savedQueriesModel, changesListModel, config
-       ) {
-               var $bottom;
-               config = config || {};
-
-               // Parent
-               FilterWrapperWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.PendingElement.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-               this.queriesModel = savedQueriesModel;
-               this.changesListModel = changesListModel;
-               this.$overlay = config.$overlay || this.$element;
-               this.$wrapper = config.$wrapper || this.$element;
-
-               this.filterTagWidget = new FilterTagMultiselectWidget(
-                       this.controller,
-                       this.model,
-                       this.queriesModel,
-                       {
-                               $overlay: this.$overlay,
-                               collapsed: config.collapsed,
-                               $wrapper: this.$wrapper
-                       }
+var FilterTagMultiselectWidget = require( './FilterTagMultiselectWidget.js' ),
+       LiveUpdateButtonWidget = require( './LiveUpdateButtonWidget.js' ),
+       ChangesLimitAndDateButtonWidget = require( './ChangesLimitAndDateButtonWidget.js' ),
+       FilterWrapperWidget;
+
+/**
+ * List displaying all filter groups
+ *
+ * @class mw.rcfilters.ui.FilterWrapperWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.PendingElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {Object} [config] Configuration object
+ * @cfg {Object} [filters] A definition of the filter groups in this list
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+ *  system. If not given, falls back to this widget's $element
+ * @cfg {boolean} [collapsed] Filter area is collapsed
+ */
+FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
+       controller, model, savedQueriesModel, changesListModel, config
+) {
+       var $bottom;
+       config = config || {};
+
+       // Parent
+       FilterWrapperWidget.parent.call( this, config );
+       // Mixin constructors
+       OO.ui.mixin.PendingElement.call( this, config );
+
+       this.controller = controller;
+       this.model = model;
+       this.queriesModel = savedQueriesModel;
+       this.changesListModel = changesListModel;
+       this.$overlay = config.$overlay || this.$element;
+       this.$wrapper = config.$wrapper || this.$element;
+
+       this.filterTagWidget = new FilterTagMultiselectWidget(
+               this.controller,
+               this.model,
+               this.queriesModel,
+               {
+                       $overlay: this.$overlay,
+                       collapsed: config.collapsed,
+                       $wrapper: this.$wrapper
+               }
+       );
+
+       this.liveUpdateButton = new LiveUpdateButtonWidget(
+               this.controller,
+               this.changesListModel
+       );
+
+       this.numChangesAndDateWidget = new ChangesLimitAndDateButtonWidget(
+               this.controller,
+               this.model,
+               {
+                       $overlay: this.$overlay
+               }
+       );
+
+       this.showNewChangesLink = new OO.ui.ButtonWidget( {
+               icon: 'reload',
+               framed: false,
+               label: mw.msg( 'rcfilters-show-new-changes' ),
+               flags: [ 'progressive' ],
+               classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
+       } );
+
+       // Events
+       this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
+       this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
+       this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
+       this.showNewChangesLink.toggle( false );
+
+       // Initialize
+       this.$top = $( '<div>' )
+               .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
+
+       $bottom = $( '<div>' )
+               .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
+               .append(
+                       this.showNewChangesLink.$element,
+                       this.numChangesAndDateWidget.$element
                );
 
-               this.liveUpdateButton = new LiveUpdateButtonWidget(
-                       this.controller,
-                       this.changesListModel
-               );
+       if ( this.controller.pollingRate ) {
+               $bottom.prepend( this.liveUpdateButton.$element );
+       }
 
-               this.numChangesAndDateWidget = new ChangesLimitAndDateButtonWidget(
-                       this.controller,
-                       this.model,
-                       {
-                               $overlay: this.$overlay
-                       }
+       this.$element
+               .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
+               .append(
+                       this.$top,
+                       this.filterTagWidget.$element,
+                       $bottom
                );
-
-               this.showNewChangesLink = new OO.ui.ButtonWidget( {
-                       icon: 'reload',
-                       framed: false,
-                       label: mw.msg( 'rcfilters-show-new-changes' ),
-                       flags: [ 'progressive' ],
-                       classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
-               } );
-
-               // Events
-               this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
-               this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
-               this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
-               this.showNewChangesLink.toggle( false );
-
-               // Initialize
-               this.$top = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
-
-               $bottom = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
-                       .append(
-                               this.showNewChangesLink.$element,
-                               this.numChangesAndDateWidget.$element
-                       );
-
-               if ( this.controller.pollingRate ) {
-                       $bottom.prepend( this.liveUpdateButton.$element );
-               }
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
-                       .append(
-                               this.$top,
-                               this.filterTagWidget.$element,
-                               $bottom
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( FilterWrapperWidget, OO.ui.Widget );
-       OO.mixinClass( FilterWrapperWidget, OO.ui.mixin.PendingElement );
-
-       /* Methods */
-
-       /**
-        * Set the content of the top section
-        *
-        * @param {jQuery} $topSectionElement
-        */
-       FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
-               this.$top.append( $topSectionElement );
-       };
-
-       /**
-        * Respond to the user clicking the 'show new changes' button
-        */
-       FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
-               this.controller.showNewChanges();
-       };
-
-       /**
-        * Respond to changes list model newChangesExist
-        *
-        * @param {boolean} newChangesExist Whether new changes exist
-        */
-       FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
-               this.showNewChangesLink.toggle( newChangesExist );
-       };
-
-       module.exports = FilterWrapperWidget;
-}() );
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterWrapperWidget, OO.ui.Widget );
+OO.mixinClass( FilterWrapperWidget, OO.ui.mixin.PendingElement );
+
+/* Methods */
+
+/**
+ * Set the content of the top section
+ *
+ * @param {jQuery} $topSectionElement
+ */
+FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
+       this.$top.append( $topSectionElement );
+};
+
+/**
+ * Respond to the user clicking the 'show new changes' button
+ */
+FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
+       this.controller.showNewChanges();
+};
+
+/**
+ * Respond to changes list model newChangesExist
+ *
+ * @param {boolean} newChangesExist Whether new changes exist
+ */
+FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
+       this.showNewChangesLink.toggle( newChangesExist );
+};
+
+module.exports = FilterWrapperWidget;
index dbf1776..7d69fb6 100644 (file)
-( function () {
-       /**
-        * Wrapper for the RC form with hide/show links
-        * Must be constructed after the model is initialized.
-        *
-        * @class mw.rcfilters.ui.FormWrapperWidget
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
-        * @param {mw.rcfilters.Controller} controller RCfilters controller
-        * @param {jQuery} $formRoot Root element of the form to attach to
-        * @param {Object} config Configuration object
-        */
-       var FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
-               config = config || {};
-
-               // Parent
-               FormWrapperWidget.parent.call( this, $.extend( {}, config, {
-                       $element: $formRoot
-               } ) );
-
-               this.changeListModel = changeListModel;
-               this.filtersModel = filtersModel;
-               this.controller = controller;
-               this.$submitButton = this.$element.find( 'form input[type=submit]' );
-
-               this.$element
-                       .on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );
-
-               this.$element
-                       .on( 'submit', 'form', this.onFormSubmit.bind( this ) );
-
-               // Events
-               this.changeListModel.connect( this, {
-                       invalidate: 'onChangesModelInvalidate',
-                       update: 'onChangesModelUpdate'
-               } );
-
-               // Initialize
-               this.cleanUpFieldset();
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( FormWrapperWidget, OO.ui.Widget );
-
-       /**
-        * Respond to link click
-        *
-        * @param {jQuery.Event} e Event
-        * @return {boolean} false
-        */
-       FormWrapperWidget.prototype.onLinkClick = function ( e ) {
-               this.controller.updateChangesList( $( e.target ).data( 'params' ) );
-               return false;
-       };
-
-       /**
-        * Respond to form submit event
-        *
-        * @param {jQuery.Event} e Event
-        * @return {boolean} false
-        */
-       FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
-               var data = {};
-
-               // Collect all data from form
-               $( e.target ).find( 'input:not([type="hidden"],[type="submit"]), select' ).each( function () {
-                       var value = '';
-
-                       if ( !$( this ).is( ':checkbox' ) || $( this ).is( ':checked' ) ) {
-                               value = $( this ).val();
-                       }
-
-                       data[ $( this ).prop( 'name' ) ] = value;
-               } );
-
-               this.controller.updateChangesList( data );
-               return false;
-       };
-
-       /**
-        * Respond to model invalidate
-        */
-       FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
-               this.$submitButton.prop( 'disabled', true );
-       };
-
-       /**
-        * Respond to model update, replace the show/hide links with the ones from the
-        * server so they feature the correct state.
-        *
-        * @param {jQuery|string} $changesList Updated changes list
-        * @param {jQuery} $fieldset Updated fieldset
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
-        */
-       FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
-               this.$submitButton.prop( 'disabled', false );
-
-               // Replace the entire fieldset
-               this.$element.empty().append( $fieldset.contents() );
-
-               if ( !isInitialDOM ) {
-                       // Make sure enhanced RC re-initializes correctly
-                       mw.hook( 'wikipage.content' ).fire( this.$element );
+/**
+ * Wrapper for the RC form with hide/show links
+ * Must be constructed after the model is initialized.
+ *
+ * @class mw.rcfilters.ui.FormWrapperWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
+ * @param {mw.rcfilters.Controller} controller RCfilters controller
+ * @param {jQuery} $formRoot Root element of the form to attach to
+ * @param {Object} config Configuration object
+ */
+var FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
+       config = config || {};
+
+       // Parent
+       FormWrapperWidget.parent.call( this, $.extend( {}, config, {
+               $element: $formRoot
+       } ) );
+
+       this.changeListModel = changeListModel;
+       this.filtersModel = filtersModel;
+       this.controller = controller;
+       this.$submitButton = this.$element.find( 'form input[type=submit]' );
+
+       this.$element
+               .on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );
+
+       this.$element
+               .on( 'submit', 'form', this.onFormSubmit.bind( this ) );
+
+       // Events
+       this.changeListModel.connect( this, {
+               invalidate: 'onChangesModelInvalidate',
+               update: 'onChangesModelUpdate'
+       } );
+
+       // Initialize
+       this.cleanUpFieldset();
+       this.$element
+               .addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
+};
+
+/* Initialization */
+
+OO.inheritClass( FormWrapperWidget, OO.ui.Widget );
+
+/**
+ * Respond to link click
+ *
+ * @param {jQuery.Event} e Event
+ * @return {boolean} false
+ */
+FormWrapperWidget.prototype.onLinkClick = function ( e ) {
+       this.controller.updateChangesList( $( e.target ).data( 'params' ) );
+       return false;
+};
+
+/**
+ * Respond to form submit event
+ *
+ * @param {jQuery.Event} e Event
+ * @return {boolean} false
+ */
+FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
+       var data = {};
+
+       // Collect all data from form
+       $( e.target ).find( 'input:not([type="hidden"],[type="submit"]), select' ).each( function () {
+               var value = '';
+
+               if ( !$( this ).is( ':checkbox' ) || $( this ).is( ':checked' ) ) {
+                       value = $( this ).val();
                }
 
-               this.cleanUpFieldset();
-       };
-
-       /**
-        * Clean up the old-style show/hide that we have implemented in the filter list
-        */
-       FormWrapperWidget.prototype.cleanUpFieldset = function () {
-               this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
-                       // HACK: Remove the text node after the span.
-                       // If there isn't one, we're at the end, so remove the text node before the span.
-                       // This would be unnecessary if we added separators with CSS.
-                       if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
-                               this.parentNode.removeChild( this.nextSibling );
-                       } else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
-                               this.parentNode.removeChild( this.previousSibling );
-                       }
-                       // Remove the span itself
-                       this.parentNode.removeChild( this );
-               } );
-
-               // Hide namespaces and tags
-               this.$element.find( '.namespaceForm' ).detach();
-               this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
-
-               // Hide Related Changes page name form
-               this.$element.find( '.targetForm' ).detach();
-
-               // misc: limit, days, watchlist info msg
-               this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();
-
-               if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
-                       this.$element.find( '.mw-recentchanges-table' ).detach();
-                       this.$element.find( 'hr' ).detach();
+               data[ $( this ).prop( 'name' ) ] = value;
+       } );
+
+       this.controller.updateChangesList( data );
+       return false;
+};
+
+/**
+ * Respond to model invalidate
+ */
+FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
+       this.$submitButton.prop( 'disabled', true );
+};
+
+/**
+ * Respond to model update, replace the show/hide links with the ones from the
+ * server so they feature the correct state.
+ *
+ * @param {jQuery|string} $changesList Updated changes list
+ * @param {jQuery} $fieldset Updated fieldset
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
+ */
+FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
+       this.$submitButton.prop( 'disabled', false );
+
+       // Replace the entire fieldset
+       this.$element.empty().append( $fieldset.contents() );
+
+       if ( !isInitialDOM ) {
+               // Make sure enhanced RC re-initializes correctly
+               mw.hook( 'wikipage.content' ).fire( this.$element );
+       }
+
+       this.cleanUpFieldset();
+};
+
+/**
+ * Clean up the old-style show/hide that we have implemented in the filter list
+ */
+FormWrapperWidget.prototype.cleanUpFieldset = function () {
+       this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
+               // HACK: Remove the text node after the span.
+               // If there isn't one, we're at the end, so remove the text node before the span.
+               // This would be unnecessary if we added separators with CSS.
+               if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
+                       this.parentNode.removeChild( this.nextSibling );
+               } else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
+                       this.parentNode.removeChild( this.previousSibling );
                }
-
-               // Get rid of all <br>s, which are inside rcshowhide
-               // If we still have content in rcshowhide, the <br>s are
-               // gone. Instead, the CSS now has a rule to mark all <span>s
-               // inside .rcshowhide with display:block; to simulate newlines
-               // where they're actually needed.
-               this.$element.find( 'br' ).detach();
-               if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
-                       this.$element.find( '.rcshowhide' ).detach();
-               }
-
-               if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
-                       this.$element.find( '.cloption-submit' ).detach();
-               }
-
-               this.$element.find(
-                       '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
-               ).detach();
-
-               // Get rid of the legend
-               this.$element.find( 'legend' ).detach();
-
-               // Check if the element is essentially empty, and detach it if it is
-               if ( !this.$element.text().trim().length ) {
-                       this.$element.detach();
-               }
-       };
-
-       module.exports = FormWrapperWidget;
-}() );
+               // Remove the span itself
+               this.parentNode.removeChild( this );
+       } );
+
+       // Hide namespaces and tags
+       this.$element.find( '.namespaceForm' ).detach();
+       this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
+
+       // Hide Related Changes page name form
+       this.$element.find( '.targetForm' ).detach();
+
+       // misc: limit, days, watchlist info msg
+       this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();
+
+       if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
+               this.$element.find( '.mw-recentchanges-table' ).detach();
+               this.$element.find( 'hr' ).detach();
+       }
+
+       // Get rid of all <br>s, which are inside rcshowhide
+       // If we still have content in rcshowhide, the <br>s are
+       // gone. Instead, the CSS now has a rule to mark all <span>s
+       // inside .rcshowhide with display:block; to simulate newlines
+       // where they're actually needed.
+       this.$element.find( 'br' ).detach();
+       if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
+               this.$element.find( '.rcshowhide' ).detach();
+       }
+
+       if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
+               this.$element.find( '.cloption-submit' ).detach();
+       }
+
+       this.$element.find(
+               '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
+       ).detach();
+
+       // Get rid of the legend
+       this.$element.find( 'legend' ).detach();
+
+       // Check if the element is essentially empty, and detach it if it is
+       if ( !this.$element.text().trim().length ) {
+               this.$element.detach();
+       }
+};
+
+module.exports = FormWrapperWidget;
index 73b874c..6634e30 100644 (file)
@@ -1,45 +1,43 @@
-( function () {
-       /**
-        * A group widget to allow for aggregation of events
-        *
-        * @class mw.rcfilters.ui.GroupWidget
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {Object} [config] Configuration object
-        * @param {Object} [events] Events to aggregate. The object represent the
-        *  event name to aggregate and the event value to emit on aggregate for items.
-        */
-       var GroupWidget = function MwRcfiltersUiViewSwitchWidget( config ) {
-               var aggregate = {};
-
-               config = config || {};
-
-               // Parent constructor
-               GroupWidget.parent.call( this, config );
-
-               // Mixin constructors
-               OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
-
-               if ( config.events ) {
-                       // Aggregate events
-                       // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( config.events, function ( eventName, eventEmit ) {
-                               aggregate[ eventName ] = eventEmit;
-                       } );
-
-                       this.aggregate( aggregate );
-               }
-
-               if ( Array.isArray( config.items ) ) {
-                       this.addItems( config.items );
-               }
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( GroupWidget, OO.ui.Widget );
-       OO.mixinClass( GroupWidget, OO.ui.mixin.GroupWidget );
-
-       module.exports = GroupWidget;
-}() );
+/**
+ * A group widget to allow for aggregation of events
+ *
+ * @class mw.rcfilters.ui.GroupWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration object
+ * @param {Object} [events] Events to aggregate. The object represent the
+ *  event name to aggregate and the event value to emit on aggregate for items.
+ */
+var GroupWidget = function MwRcfiltersUiViewSwitchWidget( config ) {
+       var aggregate = {};
+
+       config = config || {};
+
+       // Parent constructor
+       GroupWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+       if ( config.events ) {
+               // Aggregate events
+               // eslint-disable-next-line no-jquery/no-each-util
+               $.each( config.events, function ( eventName, eventEmit ) {
+                       aggregate[ eventName ] = eventEmit;
+               } );
+
+               this.aggregate( aggregate );
+       }
+
+       if ( Array.isArray( config.items ) ) {
+               this.addItems( config.items );
+       }
+};
+
+/* Initialize */
+
+OO.inheritClass( GroupWidget, OO.ui.Widget );
+OO.mixinClass( GroupWidget, OO.ui.mixin.GroupWidget );
+
+module.exports = GroupWidget;
index cb5f8eb..082b65b 100644 (file)
-( function () {
-       /**
-        * A widget representing a filter item highlight color picker
-        *
-        * @class mw.rcfilters.ui.HighlightColorPickerWidget
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.LabelElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {Object} [config] Configuration object
-        */
-       var HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
-               var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
-               config = config || {};
-
-               // Parent
-               HighlightColorPickerWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
-                       label: mw.message( 'rcfilters-highlightmenu-title' ).text()
-               } ) );
-
-               this.controller = controller;
-
-               this.currentSelection = 'none';
-               this.buttonSelect = new OO.ui.ButtonSelectWidget( {
-                       items: colors.map( function ( color ) {
-                               return new OO.ui.ButtonOptionWidget( {
-                                       icon: color === 'none' ? 'check' : null,
-                                       data: color,
-                                       classes: [
-                                               'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
-                                               'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
-                                       ],
-                                       framed: false
-                               } );
-                       } ),
-                       classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
-               } );
-
-               // Event
-               this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
-                       .append(
-                               this.$label
-                                       .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
-                               this.buttonSelect.$element
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( HighlightColorPickerWidget, OO.ui.Widget );
-       OO.mixinClass( HighlightColorPickerWidget, OO.ui.mixin.LabelElement );
-
-       /* Events */
-
-       /**
-        * @event chooseColor
-        * @param {string} The chosen color
-        *
-        * A color has been chosen
-        */
-
-       /* Methods */
-
-       /**
-        * Bind the color picker to an item
-        * @param {mw.rcfilters.dm.FilterItem} filterItem
-        */
-       HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
-               if ( this.filterItem ) {
-                       this.filterItem.disconnect( this );
+/**
+ * A widget representing a filter item highlight color picker
+ *
+ * @class mw.rcfilters.ui.HighlightColorPickerWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {Object} [config] Configuration object
+ */
+var HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
+       var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
+       config = config || {};
+
+       // Parent
+       HighlightColorPickerWidget.parent.call( this, config );
+       // Mixin constructors
+       OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
+               label: mw.message( 'rcfilters-highlightmenu-title' ).text()
+       } ) );
+
+       this.controller = controller;
+
+       this.currentSelection = 'none';
+       this.buttonSelect = new OO.ui.ButtonSelectWidget( {
+               items: colors.map( function ( color ) {
+                       return new OO.ui.ButtonOptionWidget( {
+                               icon: color === 'none' ? 'check' : null,
+                               data: color,
+                               classes: [
+                                       'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
+                                       'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
+                               ],
+                               framed: false
+                       } );
+               } ),
+               classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
+       } );
+
+       // Event
+       this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
+
+       this.$element
+               .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
+               .append(
+                       this.$label
+                               .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
+                       this.buttonSelect.$element
+               );
+};
+
+/* Initialization */
+
+OO.inheritClass( HighlightColorPickerWidget, OO.ui.Widget );
+OO.mixinClass( HighlightColorPickerWidget, OO.ui.mixin.LabelElement );
+
+/* Events */
+
+/**
+ * @event chooseColor
+ * @param {string} The chosen color
+ *
+ * A color has been chosen
+ */
+
+/* Methods */
+
+/**
+ * Bind the color picker to an item
+ * @param {mw.rcfilters.dm.FilterItem} filterItem
+ */
+HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
+       if ( this.filterItem ) {
+               this.filterItem.disconnect( this );
+       }
+
+       this.filterItem = filterItem;
+       this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
+       this.updateUiBasedOnModel();
+};
+
+/**
+ * Respond to item model update event
+ */
+HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
+       this.selectColor( this.filterItem.getHighlightColor() || 'none' );
+};
+
+/**
+ * Select the color for this widget
+ *
+ * @param {string} color Selected color
+ */
+HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
+       var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
+               selectedItem = this.buttonSelect.findItemFromData( color );
+
+       if ( this.currentSelection !== color ) {
+               this.currentSelection = color;
+
+               this.buttonSelect.selectItem( selectedItem );
+               if ( previousItem ) {
+                       previousItem.setIcon( null );
                }
 
-               this.filterItem = filterItem;
-               this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
-               this.updateUiBasedOnModel();
-       };
-
-       /**
-        * Respond to item model update event
-        */
-       HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
-               this.selectColor( this.filterItem.getHighlightColor() || 'none' );
-       };
-
-       /**
-        * Select the color for this widget
-        *
-        * @param {string} color Selected color
-        */
-       HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
-               var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
-                       selectedItem = this.buttonSelect.findItemFromData( color );
-
-               if ( this.currentSelection !== color ) {
-                       this.currentSelection = color;
-
-                       this.buttonSelect.selectItem( selectedItem );
-                       if ( previousItem ) {
-                               previousItem.setIcon( null );
-                       }
-
-                       if ( selectedItem ) {
-                               selectedItem.setIcon( 'check' );
-                       }
+               if ( selectedItem ) {
+                       selectedItem.setIcon( 'check' );
                }
-       };
-
-       HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
-               var color = button.data;
-               if ( color === 'none' ) {
-                       this.controller.clearHighlightColor( this.filterItem.getName() );
-               } else {
-                       this.controller.setHighlightColor( this.filterItem.getName(), color );
-               }
-               this.emit( 'chooseColor', color );
-       };
-
-       module.exports = HighlightColorPickerWidget;
-}() );
+       }
+};
+
+HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
+       var color = button.data;
+       if ( color === 'none' ) {
+               this.controller.clearHighlightColor( this.filterItem.getName() );
+       } else {
+               this.controller.setHighlightColor( this.filterItem.getName(), color );
+       }
+       this.emit( 'chooseColor', color );
+};
+
+module.exports = HighlightColorPickerWidget;
index 4c467df..5a69013 100644 (file)
@@ -1,68 +1,65 @@
-( function () {
-       var HighlightColorPickerWidget = require( './HighlightColorPickerWidget.js' ),
-               HighlightPopupWidget;
-       /**
-        * A popup containing a color picker, for setting highlight colors.
-        *
-        * @class mw.rcfilters.ui.HighlightPopupWidget
-        * @extends OO.ui.PopupWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {Object} [config] Configuration object
-        */
-       HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
-               config = config || {};
+var HighlightColorPickerWidget = require( './HighlightColorPickerWidget.js' ),
+       HighlightPopupWidget;
+/**
+ * A popup containing a color picker, for setting highlight colors.
+ *
+ * @class mw.rcfilters.ui.HighlightPopupWidget
+ * @extends OO.ui.PopupWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {Object} [config] Configuration object
+ */
+HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
+       config = config || {};
 
-               // Parent
-               HighlightPopupWidget.parent.call( this, $.extend( {
-                       autoClose: true,
-                       anchor: false,
-                       padded: true,
-                       align: 'backwards',
-                       horizontalPosition: 'end',
-                       width: 290
-               }, config ) );
+       // Parent
+       HighlightPopupWidget.parent.call( this, $.extend( {
+               autoClose: true,
+               anchor: false,
+               padded: true,
+               align: 'backwards',
+               horizontalPosition: 'end',
+               width: 290
+       }, config ) );
 
-               this.colorPicker = new HighlightColorPickerWidget( controller );
+       this.colorPicker = new HighlightColorPickerWidget( controller );
 
-               this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
+       this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
 
-               this.$body.append( this.colorPicker.$element );
-       };
+       this.$body.append( this.colorPicker.$element );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget );
+OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget );
 
-       /* Methods */
+/* Methods */
 
-       /**
       * Set the button (or other widget) that this popup should hang off.
       *
       * @param {OO.ui.Widget} widget Widget the popup should orient itself to
       */
-       HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
-               this.setFloatableContainer( widget.$element );
-               this.$autoCloseIgnore = widget.$element;
-       };
+/**
+ * Set the button (or other widget) that this popup should hang off.
+ *
+ * @param {OO.ui.Widget} widget Widget the popup should orient itself to
+ */
+HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
+       this.setFloatableContainer( widget.$element );
+       this.$autoCloseIgnore = widget.$element;
+};
 
-       /**
       * Set the filter item that this popup should control the highlight color for.
       *
       * @param {mw.rcfilters.dm.FilterItem} item
       */
-       HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
-               this.colorPicker.setFilterItem( item );
-       };
+/**
+ * Set the filter item that this popup should control the highlight color for.
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item
+ */
+HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
+       this.colorPicker.setFilterItem( item );
+};
 
-       /**
       * When the user chooses a color in the color picker, close the popup.
       */
-       HighlightPopupWidget.prototype.onChooseColor = function () {
-               this.toggle( false );
-       };
+/**
+ * When the user chooses a color in the color picker, close the popup.
+ */
+HighlightPopupWidget.prototype.onChooseColor = function () {
+       this.toggle( false );
+};
 
-       module.exports = HighlightPopupWidget;
-
-}() );
+module.exports = HighlightPopupWidget;
index 56ed628..710bd65 100644 (file)
-( function () {
-       var FilterItemHighlightButton = require( './FilterItemHighlightButton.js' ),
-               CheckboxInputWidget = require( './CheckboxInputWidget.js' ),
-               ItemMenuOptionWidget;
-
-       /**
-        * A widget representing a base toggle item
-        *
-        * @class mw.rcfilters.ui.ItemMenuOptionWidget
-        * @extends OO.ui.MenuOptionWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.ItemModel} invertModel
-        * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
-        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
-        * @param {Object} config Configuration object
-        */
-       ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
-               controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
-       ) {
-               var layout,
-                       classes = [],
-                       $label = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.filtersViewModel = filtersViewModel;
-               this.invertModel = invertModel;
-               this.itemModel = itemModel;
-
-               // Parent
-               ItemMenuOptionWidget.parent.call( this, $.extend( {
-                       // Override the 'check' icon that OOUI defines
-                       icon: '',
-                       data: this.itemModel.getName(),
-                       label: this.itemModel.getLabel()
-               }, config ) );
-
-               this.checkboxWidget = new CheckboxInputWidget( {
-                       value: this.itemModel.getName(),
-                       selected: this.itemModel.isSelected()
-               } );
-
+var FilterItemHighlightButton = require( './FilterItemHighlightButton.js' ),
+       CheckboxInputWidget = require( './CheckboxInputWidget.js' ),
+       ItemMenuOptionWidget;
+
+/**
+ * A widget representing a base toggle item
+ *
+ * @class mw.rcfilters.ui.ItemMenuOptionWidget
+ * @extends OO.ui.MenuOptionWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.ItemModel} invertModel
+ * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
+ * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
+ * @param {Object} config Configuration object
+ */
+ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
+       controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
+) {
+       var layout,
+               classes = [],
+               $label = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
+
+       config = config || {};
+
+       this.controller = controller;
+       this.filtersViewModel = filtersViewModel;
+       this.invertModel = invertModel;
+       this.itemModel = itemModel;
+
+       // Parent
+       ItemMenuOptionWidget.parent.call( this, $.extend( {
+               // Override the 'check' icon that OOUI defines
+               icon: '',
+               data: this.itemModel.getName(),
+               label: this.itemModel.getLabel()
+       }, config ) );
+
+       this.checkboxWidget = new CheckboxInputWidget( {
+               value: this.itemModel.getName(),
+               selected: this.itemModel.isSelected()
+       } );
+
+       $label.append(
+               $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
+                       .append( $( '<bdi>' ).append( this.$label ) )
+       );
+       if ( this.itemModel.getDescription() ) {
                $label.append(
                        $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
-                               .append( $( '<bdi>' ).append( this.$label ) )
+                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
+                               .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
                );
-               if ( this.itemModel.getDescription() ) {
-                       $label.append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
-                                       .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
-                       );
+       }
+
+       this.highlightButton = new FilterItemHighlightButton(
+               this.controller,
+               this.itemModel,
+               highlightPopup,
+               {
+                       $overlay: config.$overlay || this.$element,
+                       title: mw.msg( 'rcfilters-highlightmenu-help' )
                }
-
-               this.highlightButton = new FilterItemHighlightButton(
-                       this.controller,
-                       this.itemModel,
-                       highlightPopup,
-                       {
-                               $overlay: config.$overlay || this.$element,
-                               title: mw.msg( 'rcfilters-highlightmenu-help' )
-                       }
-               );
-               this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
-
-               this.excludeLabel = new OO.ui.LabelWidget( {
-                       label: mw.msg( 'rcfilters-filter-excluded' )
-               } );
-               this.excludeLabel.toggle(
-                       this.itemModel.getGroupModel().getView() === 'namespaces' &&
-                       this.itemModel.isSelected() &&
-                       this.invertModel.isSelected()
+       );
+       this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
+
+       this.excludeLabel = new OO.ui.LabelWidget( {
+               label: mw.msg( 'rcfilters-filter-excluded' )
+       } );
+       this.excludeLabel.toggle(
+               this.itemModel.getGroupModel().getView() === 'namespaces' &&
+               this.itemModel.isSelected() &&
+               this.invertModel.isSelected()
+       );
+
+       layout = new OO.ui.FieldLayout( this.checkboxWidget, {
+               label: $label,
+               align: 'inline'
+       } );
+
+       // Events
+       this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
+       this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
+       this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
+       // HACK: Prevent defaults on 'click' for the label so it
+       // doesn't steal the focus away from the input. This means
+       // we can continue arrow-movement after we click the label
+       // and is consistent with the checkbox *itself* also preventing
+       // defaults on 'click' as well.
+       layout.$label.on( 'click', false );
+
+       this.$element
+               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
+               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView() )
+               .append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-table' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-row' )
+                                               .append(
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
+                                                               .append( layout.$element ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
+                                                               .append( this.excludeLabel.$element ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
+                                                               .append( this.highlightButton.$element )
+                                               )
+                               )
                );
 
-               layout = new OO.ui.FieldLayout( this.checkboxWidget, {
-                       label: $label,
-                       align: 'inline'
+       if ( this.itemModel.getIdentifiers() ) {
+               this.itemModel.getIdentifiers().forEach( function ( ident ) {
+                       classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
                } );
 
-               // Events
-               this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
-               this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
-               this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
-               // HACK: Prevent defaults on 'click' for the label so it
-               // doesn't steal the focus away from the input. This means
-               // we can continue arrow-movement after we click the label
-               // and is consistent with the checkbox *itself* also preventing
-               // defaults on 'click' as well.
-               layout.$label.on( 'click', false );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
-                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView() )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
-                                                                       .append( layout.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
-                                                                       .append( this.excludeLabel.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
-                                                                       .append( this.highlightButton.$element )
-                                                       )
-                                       )
-                       );
-
-               if ( this.itemModel.getIdentifiers() ) {
-                       this.itemModel.getIdentifiers().forEach( function ( ident ) {
-                               classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
-                       } );
-
-                       this.$element.addClass( classes );
-               }
+               this.$element.addClass( classes );
+       }
 
-               this.updateUiBasedOnState();
-       };
+       this.updateUiBasedOnState();
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
+OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
 
-       /* Static properties */
+/* Static properties */
 
-       // We do our own scrolling to top
-       ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+// We do our own scrolling to top
+ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
 
-       /* Methods */
+/* Methods */
 
-       /**
-        * Respond to item model update event
-        */
-       ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
-               this.checkboxWidget.setSelected( this.itemModel.isSelected() );
-
-               this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
-               this.excludeLabel.toggle(
-                       this.itemModel.getGroupModel().getView() === 'namespaces' &&
-                       this.itemModel.isSelected() &&
-                       this.invertModel.isSelected()
-               );
-               this.toggle( this.itemModel.isVisible() );
-       };
+/**
+ * Respond to item model update event
+ */
+ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+       this.checkboxWidget.setSelected( this.itemModel.isSelected() );
 
-       /**
-        * Get the name of this filter
-        *
-        * @return {string} Filter name
-        */
-       ItemMenuOptionWidget.prototype.getName = function () {
-               return this.itemModel.getName();
-       };
+       this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
+       this.excludeLabel.toggle(
+               this.itemModel.getGroupModel().getView() === 'namespaces' &&
+               this.itemModel.isSelected() &&
+               this.invertModel.isSelected()
+       );
+       this.toggle( this.itemModel.isVisible() );
+};
 
-       ItemMenuOptionWidget.prototype.getModel = function () {
-               return this.itemModel;
-       };
+/**
+ * Get the name of this filter
+ *
+ * @return {string} Filter name
+ */
+ItemMenuOptionWidget.prototype.getName = function () {
+       return this.itemModel.getName();
+};
 
-       module.exports = ItemMenuOptionWidget;
+ItemMenuOptionWidget.prototype.getModel = function () {
+       return this.itemModel;
+};
 
-}() );
+module.exports = ItemMenuOptionWidget;
index 3ccb6e2..04289c7 100644 (file)
@@ -1,72 +1,69 @@
-( function () {
-       /**
-        * Widget for toggling live updates
-        *
-        * @class mw.rcfilters.ui.LiveUpdateButtonWidget
-        * @extends OO.ui.ToggleButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {Object} [config] Configuration object
-        */
-       var LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
-               config = config || {};
+/**
+ * Widget for toggling live updates
+ *
+ * @class mw.rcfilters.ui.LiveUpdateButtonWidget
+ * @extends OO.ui.ToggleButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {Object} [config] Configuration object
+ */
+var LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
+       config = config || {};
 
-               // Parent
-               LiveUpdateButtonWidget.parent.call( this, $.extend( {
-                       label: mw.message( 'rcfilters-liveupdates-button' ).text()
-               }, config ) );
+       // Parent
+       LiveUpdateButtonWidget.parent.call( this, $.extend( {
+               label: mw.message( 'rcfilters-liveupdates-button' ).text()
+       }, config ) );
 
-               this.controller = controller;
-               this.model = changesListModel;
+       this.controller = controller;
+       this.model = changesListModel;
 
-               // Events
-               this.connect( this, { click: 'onClick' } );
-               this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
+       // Events
+       this.connect( this, { click: 'onClick' } );
+       this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
 
-               this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
+       this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
 
-               this.setState( false );
-       };
+       this.setState( false );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
+OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
 
-       /* Methods */
+/* Methods */
 
-       /**
       * Respond to the button being clicked
       */
-       LiveUpdateButtonWidget.prototype.onClick = function () {
-               this.controller.toggleLiveUpdate();
-       };
+/**
+ * Respond to the button being clicked
+ */
+LiveUpdateButtonWidget.prototype.onClick = function () {
+       this.controller.toggleLiveUpdate();
+};
 
-       /**
       * Set the button's state and change its appearance
       *
       * @param {boolean} enable Whether the 'live update' feature is now on/off
       */
-       LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
-               this.setValue( enable );
-               this.setIcon( enable ? 'stop' : 'play' );
-               this.setTitle( mw.message(
-                       enable ?
-                               'rcfilters-liveupdates-button-title-on' :
-                               'rcfilters-liveupdates-button-title-off'
-               ).text() );
-       };
+/**
+ * Set the button's state and change its appearance
+ *
+ * @param {boolean} enable Whether the 'live update' feature is now on/off
+ */
+LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
+       this.setValue( enable );
+       this.setIcon( enable ? 'stop' : 'play' );
+       this.setTitle( mw.message(
+               enable ?
+                       'rcfilters-liveupdates-button-title-on' :
+                       'rcfilters-liveupdates-button-title-off'
+       ).text() );
+};
 
-       /**
       * Respond to the 'live update' feature being turned on/off
       *
       * @param {boolean} enable Whether the 'live update' feature is now on/off
       */
-       LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
-               this.setState( enable );
-       };
+/**
+ * Respond to the 'live update' feature being turned on/off
+ *
+ * @param {boolean} enable Whether the 'live update' feature is now on/off
+ */
+LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
+       this.setState( enable );
+};
 
-       module.exports = LiveUpdateButtonWidget;
-
-}() );
+module.exports = LiveUpdateButtonWidget;
index bc1cac8..31edb77 100644 (file)
-( function () {
-       var SavedLinksListWidget = require( './SavedLinksListWidget.js' ),
-               FilterWrapperWidget = require( './FilterWrapperWidget.js' ),
-               ChangesListWrapperWidget = require( './ChangesListWrapperWidget.js' ),
-               RcTopSectionWidget = require( './RcTopSectionWidget.js' ),
-               RclTopSectionWidget = require( './RclTopSectionWidget.js' ),
-               WatchlistTopSectionWidget = require( './WatchlistTopSectionWidget.js' ),
-               FormWrapperWidget = require( './FormWrapperWidget.js' ),
-               MainWrapperWidget;
-
-       /**
-        * Wrapper for changes list content
-        *
-        * @class mw.rcfilters.ui.MainWrapperWidget
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} $topSection Top section container
-        * @cfg {jQuery} $filtersContainer
-        * @cfg {jQuery} $changesListContainer
-        * @cfg {jQuery} $formContainer
-        * @cfg {boolean} [collapsed] Filter area is collapsed
-        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
-        *  system. If not given, falls back to this widget's $element
-        */
-       MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
-               controller, model, savedQueriesModel, changesListModel, config
-       ) {
-               config = $.extend( {}, config );
-
-               // Parent
-               MainWrapperWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-               this.changesListModel = changesListModel;
-               this.$topSection = config.$topSection;
-               this.$filtersContainer = config.$filtersContainer;
-               this.$changesListContainer = config.$changesListContainer;
-               this.$formContainer = config.$formContainer;
-               this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay' );
-               this.$wrapper = config.$wrapper || this.$element;
-
-               this.savedLinksListWidget = new SavedLinksListWidget(
-                       controller, savedQueriesModel, { $overlay: this.$overlay }
-               );
-
-               this.filtersWidget = new FilterWrapperWidget(
-                       controller,
-                       model,
-                       savedQueriesModel,
-                       changesListModel,
-                       {
-                               $overlay: this.$overlay,
-                               $wrapper: this.$wrapper,
-                               collapsed: config.collapsed
-                       }
-               );
-
-               this.changesListWidget = new ChangesListWrapperWidget(
-                       model, changesListModel, controller, this.$changesListContainer );
+var SavedLinksListWidget = require( './SavedLinksListWidget.js' ),
+       FilterWrapperWidget = require( './FilterWrapperWidget.js' ),
+       ChangesListWrapperWidget = require( './ChangesListWrapperWidget.js' ),
+       RcTopSectionWidget = require( './RcTopSectionWidget.js' ),
+       RclTopSectionWidget = require( './RclTopSectionWidget.js' ),
+       WatchlistTopSectionWidget = require( './WatchlistTopSectionWidget.js' ),
+       FormWrapperWidget = require( './FormWrapperWidget.js' ),
+       MainWrapperWidget;
+
+/**
+ * Wrapper for changes list content
+ *
+ * @class mw.rcfilters.ui.MainWrapperWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} $topSection Top section container
+ * @cfg {jQuery} $filtersContainer
+ * @cfg {jQuery} $changesListContainer
+ * @cfg {jQuery} $formContainer
+ * @cfg {boolean} [collapsed] Filter area is collapsed
+ * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+ *  system. If not given, falls back to this widget's $element
+ */
+MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
+       controller, model, savedQueriesModel, changesListModel, config
+) {
+       config = $.extend( {}, config );
+
+       // Parent
+       MainWrapperWidget.parent.call( this, config );
+
+       this.controller = controller;
+       this.model = model;
+       this.changesListModel = changesListModel;
+       this.$topSection = config.$topSection;
+       this.$filtersContainer = config.$filtersContainer;
+       this.$changesListContainer = config.$changesListContainer;
+       this.$formContainer = config.$formContainer;
+       this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay' );
+       this.$wrapper = config.$wrapper || this.$element;
+
+       this.savedLinksListWidget = new SavedLinksListWidget(
+               controller, savedQueriesModel, { $overlay: this.$overlay }
+       );
+
+       this.filtersWidget = new FilterWrapperWidget(
+               controller,
+               model,
+               savedQueriesModel,
+               changesListModel,
+               {
+                       $overlay: this.$overlay,
+                       $wrapper: this.$wrapper,
+                       collapsed: config.collapsed
+               }
+       );
 
-               /* Events */
+       this.changesListWidget = new ChangesListWrapperWidget(
+               model, changesListModel, controller, this.$changesListContainer );
 
-               // Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
-               // to prevent users from accidentally clicking on links in results, while menu is opened.
-               // Overlay on changes list is not the same as this.$overlay
-               this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );
+       /* Events */
 
-               // Initialize
-               this.$filtersContainer.append( this.filtersWidget.$element );
-               $( 'body' )
-                       .append( this.$overlay )
-                       .addClass( 'mw-rcfilters-ui-initialized' );
-       };
+       // Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
+       // to prevent users from accidentally clicking on links in results, while menu is opened.
+       // Overlay on changes list is not the same as this.$overlay
+       this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );
 
-       /* Initialization */
+       // Initialize
+       this.$filtersContainer.append( this.filtersWidget.$element );
+       $( 'body' )
+               .append( this.$overlay )
+               .addClass( 'mw-rcfilters-ui-initialized' );
+};
 
-       OO.inheritClass( MainWrapperWidget, OO.ui.Widget );
+/* Initialization */
 
-       /* Methods */
+OO.inheritClass( MainWrapperWidget, OO.ui.Widget );
 
-       /**
-        * Set the content of the top section, depending on the type of special page.
-        *
-        * @param {string} specialPage
-        */
-       MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
-               var topSection;
+/* Methods */
 
-               if ( specialPage === 'Recentchanges' ) {
-                       topSection = new RcTopSectionWidget(
-                               this.savedLinksListWidget, this.$topSection
-                       );
-                       this.filtersWidget.setTopSection( topSection.$element );
-               }
+/**
+ * Set the content of the top section, depending on the type of special page.
+ *
+ * @param {string} specialPage
+ */
+MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
+       var topSection;
 
-               if ( specialPage === 'Recentchangeslinked' ) {
-                       topSection = new RclTopSectionWidget(
-                               this.savedLinksListWidget, this.controller,
-                               this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
-                               this.model.getGroup( 'page' ).getItemByParamName( 'target' )
-                       );
+       if ( specialPage === 'Recentchanges' ) {
+               topSection = new RcTopSectionWidget(
+                       this.savedLinksListWidget, this.$topSection
+               );
+               this.filtersWidget.setTopSection( topSection.$element );
+       }
+
+       if ( specialPage === 'Recentchangeslinked' ) {
+               topSection = new RclTopSectionWidget(
+                       this.savedLinksListWidget, this.controller,
+                       this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
+                       this.model.getGroup( 'page' ).getItemByParamName( 'target' )
+               );
 
-                       this.filtersWidget.setTopSection( topSection.$element );
-               }
+               this.filtersWidget.setTopSection( topSection.$element );
+       }
 
-               if ( specialPage === 'Watchlist' ) {
-                       topSection = new WatchlistTopSectionWidget(
-                               this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
-                       );
+       if ( specialPage === 'Watchlist' ) {
+               topSection = new WatchlistTopSectionWidget(
+                       this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
+               );
 
-                       this.filtersWidget.setTopSection( topSection.$element );
-               }
-       };
-
-       /**
-        * Filter menu toggle event listener
-        *
-        * @param {boolean} isVisible
-        */
-       MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
-               this.changesListWidget.toggleOverlay( isVisible );
-       };
-
-       /**
-        * Initialize FormWrapperWidget
-        *
-        * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
-        */
-       MainWrapperWidget.prototype.initFormWidget = function () {
-               return new FormWrapperWidget(
-                       this.model, this.changesListModel, this.controller, this.$formContainer );
-       };
-
-       module.exports = MainWrapperWidget;
-}() );
+               this.filtersWidget.setTopSection( topSection.$element );
+       }
+};
+
+/**
+ * Filter menu toggle event listener
+ *
+ * @param {boolean} isVisible
+ */
+MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
+       this.changesListWidget.toggleOverlay( isVisible );
+};
+
+/**
+ * Initialize FormWrapperWidget
+ *
+ * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
+ */
+MainWrapperWidget.prototype.initFormWidget = function () {
+       return new FormWrapperWidget(
+               this.model, this.changesListModel, this.controller, this.$formContainer );
+};
+
+module.exports = MainWrapperWidget;
index 3914337..c7fa334 100644 (file)
@@ -1,58 +1,55 @@
-( function () {
-       /**
-        * Button for marking all changes as seen on the Watchlist
-        *
-        * @class mw.rcfilters.ui.MarkSeenButtonWidget
-        * @extends OO.ui.ButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
-        * @param {Object} [config] Configuration object
-        */
-       var MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
-               config = config || {};
-
-               // Parent
-               MarkSeenButtonWidget.parent.call( this, $.extend( {
-                       label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
-                       icon: 'checkAll'
-               }, config ) );
-
-               this.controller = controller;
-               this.model = model;
-
-               // Events
-               this.connect( this, { click: 'onClick' } );
-               this.model.connect( this, { update: 'onModelUpdate' } );
-
-               this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );
-
-               this.onModelUpdate();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( MarkSeenButtonWidget, OO.ui.ButtonWidget );
-
-       /* Methods */
-
-       /**
-        * Respond to the button being clicked
-        */
-       MarkSeenButtonWidget.prototype.onClick = function () {
-               this.controller.markAllChangesAsSeen();
-               // assume there's no more unseen changes until the next model update
-               this.setDisabled( true );
-       };
-
-       /**
-        * Respond to the model being updated with new changes
-        */
-       MarkSeenButtonWidget.prototype.onModelUpdate = function () {
-               this.setDisabled( !this.model.hasUnseenWatchedChanges() );
-       };
-
-       module.exports = MarkSeenButtonWidget;
-
-}() );
+/**
+ * Button for marking all changes as seen on the Watchlist
+ *
+ * @class mw.rcfilters.ui.MarkSeenButtonWidget
+ * @extends OO.ui.ButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
+ * @param {Object} [config] Configuration object
+ */
+var MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
+       config = config || {};
+
+       // Parent
+       MarkSeenButtonWidget.parent.call( this, $.extend( {
+               label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
+               icon: 'checkAll'
+       }, config ) );
+
+       this.controller = controller;
+       this.model = model;
+
+       // Events
+       this.connect( this, { click: 'onClick' } );
+       this.model.connect( this, { update: 'onModelUpdate' } );
+
+       this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );
+
+       this.onModelUpdate();
+};
+
+/* Initialization */
+
+OO.inheritClass( MarkSeenButtonWidget, OO.ui.ButtonWidget );
+
+/* Methods */
+
+/**
+ * Respond to the button being clicked
+ */
+MarkSeenButtonWidget.prototype.onClick = function () {
+       this.controller.markAllChangesAsSeen();
+       // assume there's no more unseen changes until the next model update
+       this.setDisabled( true );
+};
+
+/**
+ * Respond to the model being updated with new changes
+ */
+MarkSeenButtonWidget.prototype.onModelUpdate = function () {
+       this.setDisabled( !this.model.hasUnseenWatchedChanges() );
+};
+
+module.exports = MarkSeenButtonWidget;
index 864d0cf..1e75020 100644 (file)
-( function () {
-       var FilterMenuHeaderWidget = require( './FilterMenuHeaderWidget.js' ),
-               HighlightPopupWidget = require( './HighlightPopupWidget.js' ),
-               FilterMenuSectionOptionWidget = require( './FilterMenuSectionOptionWidget.js' ),
-               FilterMenuOptionWidget = require( './FilterMenuOptionWidget.js' ),
-               MenuSelectWidget;
-
-       /**
-        * A floating menu widget for the filter list
-        *
-        * @class mw.rcfilters.ui.MenuSelectWidget
-        * @extends OO.ui.MenuSelectWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        * @cfg {Object[]} [footers] An array of objects defining the footers for
-        *  this menu, with a definition whether they appear per specific views.
-        *  The expected structure is:
-        *  [
-        *     {
-        *        name: {string} A unique name for the footer object
-        *        $element: {jQuery} A jQuery object for the content of the footer
-        *        views: {string[]} Optional. An array stating which views this footer is
-        *               active on. Use null or omit to display this on all views.
-        *     }
-        *  ]
-        */
-       MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
-               var header;
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.currentView = '';
-               this.views = {};
-               this.userSelecting = false;
-
-               this.menuInitialized = false;
-               this.$overlay = config.$overlay || this.$element;
-               this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
-               this.footers = [];
-
-               // Parent
-               MenuSelectWidget.parent.call( this, $.extend( config, {
-                       $autoCloseIgnore: this.$overlay,
-                       width: 650,
-                       // Our filtering is done through the model
-                       filterFromInput: false
-               } ) );
-               this.setGroupElement(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
-               );
-               this.setClippableElement( this.$body );
-               this.setClippableContainer( this.$element );
-
-               header = new FilterMenuHeaderWidget(
-                       this.controller,
-                       this.model,
-                       {
-                               $overlay: this.$overlay
-                       }
+var FilterMenuHeaderWidget = require( './FilterMenuHeaderWidget.js' ),
+       HighlightPopupWidget = require( './HighlightPopupWidget.js' ),
+       FilterMenuSectionOptionWidget = require( './FilterMenuSectionOptionWidget.js' ),
+       FilterMenuOptionWidget = require( './FilterMenuOptionWidget.js' ),
+       MenuSelectWidget;
+
+/**
+ * A floating menu widget for the filter list
+ *
+ * @class mw.rcfilters.ui.MenuSelectWidget
+ * @extends OO.ui.MenuSelectWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} [config] Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ * @cfg {Object[]} [footers] An array of objects defining the footers for
+ *  this menu, with a definition whether they appear per specific views.
+ *  The expected structure is:
+ *  [
+ *     {
+ *        name: {string} A unique name for the footer object
+ *        $element: {jQuery} A jQuery object for the content of the footer
+ *        views: {string[]} Optional. An array stating which views this footer is
+ *               active on. Use null or omit to display this on all views.
+ *     }
+ *  ]
+ */
+MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
+       var header;
+
+       config = config || {};
+
+       this.controller = controller;
+       this.model = model;
+       this.currentView = '';
+       this.views = {};
+       this.userSelecting = false;
+
+       this.menuInitialized = false;
+       this.$overlay = config.$overlay || this.$element;
+       this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
+       this.footers = [];
+
+       // Parent
+       MenuSelectWidget.parent.call( this, $.extend( config, {
+               $autoCloseIgnore: this.$overlay,
+               width: 650,
+               // Our filtering is done through the model
+               filterFromInput: false
+       } ) );
+       this.setGroupElement(
+               $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
+       );
+       this.setClippableElement( this.$body );
+       this.setClippableContainer( this.$element );
+
+       header = new FilterMenuHeaderWidget(
+               this.controller,
+               this.model,
+               {
+                       $overlay: this.$overlay
+               }
+       );
+
+       this.noResults = new OO.ui.LabelWidget( {
+               label: mw.msg( 'rcfilters-filterlist-noresults' ),
+               classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
+       } );
+
+       // Events
+       this.model.connect( this, {
+               initialize: 'onModelInitialize',
+               searchChange: 'onModelSearchChange'
+       } );
+
+       // Initialization
+       this.$element
+               .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
+               .append( header.$element )
+               .append(
+                       this.$body
+                               .append( this.$group, this.noResults.$element )
                );
 
-               this.noResults = new OO.ui.LabelWidget( {
-                       label: mw.msg( 'rcfilters-filterlist-noresults' ),
-                       classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
-               } );
-
-               // Events
-               this.model.connect( this, {
-                       initialize: 'onModelInitialize',
-                       searchChange: 'onModelSearchChange'
-               } );
-
-               // Initialization
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
-                       .append( header.$element )
-                       .append(
-                               this.$body
-                                       .append( this.$group, this.noResults.$element )
-                       );
-
-               // Append all footers; we will control their visibility
-               // based on view
-               config.footers = config.footers || [];
-               config.footers.forEach( function ( footerData ) {
-                       var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
-                               adjustedData = {
-                                       // Wrap the element with our own footer wrapper
-                                       $element: $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
-                                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
-                                               .append( footerData.$element ),
-                                       views: footerData.views
-                               };
-
-                       if ( !footerData.disabled ) {
-                               this.footers.push( adjustedData );
-
-                               if ( isSticky ) {
-                                       this.$element.append( adjustedData.$element );
-                               } else {
-                                       this.$body.append( adjustedData.$element );
-                               }
+       // Append all footers; we will control their visibility
+       // based on view
+       config.footers = config.footers || [];
+       config.footers.forEach( function ( footerData ) {
+               var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
+                       adjustedData = {
+                               // Wrap the element with our own footer wrapper
+                               $element: $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
+                                       .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
+                                       .append( footerData.$element ),
+                               views: footerData.views
+                       };
+
+               if ( !footerData.disabled ) {
+                       this.footers.push( adjustedData );
+
+                       if ( isSticky ) {
+                               this.$element.append( adjustedData.$element );
+                       } else {
+                               this.$body.append( adjustedData.$element );
                        }
-               }.bind( this ) );
-
-               // Switch to the correct view
-               this.updateView();
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( MenuSelectWidget, OO.ui.MenuSelectWidget );
-
-       /* Events */
-
-       /* Methods */
-       MenuSelectWidget.prototype.onModelSearchChange = function () {
-               this.updateView();
-       };
-
-       /**
-        * @inheritdoc
-        */
-       MenuSelectWidget.prototype.toggle = function ( show ) {
-               this.lazyMenuCreation();
-               MenuSelectWidget.parent.prototype.toggle.call( this, show );
-               // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
-               this.setVerticalPosition( 'below' );
-       };
-
-       /**
-        * lazy creation of the menu
-        */
-       MenuSelectWidget.prototype.lazyMenuCreation = function () {
-               var widget = this,
-                       items = [],
-                       viewGroupCount = {},
-                       groups = this.model.getFilterGroups();
-
-               if ( this.menuInitialized ) {
-                       return;
                }
-
-               this.menuInitialized = true;
-
-               // Create shared popup for highlight buttons
-               this.highlightPopup = new HighlightPopupWidget( this.controller );
-               this.$overlay.append( this.highlightPopup.$element );
-
-               // Count groups per view
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( groups, function ( groupName, groupModel ) {
-                       if ( !groupModel.isHidden() ) {
-                               viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
-                               viewGroupCount[ groupModel.getView() ]++;
-                       }
-               } );
-
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( groups, function ( groupName, groupModel ) {
-                       var currentItems = [],
-                               view = groupModel.getView();
-
-                       if ( !groupModel.isHidden() ) {
-                               if ( viewGroupCount[ view ] > 1 ) {
-                                       // Only add a section header if there is more than
-                                       // one group
-                                       currentItems.push(
-                                               // Group section
-                                               new FilterMenuSectionOptionWidget(
-                                                       widget.controller,
-                                                       groupModel,
-                                                       {
-                                                               $overlay: widget.$overlay
-                                                       }
-                                               )
-                                       );
-                               }
-
-                               // Add items
-                               widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
-                                       currentItems.push(
-                                               new FilterMenuOptionWidget(
-                                                       widget.controller,
-                                                       widget.model,
-                                                       widget.model.getInvertModel(),
-                                                       filterItem,
-                                                       widget.highlightPopup,
-                                                       {
-                                                               $overlay: widget.$overlay
-                                                       }
-                                               )
-                                       );
-                               } );
-
-                               // Cache the items per view, so we can switch between them
-                               // without rebuilding the widgets each time
-                               widget.views[ view ] = widget.views[ view ] || [];
-                               widget.views[ view ] = widget.views[ view ].concat( currentItems );
-                               items = items.concat( currentItems );
-                       }
-               } );
-
-               this.addItems( items );
-               this.updateView();
-       };
-
-       /**
-        * Respond to model initialize event. Populate the menu from the model
-        */
-       MenuSelectWidget.prototype.onModelInitialize = function () {
-               this.menuInitialized = false;
-               // Set timeout for the menu to lazy build.
-               setTimeout( this.lazyMenuCreation.bind( this ) );
-       };
-
-       /**
-        * Update view
-        */
-       MenuSelectWidget.prototype.updateView = function () {
-               var viewName = this.model.getCurrentView();
-
-               if ( this.views[ viewName ] && this.currentView !== viewName ) {
-                       this.updateFooterVisibility( viewName );
-
-                       this.$element
-                               .data( 'view', viewName )
-                               .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
-                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
-
-                       this.currentView = viewName;
-                       this.scrollToTop();
+       }.bind( this ) );
+
+       // Switch to the correct view
+       this.updateView();
+};
+
+/* Initialize */
+
+OO.inheritClass( MenuSelectWidget, OO.ui.MenuSelectWidget );
+
+/* Events */
+
+/* Methods */
+MenuSelectWidget.prototype.onModelSearchChange = function () {
+       this.updateView();
+};
+
+/**
+ * @inheritdoc
+ */
+MenuSelectWidget.prototype.toggle = function ( show ) {
+       this.lazyMenuCreation();
+       MenuSelectWidget.parent.prototype.toggle.call( this, show );
+       // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
+       this.setVerticalPosition( 'below' );
+};
+
+/**
+ * lazy creation of the menu
+ */
+MenuSelectWidget.prototype.lazyMenuCreation = function () {
+       var widget = this,
+               items = [],
+               viewGroupCount = {},
+               groups = this.model.getFilterGroups();
+
+       if ( this.menuInitialized ) {
+               return;
+       }
+
+       this.menuInitialized = true;
+
+       // Create shared popup for highlight buttons
+       this.highlightPopup = new HighlightPopupWidget( this.controller );
+       this.$overlay.append( this.highlightPopup.$element );
+
+       // Count groups per view
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( groups, function ( groupName, groupModel ) {
+               if ( !groupModel.isHidden() ) {
+                       viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
+                       viewGroupCount[ groupModel.getView() ]++;
                }
-
-               this.postProcessItems();
-               this.clip();
-       };
-
-       /**
-        * Go over the available footers and decide which should be visible
-        * for this view
-        *
-        * @param {string} [currentView] Current view
-        */
-       MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
-               currentView = currentView || this.model.getCurrentView();
-
-               this.footers.forEach( function ( data ) {
-                       data.$element.toggle(
-                               // This footer should only be shown if it is configured
-                               // for all views or for this specific view
-                               !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
-                       );
-               } );
-       };
-
-       /**
-        * Post-process items after the visibility changed. Make sure
-        * that we always have an item selected, and that the no-results
-        * widget appears if the menu is empty.
-        */
-       MenuSelectWidget.prototype.postProcessItems = function () {
-               var i,
-                       itemWasSelected = false,
-                       items = this.getItems();
-
-               // If we are not already selecting an item, always make sure
-               // that the top item is selected
-               if ( !this.userSelecting ) {
-                       // Select the first item in the list
-                       for ( i = 0; i < items.length; i++ ) {
-                               if (
-                                       !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
-                                       items[ i ].isVisible()
-                               ) {
-                                       itemWasSelected = true;
-                                       this.selectItem( items[ i ] );
-                                       break;
-                               }
+       } );
+
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( groups, function ( groupName, groupModel ) {
+               var currentItems = [],
+                       view = groupModel.getView();
+
+               if ( !groupModel.isHidden() ) {
+                       if ( viewGroupCount[ view ] > 1 ) {
+                               // Only add a section header if there is more than
+                               // one group
+                               currentItems.push(
+                                       // Group section
+                                       new FilterMenuSectionOptionWidget(
+                                               widget.controller,
+                                               groupModel,
+                                               {
+                                                       $overlay: widget.$overlay
+                                               }
+                                       )
+                               );
                        }
 
-                       if ( !itemWasSelected ) {
-                               this.selectItem( null );
-                       }
+                       // Add items
+                       widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
+                               currentItems.push(
+                                       new FilterMenuOptionWidget(
+                                               widget.controller,
+                                               widget.model,
+                                               widget.model.getInvertModel(),
+                                               filterItem,
+                                               widget.highlightPopup,
+                                               {
+                                                       $overlay: widget.$overlay
+                                               }
+                                       )
+                               );
+                       } );
+
+                       // Cache the items per view, so we can switch between them
+                       // without rebuilding the widgets each time
+                       widget.views[ view ] = widget.views[ view ] || [];
+                       widget.views[ view ] = widget.views[ view ].concat( currentItems );
+                       items = items.concat( currentItems );
                }
+       } );
+
+       this.addItems( items );
+       this.updateView();
+};
+
+/**
+ * Respond to model initialize event. Populate the menu from the model
+ */
+MenuSelectWidget.prototype.onModelInitialize = function () {
+       this.menuInitialized = false;
+       // Set timeout for the menu to lazy build.
+       setTimeout( this.lazyMenuCreation.bind( this ) );
+};
+
+/**
+ * Update view
+ */
+MenuSelectWidget.prototype.updateView = function () {
+       var viewName = this.model.getCurrentView();
+
+       if ( this.views[ viewName ] && this.currentView !== viewName ) {
+               this.updateFooterVisibility( viewName );
 
-               this.noResults.toggle( !this.getItems().some( function ( item ) {
-                       return item.isVisible();
-               } ) );
-       };
-
-       /**
-        * Get the option widget that matches the model given
-        *
-        * @param {mw.rcfilters.dm.ItemModel} model Item model
-        * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
-        */
-       MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
-               this.lazyMenuCreation();
-               return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
-                       return item.getName() === model.getName();
-               } )[ 0 ];
-       };
-
-       /**
-        * @inheritdoc
-        */
-       MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
-               var nextItem,
-                       currentItem = this.findHighlightedItem() || this.findSelectedItem();
-
-               // Call parent
-               MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
-
-               // We want to select the item on arrow movement
-               // rather than just highlight it, like the menu
-               // does by default
-               if ( !this.isDisabled() && this.isVisible() ) {
-                       switch ( e.keyCode ) {
-                               case OO.ui.Keys.UP:
-                               case OO.ui.Keys.LEFT:
-                                       // Get the next item
-                                       nextItem = this.findRelativeSelectableItem( currentItem, -1 );
-                                       break;
-                               case OO.ui.Keys.DOWN:
-                               case OO.ui.Keys.RIGHT:
-                                       // Get the next item
-                                       nextItem = this.findRelativeSelectableItem( currentItem, 1 );
-                                       break;
+               this.$element
+                       .data( 'view', viewName )
+                       .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
+                       .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
+
+               this.currentView = viewName;
+               this.scrollToTop();
+       }
+
+       this.postProcessItems();
+       this.clip();
+};
+
+/**
+ * Go over the available footers and decide which should be visible
+ * for this view
+ *
+ * @param {string} [currentView] Current view
+ */
+MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
+       currentView = currentView || this.model.getCurrentView();
+
+       this.footers.forEach( function ( data ) {
+               data.$element.toggle(
+                       // This footer should only be shown if it is configured
+                       // for all views or for this specific view
+                       !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
+               );
+       } );
+};
+
+/**
+ * Post-process items after the visibility changed. Make sure
+ * that we always have an item selected, and that the no-results
+ * widget appears if the menu is empty.
+ */
+MenuSelectWidget.prototype.postProcessItems = function () {
+       var i,
+               itemWasSelected = false,
+               items = this.getItems();
+
+       // If we are not already selecting an item, always make sure
+       // that the top item is selected
+       if ( !this.userSelecting ) {
+               // Select the first item in the list
+               for ( i = 0; i < items.length; i++ ) {
+                       if (
+                               !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
+                               items[ i ].isVisible()
+                       ) {
+                               itemWasSelected = true;
+                               this.selectItem( items[ i ] );
+                               break;
                        }
+               }
 
-                       nextItem = nextItem && nextItem.constructor.static.selectable ?
-                               nextItem : null;
-
-                       // Select the next item
-                       this.selectItem( nextItem );
+               if ( !itemWasSelected ) {
+                       this.selectItem( null );
                }
-       };
-
-       /**
-        * Scroll to the top of the menu
-        */
-       MenuSelectWidget.prototype.scrollToTop = function () {
-               this.$body.scrollTop( 0 );
-       };
-
-       /**
-        * Set whether the user is currently selecting an item.
-        * This is important when the user selects an item that is in between
-        * different views, and makes sure we do not re-select a different
-        * item (like the item on top) when this is happening.
-        *
-        * @param {boolean} isSelecting User is selecting
-        */
-       MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
-               this.userSelecting = !!isSelecting;
-       };
-
-       module.exports = MenuSelectWidget;
-}() );
+       }
+
+       this.noResults.toggle( !this.getItems().some( function ( item ) {
+               return item.isVisible();
+       } ) );
+};
+
+/**
+ * Get the option widget that matches the model given
+ *
+ * @param {mw.rcfilters.dm.ItemModel} model Item model
+ * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
+ */
+MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
+       this.lazyMenuCreation();
+       return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
+               return item.getName() === model.getName();
+       } )[ 0 ];
+};
+
+/**
+ * @inheritdoc
+ */
+MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
+       var nextItem,
+               currentItem = this.findHighlightedItem() || this.findSelectedItem();
+
+       // Call parent
+       MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
+
+       // We want to select the item on arrow movement
+       // rather than just highlight it, like the menu
+       // does by default
+       if ( !this.isDisabled() && this.isVisible() ) {
+               switch ( e.keyCode ) {
+                       case OO.ui.Keys.UP:
+                       case OO.ui.Keys.LEFT:
+                               // Get the next item
+                               nextItem = this.findRelativeSelectableItem( currentItem, -1 );
+                               break;
+                       case OO.ui.Keys.DOWN:
+                       case OO.ui.Keys.RIGHT:
+                               // Get the next item
+                               nextItem = this.findRelativeSelectableItem( currentItem, 1 );
+                               break;
+               }
+
+               nextItem = nextItem && nextItem.constructor.static.selectable ?
+                       nextItem : null;
+
+               // Select the next item
+               this.selectItem( nextItem );
+       }
+};
+
+/**
+ * Scroll to the top of the menu
+ */
+MenuSelectWidget.prototype.scrollToTop = function () {
+       this.$body.scrollTop( 0 );
+};
+
+/**
+ * Set whether the user is currently selecting an item.
+ * This is important when the user selects an item that is in between
+ * different views, and makes sure we do not re-select a different
+ * item (like the item on top) when this is happening.
+ *
+ * @param {boolean} isSelecting User is selecting
+ */
+MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
+       this.userSelecting = !!isSelecting;
+};
+
+module.exports = MenuSelectWidget;
index 6de9c40..3d56fba 100644 (file)
-( function () {
-       /**
-        * Top section (between page title and filters) on Special:Recentchanges
-        *
-        * @class mw.rcfilters.ui.RcTopSectionWidget
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
-        * @param {jQuery} $topLinks Content of the community-defined links
-        * @param {Object} [config] Configuration object
-        */
-       var RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
-               savedLinksListWidget, $topLinks, config
-       ) {
-               var toplinksTitle,
-                       topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
-                       topLinksCookie = mw.cookie.get( topLinksCookieName ),
-                       topLinksCookieValue = topLinksCookie || 'collapsed',
-                       widget = this;
+/**
+ * Top section (between page title and filters) on Special:Recentchanges
+ *
+ * @class mw.rcfilters.ui.RcTopSectionWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+ * @param {jQuery} $topLinks Content of the community-defined links
+ * @param {Object} [config] Configuration object
+ */
+var RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
+       savedLinksListWidget, $topLinks, config
+) {
+       var toplinksTitle,
+               topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
+               topLinksCookie = mw.cookie.get( topLinksCookieName ),
+               topLinksCookieValue = topLinksCookie || 'collapsed',
+               widget = this;
 
-               config = config || {};
+       config = config || {};
 
-               // Parent
-               RcTopSectionWidget.parent.call( this, config );
+       // Parent
+       RcTopSectionWidget.parent.call( this, config );
 
-               this.$topLinks = $topLinks;
+       this.$topLinks = $topLinks;
 
-               toplinksTitle = new OO.ui.ButtonWidget( {
-                       framed: false,
-                       indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
-                       flags: [ 'progressive' ],
-                       label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
-               } );
+       toplinksTitle = new OO.ui.ButtonWidget( {
+               framed: false,
+               indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
+               flags: [ 'progressive' ],
+               label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
+       } );
 
-               this.$topLinks
-                       .makeCollapsible( {
-                               collapsed: topLinksCookieValue === 'collapsed',
-                               $customTogglers: toplinksTitle.$element
-                       } )
-                       .on( 'beforeExpand.mw-collapsible', function () {
-                               mw.cookie.set( topLinksCookieName, 'expanded' );
-                               toplinksTitle.setIndicator( 'up' );
-                               widget.switchTopLinks( 'expanded' );
-                       } )
-                       .on( 'beforeCollapse.mw-collapsible', function () {
-                               mw.cookie.set( topLinksCookieName, 'collapsed' );
-                               toplinksTitle.setIndicator( 'down' );
-                               widget.switchTopLinks( 'collapsed' );
-                       } );
+       this.$topLinks
+               .makeCollapsible( {
+                       collapsed: topLinksCookieValue === 'collapsed',
+                       $customTogglers: toplinksTitle.$element
+               } )
+               .on( 'beforeExpand.mw-collapsible', function () {
+                       mw.cookie.set( topLinksCookieName, 'expanded' );
+                       toplinksTitle.setIndicator( 'up' );
+                       widget.switchTopLinks( 'expanded' );
+               } )
+               .on( 'beforeCollapse.mw-collapsible', function () {
+                       mw.cookie.set( topLinksCookieName, 'collapsed' );
+                       toplinksTitle.setIndicator( 'down' );
+                       widget.switchTopLinks( 'collapsed' );
+               } );
 
-               this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
-                       .replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );
+       this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
+               .replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );
 
-               // Create two positions for the toplinks to toggle between
-               // in the table (first cell) or up above it
-               this.$top = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
-               this.$tableTopLinks = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-cell' )
-                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );
+       // Create two positions for the toplinks to toggle between
+       // in the table (first cell) or up above it
+       this.$top = $( '<div>' )
+               .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
+       this.$tableTopLinks = $( '<div>' )
+               .addClass( 'mw-rcfilters-ui-cell' )
+               .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );
 
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               this.$tableTopLinks,
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
+               .append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-table' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-row' )
+                                               .append(
+                                                       this.$tableTopLinks,
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-table-placeholder' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' ),
+                                                       !mw.user.isAnon() ?
                                                                $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-table-placeholder' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' ),
-                                                               !mw.user.isAnon() ?
-                                                                       $( '<div>' )
-                                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                                               .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
-                                                                               .append( savedLinksListWidget.$element ) :
-                                                                       null
-                                                       )
-                                       )
-                       );
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
+                                                                       .append( savedLinksListWidget.$element ) :
+                                                               null
+                                               )
+                               )
+               );
 
-               // Hack: For jumpiness reasons, this should be a sibling of -head
-               $( '.rcfilters-head' ).before( this.$top );
+       // Hack: For jumpiness reasons, this should be a sibling of -head
+       $( '.rcfilters-head' ).before( this.$top );
 
-               // Initialize top links position
-               widget.switchTopLinks( topLinksCookieValue );
-       };
+       // Initialize top links position
+       widget.switchTopLinks( topLinksCookieValue );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( RcTopSectionWidget, OO.ui.Widget );
+OO.inheritClass( RcTopSectionWidget, OO.ui.Widget );
 
-       /**
       * Switch the top links widget from inside the table (when collapsed)
       * to the 'top' (when open)
       *
       * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
       */
-       RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
-               state = state || 'expanded';
+/**
+ * Switch the top links widget from inside the table (when collapsed)
+ * to the 'top' (when open)
+ *
+ * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
+ */
+RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
+       state = state || 'expanded';
 
-               if ( state === 'expanded' ) {
-                       this.$top.append( this.$topLinks );
-               } else {
-                       this.$tableTopLinks.append( this.$topLinks );
-               }
-               this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
-       };
+       if ( state === 'expanded' ) {
+               this.$top.append( this.$topLinks );
+       } else {
+               this.$tableTopLinks.append( this.$topLinks );
+       }
+       this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
+};
 
-       module.exports = RcTopSectionWidget;
-}() );
+module.exports = RcTopSectionWidget;
index 6eb0d5b..382b54c 100644 (file)
@@ -1,82 +1,80 @@
-( function () {
-       /**
-        * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
-        *
-        * @class mw.rcfilters.ui.RclTargetPageWidget
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FilterItem} targetPageModel
-        * @param {Object} [config] Configuration object
-        */
-       var RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
-               controller, targetPageModel, config
-       ) {
-               config = config || {};
+/**
+ * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
+ *
+ * @class mw.rcfilters.ui.RclTargetPageWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FilterItem} targetPageModel
+ * @param {Object} [config] Configuration object
+ */
+var RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
+       controller, targetPageModel, config
+) {
+       config = config || {};
 
-               // Parent
-               RclTargetPageWidget.parent.call( this, config );
+       // Parent
+       RclTargetPageWidget.parent.call( this, config );
 
-               this.controller = controller;
-               this.model = targetPageModel;
+       this.controller = controller;
+       this.model = targetPageModel;
 
-               this.titleSearch = new mw.widgets.TitleInputWidget( {
-                       validate: false,
-                       placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
-                       showImages: true,
-                       showDescriptions: true,
-                       addQueryInput: false
-               } );
+       this.titleSearch = new mw.widgets.TitleInputWidget( {
+               validate: false,
+               placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
+               showImages: true,
+               showDescriptions: true,
+               addQueryInput: false
+       } );
 
-               // Events
-               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+       // Events
+       this.model.connect( this, { update: 'updateUiBasedOnModel' } );
 
-               this.titleSearch.$input.on( {
-                       blur: this.onLookupInputBlur.bind( this )
-               } );
+       this.titleSearch.$input.on( {
+               blur: this.onLookupInputBlur.bind( this )
+       } );
 
-               this.titleSearch.lookupMenu.connect( this, {
-                       choose: 'onLookupMenuItemChoose'
-               } );
+       this.titleSearch.lookupMenu.connect( this, {
+               choose: 'onLookupMenuItemChoose'
+       } );
 
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
-                       .append( this.titleSearch.$element );
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
+               .append( this.titleSearch.$element );
 
-               this.updateUiBasedOnModel();
-       };
+       this.updateUiBasedOnModel();
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( RclTargetPageWidget, OO.ui.Widget );
+OO.inheritClass( RclTargetPageWidget, OO.ui.Widget );
 
-       /* Methods */
+/* Methods */
 
-       /**
       * Respond to the user choosing a title
       */
-       RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
-               this.titleSearch.$input.trigger( 'blur' );
-       };
+/**
+ * Respond to the user choosing a title
+ */
+RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
+       this.titleSearch.$input.trigger( 'blur' );
+};
 
-       /**
       * Respond to titleSearch $input blur
       */
-       RclTargetPageWidget.prototype.onLookupInputBlur = function () {
-               this.controller.setTargetPage( this.titleSearch.getQueryValue() );
-       };
+/**
+ * Respond to titleSearch $input blur
+ */
+RclTargetPageWidget.prototype.onLookupInputBlur = function () {
+       this.controller.setTargetPage( this.titleSearch.getQueryValue() );
+};
 
-       /**
       * Respond to the model being updated
       */
-       RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
-               var title = mw.Title.newFromText( this.model.getValue() ),
-                       text = title ? title.toText() : this.model.getValue();
-               this.titleSearch.setValue( text );
-               this.titleSearch.setTitle( text );
-       };
+/**
+ * Respond to the model being updated
+ */
+RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
+       var title = mw.Title.newFromText( this.model.getValue() ),
+               text = title ? title.toText() : this.model.getValue();
+       this.titleSearch.setValue( text );
+       this.titleSearch.setTitle( text );
+};
 
-       module.exports = RclTargetPageWidget;
-}() );
+module.exports = RclTargetPageWidget;
index e2c58d0..46f2de9 100644 (file)
@@ -1,76 +1,74 @@
-( function () {
-       /**
-        * Widget to select to view changes that link TO or FROM the target page
-        * on Special:RecentChangesLinked (AKA Related Changes)
-        *
-        * @class mw.rcfilters.ui.RclToOrFromWidget
-        * @extends OO.ui.DropdownWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
-        * @param {Object} [config] Configuration object
-        */
-       var RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
-               controller, showLinkedToModel, config
-       ) {
-               config = config || {};
+/**
+ * Widget to select to view changes that link TO or FROM the target page
+ * on Special:RecentChangesLinked (AKA Related Changes)
+ *
+ * @class mw.rcfilters.ui.RclToOrFromWidget
+ * @extends OO.ui.DropdownWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
+ * @param {Object} [config] Configuration object
+ */
+var RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
+       controller, showLinkedToModel, config
+) {
+       config = config || {};
 
-               this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
-                       data: 'from', // default (showlinkedto=0)
-                       label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
-               } );
-               this.showLinkedTo = new OO.ui.MenuOptionWidget( {
-                       data: 'to', // showlinkedto=1
-                       label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
-               } );
+       this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
+               data: 'from', // default (showlinkedto=0)
+               label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
+       } );
+       this.showLinkedTo = new OO.ui.MenuOptionWidget( {
+               data: 'to', // showlinkedto=1
+               label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
+       } );
 
-               // Parent
-               RclToOrFromWidget.parent.call( this, $.extend( {
-                       classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
-                       menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
-               }, config ) );
+       // Parent
+       RclToOrFromWidget.parent.call( this, $.extend( {
+               classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
+               menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
+       }, config ) );
 
-               this.controller = controller;
-               this.model = showLinkedToModel;
+       this.controller = controller;
+       this.model = showLinkedToModel;
 
-               this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
-               this.model.connect( this, { update: 'onModelUpdate' } );
+       this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
+       this.model.connect( this, { update: 'onModelUpdate' } );
 
-               // force an initial update of the component based on the state
-               this.onModelUpdate();
-       };
+       // force an initial update of the component based on the state
+       this.onModelUpdate();
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget );
+OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget );
 
-       /* Methods */
+/* Methods */
 
-       /**
       * Respond to the user choosing an item in the menu
       *
       * @param {OO.ui.MenuOptionWidget} chosenItem
       */
-       RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
-               this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
-       };
+/**
+ * Respond to the user choosing an item in the menu
+ *
+ * @param {OO.ui.MenuOptionWidget} chosenItem
+ */
+RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
+       this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
+};
 
-       /**
       * Respond to model update
       */
-       RclToOrFromWidget.prototype.onModelUpdate = function () {
-               this.getMenu().selectItem(
-                       this.model.isSelected() ?
-                               this.showLinkedTo :
-                               this.showLinkedFrom
-               );
-               this.setLabel( mw.msg(
-                       this.model.isSelected() ?
-                               'rcfilters-filter-showlinkedto-label' :
-                               'rcfilters-filter-showlinkedfrom-label'
-               ) );
-       };
+/**
+ * Respond to model update
+ */
+RclToOrFromWidget.prototype.onModelUpdate = function () {
+       this.getMenu().selectItem(
+               this.model.isSelected() ?
+                       this.showLinkedTo :
+                       this.showLinkedFrom
+       );
+       this.setLabel( mw.msg(
+               this.model.isSelected() ?
+                       'rcfilters-filter-showlinkedto-label' :
+                       'rcfilters-filter-showlinkedfrom-label'
+       ) );
+};
 
-       module.exports = RclToOrFromWidget;
-}() );
+module.exports = RclToOrFromWidget;
index d968b9e..560f3d8 100644 (file)
@@ -1,73 +1,71 @@
-( function () {
-       var RclToOrFromWidget = require( './RclToOrFromWidget.js' ),
-               RclTargetPageWidget = require( './RclTargetPageWidget.js' ),
-               RclTopSectionWidget;
+var RclToOrFromWidget = require( './RclToOrFromWidget.js' ),
+       RclTargetPageWidget = require( './RclTargetPageWidget.js' ),
+       RclTopSectionWidget;
 
-       /**
       * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
       *
       * @class mw.rcfilters.ui.RclTopSectionWidget
       * @extends OO.ui.Widget
       *
       * @constructor
       * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
       * @param {mw.rcfilters.Controller} controller
       * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
       * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
       * @param {Object} [config] Configuration object
       */
-       RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
-               savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
-       ) {
-               var toOrFromWidget,
-                       targetPage;
-               config = config || {};
+/**
+ * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
+ *
+ * @class mw.rcfilters.ui.RclTopSectionWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
+ * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
+ * @param {Object} [config] Configuration object
+ */
+RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
+       savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
+) {
+       var toOrFromWidget,
+               targetPage;
+       config = config || {};
 
-               // Parent
-               RclTopSectionWidget.parent.call( this, config );
+       // Parent
+       RclTopSectionWidget.parent.call( this, config );
 
-               this.controller = controller;
+       this.controller = controller;
 
-               toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel );
-               targetPage = new RclTargetPageWidget( controller, targetPageModel );
+       toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel );
+       targetPage = new RclTargetPageWidget( controller, targetPageModel );
 
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
+               .append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-table' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-row' )
+                                               .append(
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .append( toOrFromWidget.$element )
+                                               ),
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-row' )
+                                               .append(
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .append( targetPage.$element ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-table-placeholder' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' ),
+                                                       !mw.user.isAnon() ?
                                                                $( '<div>' )
                                                                        .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .append( toOrFromWidget.$element )
-                                                       ),
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .append( targetPage.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-table-placeholder' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' ),
-                                                               !mw.user.isAnon() ?
-                                                                       $( '<div>' )
-                                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                                               .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
-                                                                               .append( savedLinksListWidget.$element ) :
-                                                                       null
-                                                       )
-                                       )
-                       );
-       };
+                                                                       .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
+                                                                       .append( savedLinksListWidget.$element ) :
+                                                               null
+                                               )
+                               )
+               );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( RclTopSectionWidget, OO.ui.Widget );
+OO.inheritClass( RclTopSectionWidget, OO.ui.Widget );
 
-       module.exports = RclTopSectionWidget;
-}() );
+module.exports = RclTopSectionWidget;
index 8c3d550..1c66c6e 100644 (file)
-( function () {
-       /**
-        * Save filters widget. This widget is displayed in the tag area
-        * and allows the user to save the current state of the system
-        * as a new saved filter query they can later load or set as
-        * default.
-        *
-        * @class mw.rcfilters.ui.SaveFiltersPopupButtonWidget
-        * @extends OO.ui.PopupButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
-        * @param {Object} [config] Configuration object
-        */
-       var SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
-               var layout,
-                       checkBoxLayout,
-                       $popupContent = $( '<div>' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-
-               // Parent
-               SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
-                       framed: false,
-                       icon: 'bookmark',
-                       title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
-                       popup: {
-                               classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
-                               padded: true,
-                               head: true,
-                               label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
-                               $content: $popupContent
-                       }
-               }, config ) );
-               // // HACK: Add an icon to the popup head label
-               this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
-
-               this.input = new OO.ui.TextInputWidget( {
-                       placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
-               } );
-               layout = new OO.ui.FieldLayout( this.input, {
-                       label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
-                       align: 'top'
-               } );
-
-               this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
-               checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
-                       label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
-                       align: 'inline'
-               } );
-
-               this.applyButton = new OO.ui.ButtonWidget( {
-                       label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
-                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
-                       flags: [ 'primary', 'progressive' ]
-               } );
-               this.cancelButton = new OO.ui.ButtonWidget( {
-                       label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
-                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
-               } );
-
-               $popupContent
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
-                                       .append( layout.$element ),
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
-                                       .append( checkBoxLayout.$element ),
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
-                                       .append(
-                                               this.cancelButton.$element,
-                                               this.applyButton.$element
-                                       )
-                       );
-
-               // Events
-               this.popup.connect( this, {
-                       ready: 'onPopupReady'
-               } );
-               this.input.connect( this, {
-                       change: 'onInputChange',
-                       enter: 'onInputEnter'
-               } );
-               this.input.$input.on( {
-                       keyup: this.onInputKeyup.bind( this )
-               } );
-               this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
-               this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
-               this.applyButton.connect( this, { click: 'onApplyButtonClick' } );
-
-               // Initialize
-               this.applyButton.setDisabled( !this.input.getValue() );
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
-       };
-
-       /* Initialization */
-       OO.inheritClass( SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );
-
-       /**
-        * Respond to input enter event
-        */
-       SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
-               this.apply();
-       };
-
-       /**
-        * Respond to input change event
-        *
-        * @param {string} value Input value
-        */
-       SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
-               value = value.trim();
-
-               this.applyButton.setDisabled( !value );
-       };
-
-       /**
-        * Respond to input keyup event, this is the way to intercept 'escape' key
-        *
-        * @param {jQuery.Event} e Event data
-        * @return {boolean} false
-        */
-       SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
-               if ( e.which === OO.ui.Keys.ESCAPE ) {
-                       this.popup.toggle( false );
-                       return false;
+/**
+ * Save filters widget. This widget is displayed in the tag area
+ * and allows the user to save the current state of the system
+ * as a new saved filter query they can later load or set as
+ * default.
+ *
+ * @class mw.rcfilters.ui.SaveFiltersPopupButtonWidget
+ * @extends OO.ui.PopupButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
+ * @param {Object} [config] Configuration object
+ */
+var SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
+       var layout,
+               checkBoxLayout,
+               $popupContent = $( '<div>' );
+
+       config = config || {};
+
+       this.controller = controller;
+       this.model = model;
+
+       // Parent
+       SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
+               framed: false,
+               icon: 'bookmark',
+               title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
+               popup: {
+                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
+                       padded: true,
+                       head: true,
+                       label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
+                       $content: $popupContent
                }
-       };
-
-       /**
-        * Respond to popup ready event
-        */
-       SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
-               this.input.focus();
-       };
-
-       /**
-        * Respond to "set as default" checkbox change
-        * @param {boolean} checked State of the checkbox
-        */
-       SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
-               var messageKey = checked ?
-                       'rcfilters-savedqueries-apply-and-setdefault-label' :
-                       'rcfilters-savedqueries-apply-label';
-
-               this.applyButton
-                       .setIcon( checked ? 'pushPin' : null )
-                       .setLabel( mw.msg( messageKey ) );
-       };
-
-       /**
-        * Respond to cancel button click event
-        */
-       SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
+       }, config ) );
+       // // HACK: Add an icon to the popup head label
+       this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
+
+       this.input = new OO.ui.TextInputWidget( {
+               placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
+       } );
+       layout = new OO.ui.FieldLayout( this.input, {
+               label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
+               align: 'top'
+       } );
+
+       this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
+       checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
+               label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
+               align: 'inline'
+       } );
+
+       this.applyButton = new OO.ui.ButtonWidget( {
+               label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
+               classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
+               flags: [ 'primary', 'progressive' ]
+       } );
+       this.cancelButton = new OO.ui.ButtonWidget( {
+               label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
+               classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
+       } );
+
+       $popupContent
+               .append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
+                               .append( layout.$element ),
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
+                               .append( checkBoxLayout.$element ),
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
+                               .append(
+                                       this.cancelButton.$element,
+                                       this.applyButton.$element
+                               )
+               );
+
+       // Events
+       this.popup.connect( this, {
+               ready: 'onPopupReady'
+       } );
+       this.input.connect( this, {
+               change: 'onInputChange',
+               enter: 'onInputEnter'
+       } );
+       this.input.$input.on( {
+               keyup: this.onInputKeyup.bind( this )
+       } );
+       this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
+       this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
+       this.applyButton.connect( this, { click: 'onApplyButtonClick' } );
+
+       // Initialize
+       this.applyButton.setDisabled( !this.input.getValue() );
+       this.$element
+               .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
+};
+
+/* Initialization */
+OO.inheritClass( SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );
+
+/**
+ * Respond to input enter event
+ */
+SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
+       this.apply();
+};
+
+/**
+ * Respond to input change event
+ *
+ * @param {string} value Input value
+ */
+SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
+       value = value.trim();
+
+       this.applyButton.setDisabled( !value );
+};
+
+/**
+ * Respond to input keyup event, this is the way to intercept 'escape' key
+ *
+ * @param {jQuery.Event} e Event data
+ * @return {boolean} false
+ */
+SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
+       if ( e.which === OO.ui.Keys.ESCAPE ) {
                this.popup.toggle( false );
-       };
-
-       /**
-        * Respond to apply button click event
-        */
-       SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
-               this.apply();
-       };
-
-       /**
-        * Apply and add the new quick link
-        */
-       SaveFiltersPopupButtonWidget.prototype.apply = function () {
-               var label = this.input.getValue().trim();
-
-               // This condition is more for sanity-check, since the
-               // apply button should be disabled if the label is empty
-               if ( label ) {
-                       this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
-                       this.input.setValue( '' );
-                       this.setAsDefaultCheckbox.setSelected( false );
-                       this.popup.toggle( false );
-
-                       this.emit( 'saveCurrent' );
-               }
-       };
+               return false;
+       }
+};
+
+/**
+ * Respond to popup ready event
+ */
+SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
+       this.input.focus();
+};
+
+/**
+ * Respond to "set as default" checkbox change
+ * @param {boolean} checked State of the checkbox
+ */
+SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
+       var messageKey = checked ?
+               'rcfilters-savedqueries-apply-and-setdefault-label' :
+               'rcfilters-savedqueries-apply-label';
+
+       this.applyButton
+               .setIcon( checked ? 'pushPin' : null )
+               .setLabel( mw.msg( messageKey ) );
+};
+
+/**
+ * Respond to cancel button click event
+ */
+SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
+       this.popup.toggle( false );
+};
+
+/**
+ * Respond to apply button click event
+ */
+SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
+       this.apply();
+};
+
+/**
+ * Apply and add the new quick link
+ */
+SaveFiltersPopupButtonWidget.prototype.apply = function () {
+       var label = this.input.getValue().trim();
+
+       // This condition is more for sanity-check, since the
+       // apply button should be disabled if the label is empty
+       if ( label ) {
+               this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
+               this.input.setValue( '' );
+               this.setAsDefaultCheckbox.setSelected( false );
+               this.popup.toggle( false );
+
+               this.emit( 'saveCurrent' );
+       }
+};
 
-       module.exports = SaveFiltersPopupButtonWidget;
-}() );
+module.exports = SaveFiltersPopupButtonWidget;
index ceb5ef8..4057c48 100644 (file)
-( function () {
-       /**
-        * Quick links menu option widget
-        *
-        * @class mw.rcfilters.ui.SavedLinksListItemWidget
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.LabelElement
-        * @mixins OO.ui.mixin.IconElement
-        * @mixins OO.ui.mixin.TitledElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       var SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
-               config = config || {};
-
-               this.model = model;
-
-               // Parent
-               SavedLinksListItemWidget.parent.call( this, $.extend( {
-                       data: this.model.getID()
-               }, config ) );
-
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, $.extend( {
-                       label: this.model.getLabel()
-               }, config ) );
-               OO.ui.mixin.IconElement.call( this, $.extend( {
-                       icon: ''
-               }, config ) );
-               OO.ui.mixin.TitledElement.call( this, $.extend( {
-                       title: this.model.getLabel()
-               }, config ) );
-
-               this.edit = false;
-               this.$overlay = config.$overlay || this.$element;
-
-               this.popupButton = new OO.ui.ButtonWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
-                       icon: 'ellipsis',
-                       framed: false
-               } );
-               this.menu = new OO.ui.MenuSelectWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
-                       widget: this.popupButton,
-                       width: 200,
-                       horizontalPosition: 'end',
-                       $floatableContainer: this.popupButton.$element,
-                       items: [
-                               new OO.ui.MenuOptionWidget( {
-                                       data: 'edit',
-                                       icon: 'edit',
-                                       label: mw.msg( 'rcfilters-savedqueries-rename' )
-                               } ),
-                               new OO.ui.MenuOptionWidget( {
-                                       data: 'delete',
-                                       icon: 'trash',
-                                       label: mw.msg( 'rcfilters-savedqueries-remove' )
-                               } ),
-                               new OO.ui.MenuOptionWidget( {
-                                       data: 'default',
-                                       icon: 'pushPin',
-                                       label: mw.msg( 'rcfilters-savedqueries-setdefault' )
-                               } )
-                       ]
-               } );
-
-               this.editInput = new OO.ui.TextInputWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
-               } );
-               this.saveButton = new OO.ui.ButtonWidget( {
-                       icon: 'check',
-                       flags: [ 'primary', 'progressive' ]
-               } );
+/**
+ * Quick links menu option widget
+ *
+ * @class mw.rcfilters.ui.SavedLinksListItemWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.TitledElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
+ * @param {Object} [config] Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+var SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
+       config = config || {};
+
+       this.model = model;
+
+       // Parent
+       SavedLinksListItemWidget.parent.call( this, $.extend( {
+               data: this.model.getID()
+       }, config ) );
+
+       // Mixin constructors
+       OO.ui.mixin.LabelElement.call( this, $.extend( {
+               label: this.model.getLabel()
+       }, config ) );
+       OO.ui.mixin.IconElement.call( this, $.extend( {
+               icon: ''
+       }, config ) );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {
+               title: this.model.getLabel()
+       }, config ) );
+
+       this.edit = false;
+       this.$overlay = config.$overlay || this.$element;
+
+       this.popupButton = new OO.ui.ButtonWidget( {
+               classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
+               icon: 'ellipsis',
+               framed: false
+       } );
+       this.menu = new OO.ui.MenuSelectWidget( {
+               classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
+               widget: this.popupButton,
+               width: 200,
+               horizontalPosition: 'end',
+               $floatableContainer: this.popupButton.$element,
+               items: [
+                       new OO.ui.MenuOptionWidget( {
+                               data: 'edit',
+                               icon: 'edit',
+                               label: mw.msg( 'rcfilters-savedqueries-rename' )
+                       } ),
+                       new OO.ui.MenuOptionWidget( {
+                               data: 'delete',
+                               icon: 'trash',
+                               label: mw.msg( 'rcfilters-savedqueries-remove' )
+                       } ),
+                       new OO.ui.MenuOptionWidget( {
+                               data: 'default',
+                               icon: 'pushPin',
+                               label: mw.msg( 'rcfilters-savedqueries-setdefault' )
+                       } )
+               ]
+       } );
+
+       this.editInput = new OO.ui.TextInputWidget( {
+               classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
+       } );
+       this.saveButton = new OO.ui.ButtonWidget( {
+               icon: 'check',
+               flags: [ 'primary', 'progressive' ]
+       } );
+       this.toggleEdit( false );
+
+       // Events
+       this.model.connect( this, { update: 'onModelUpdate' } );
+       this.popupButton.connect( this, { click: 'onPopupButtonClick' } );
+       this.menu.connect( this, {
+               choose: 'onMenuChoose'
+       } );
+       this.saveButton.connect( this, { click: 'save' } );
+       this.editInput.connect( this, {
+               change: 'onInputChange',
+               enter: 'save'
+       } );
+       this.editInput.$input.on( {
+               blur: this.onInputBlur.bind( this ),
+               keyup: this.onInputKeyup.bind( this )
+       } );
+       this.$element.on( { click: this.onClick.bind( this ) } );
+       this.$label.on( { click: this.onClick.bind( this ) } );
+       this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );
+       // Prevent propagation on mousedown for the save button
+       // so the menu doesn't close
+       this.saveButton.$element.on( { mousedown: function () {
+               return false;
+       } } );
+
+       // Initialize
+       this.toggleDefault( !!this.model.isDefault() );
+       this.$overlay.append( this.menu.$element );
+       this.$element
+               .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
+               .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
+               .append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-table' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-row' )
+                                               .append(
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
+                                                               .append(
+                                                                       this.$label
+                                                                               .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
+                                                                       this.editInput.$element,
+                                                                       this.saveButton.$element
+                                                               ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
+                                                               .append( this.$icon ),
+                                                       this.popupButton.$element
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                               )
+                               )
+               );
+};
+
+/* Initialization */
+OO.inheritClass( SavedLinksListItemWidget, OO.ui.Widget );
+OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.TitledElement );
+
+/* Events */
+
+/**
+ * @event delete
+ *
+ * The delete option was selected for this item
+ */
+
+/**
+ * @event default
+ * @param {boolean} default Item is default
+ *
+ * The 'make default' option was selected for this item
+ */
+
+/**
+ * @event edit
+ * @param {string} newLabel New label for the query
+ *
+ * The label has been edited
+ */
+
+/* Methods */
+
+/**
+ * Respond to model update event
+ */
+SavedLinksListItemWidget.prototype.onModelUpdate = function () {
+       this.setLabel( this.model.getLabel() );
+       this.toggleDefault( this.model.isDefault() );
+};
+
+/**
+ * Respond to click on the element or label
+ *
+ * @fires click
+ */
+SavedLinksListItemWidget.prototype.onClick = function () {
+       if ( !this.editing ) {
+               this.emit( 'click' );
+       }
+};
+
+/**
+ * Respond to click on the 'default' icon. Open the submenu where the
+ * default state can be changed.
+ *
+ * @return {boolean} false
+ */
+SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
+       this.menu.toggle();
+       return false;
+};
+
+/**
+ * Respond to popup button click event
+ */
+SavedLinksListItemWidget.prototype.onPopupButtonClick = function () {
+       this.menu.toggle();
+};
+
+/**
+ * Respond to menu choose event
+ *
+ * @param {OO.ui.MenuOptionWidget} item Chosen item
+ * @fires delete
+ * @fires default
+ */
+SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
+       var action = item.getData();
+
+       if ( action === 'edit' ) {
+               this.toggleEdit( true );
+       } else if ( action === 'delete' ) {
+               this.emit( 'delete' );
+       } else if ( action === 'default' ) {
+               this.emit( 'default', !this.default );
+       }
+       // Reset selected
+       this.menu.selectItem( null );
+       // Close the menu
+       this.menu.toggle( false );
+};
+
+/**
+ * Respond to input keyup event, this is the way to intercept 'escape' key
+ *
+ * @param {jQuery.Event} e Event data
+ * @return {boolean} false
+ */
+SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
+       if ( e.which === OO.ui.Keys.ESCAPE ) {
+               // Return the input to the original label
+               this.editInput.setValue( this.getLabel() );
                this.toggleEdit( false );
-
-               // Events
-               this.model.connect( this, { update: 'onModelUpdate' } );
-               this.popupButton.connect( this, { click: 'onPopupButtonClick' } );
-               this.menu.connect( this, {
-                       choose: 'onMenuChoose'
-               } );
-               this.saveButton.connect( this, { click: 'save' } );
-               this.editInput.connect( this, {
-                       change: 'onInputChange',
-                       enter: 'save'
-               } );
-               this.editInput.$input.on( {
-                       blur: this.onInputBlur.bind( this ),
-                       keyup: this.onInputKeyup.bind( this )
-               } );
-               this.$element.on( { click: this.onClick.bind( this ) } );
-               this.$label.on( { click: this.onClick.bind( this ) } );
-               this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );
-               // Prevent propagation on mousedown for the save button
-               // so the menu doesn't close
-               this.saveButton.$element.on( { mousedown: function () {
-                       return false;
-               } } );
-
-               // Initialize
-               this.toggleDefault( !!this.model.isDefault() );
-               this.$overlay.append( this.menu.$element );
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
-                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
-                                                                       .append(
-                                                                               this.$label
-                                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
-                                                                               this.editInput.$element,
-                                                                               this.saveButton.$element
-                                                                       ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
-                                                                       .append( this.$icon ),
-                                                               this.popupButton.$element
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       )
-                                       )
-                       );
-       };
-
-       /* Initialization */
-       OO.inheritClass( SavedLinksListItemWidget, OO.ui.Widget );
-       OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.LabelElement );
-       OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.IconElement );
-       OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.TitledElement );
-
-       /* Events */
-
-       /**
-        * @event delete
-        *
-        * The delete option was selected for this item
-        */
-
-       /**
-        * @event default
-        * @param {boolean} default Item is default
-        *
-        * The 'make default' option was selected for this item
-        */
-
-       /**
-        * @event edit
-        * @param {string} newLabel New label for the query
-        *
-        * The label has been edited
-        */
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       SavedLinksListItemWidget.prototype.onModelUpdate = function () {
-               this.setLabel( this.model.getLabel() );
-               this.toggleDefault( this.model.isDefault() );
-       };
-
-       /**
-        * Respond to click on the element or label
-        *
-        * @fires click
-        */
-       SavedLinksListItemWidget.prototype.onClick = function () {
-               if ( !this.editing ) {
-                       this.emit( 'click' );
-               }
-       };
-
-       /**
-        * Respond to click on the 'default' icon. Open the submenu where the
-        * default state can be changed.
-        *
-        * @return {boolean} false
-        */
-       SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
-               this.menu.toggle();
                return false;
-       };
-
-       /**
-        * Respond to popup button click event
-        */
-       SavedLinksListItemWidget.prototype.onPopupButtonClick = function () {
-               this.menu.toggle();
-       };
-
-       /**
-        * Respond to menu choose event
-        *
-        * @param {OO.ui.MenuOptionWidget} item Chosen item
-        * @fires delete
-        * @fires default
-        */
-       SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
-               var action = item.getData();
-
-               if ( action === 'edit' ) {
-                       this.toggleEdit( true );
-               } else if ( action === 'delete' ) {
-                       this.emit( 'delete' );
-               } else if ( action === 'default' ) {
-                       this.emit( 'default', !this.default );
-               }
-               // Reset selected
-               this.menu.selectItem( null );
-               // Close the menu
-               this.menu.toggle( false );
-       };
-
-       /**
-        * Respond to input keyup event, this is the way to intercept 'escape' key
-        *
-        * @param {jQuery.Event} e Event data
-        * @return {boolean} false
-        */
-       SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
-               if ( e.which === OO.ui.Keys.ESCAPE ) {
-                       // Return the input to the original label
-                       this.editInput.setValue( this.getLabel() );
-                       this.toggleEdit( false );
-                       return false;
-               }
-       };
-
-       /**
-        * Respond to blur event on the input
-        */
-       SavedLinksListItemWidget.prototype.onInputBlur = function () {
-               this.save();
-
-               // Whether the save succeeded or not, the input-blur event
-               // means we need to cancel editing mode
+       }
+};
+
+/**
+ * Respond to blur event on the input
+ */
+SavedLinksListItemWidget.prototype.onInputBlur = function () {
+       this.save();
+
+       // Whether the save succeeded or not, the input-blur event
+       // means we need to cancel editing mode
+       this.toggleEdit( false );
+};
+
+/**
+ * Respond to input change event
+ *
+ * @param {string} value Input value
+ */
+SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
+       value = value.trim();
+
+       this.saveButton.setDisabled( !value );
+};
+
+/**
+ * Save the name of the query
+ *
+ * @param {string} [value] The value to save
+ * @fires edit
+ */
+SavedLinksListItemWidget.prototype.save = function () {
+       var value = this.editInput.getValue().trim();
+
+       if ( value ) {
+               this.emit( 'edit', value );
                this.toggleEdit( false );
-       };
-
-       /**
-        * Respond to input change event
-        *
-        * @param {string} value Input value
-        */
-       SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
-               value = value.trim();
-
-               this.saveButton.setDisabled( !value );
-       };
-
-       /**
-        * Save the name of the query
-        *
-        * @param {string} [value] The value to save
-        * @fires edit
-        */
-       SavedLinksListItemWidget.prototype.save = function () {
-               var value = this.editInput.getValue().trim();
-
-               if ( value ) {
-                       this.emit( 'edit', value );
-                       this.toggleEdit( false );
-               }
-       };
-
-       /**
-        * Toggle edit mode on this widget
-        *
-        * @param {boolean} isEdit Widget is in edit mode
-        */
-       SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
-               isEdit = isEdit === undefined ? !this.editing : isEdit;
-
-               if ( this.editing !== isEdit ) {
-                       this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
-                       this.editInput.setValue( this.getLabel() );
-
-                       this.editInput.toggle( isEdit );
-                       this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
-                       this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
-                       this.popupButton.toggle( !isEdit );
-                       this.saveButton.toggle( isEdit );
-
-                       if ( isEdit ) {
-                               this.editInput.$input.trigger( 'focus' );
-                       }
-                       this.editing = isEdit;
-               }
-       };
-
-       /**
-        * Toggle default this widget
-        *
-        * @param {boolean} isDefault This item is default
-        */
-       SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
-               isDefault = isDefault === undefined ? !this.default : isDefault;
-
-               if ( this.default !== isDefault ) {
-                       this.default = isDefault;
-                       this.setIcon( this.default ? 'pushPin' : '' );
-                       this.menu.findItemFromData( 'default' ).setLabel(
-                               this.default ?
-                                       mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
-                                       mw.msg( 'rcfilters-savedqueries-setdefault' )
-                       );
+       }
+};
+
+/**
+ * Toggle edit mode on this widget
+ *
+ * @param {boolean} isEdit Widget is in edit mode
+ */
+SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
+       isEdit = isEdit === undefined ? !this.editing : isEdit;
+
+       if ( this.editing !== isEdit ) {
+               this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
+               this.editInput.setValue( this.getLabel() );
+
+               this.editInput.toggle( isEdit );
+               this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
+               this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
+               this.popupButton.toggle( !isEdit );
+               this.saveButton.toggle( isEdit );
+
+               if ( isEdit ) {
+                       this.editInput.$input.trigger( 'focus' );
                }
-       };
-
-       /**
-        * Get item ID
-        *
-        * @return {string} Query identifier
-        */
-       SavedLinksListItemWidget.prototype.getID = function () {
-               return this.model.getID();
-       };
-
-       module.exports = SavedLinksListItemWidget;
-
-}() );
+               this.editing = isEdit;
+       }
+};
+
+/**
+ * Toggle default this widget
+ *
+ * @param {boolean} isDefault This item is default
+ */
+SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
+       isDefault = isDefault === undefined ? !this.default : isDefault;
+
+       if ( this.default !== isDefault ) {
+               this.default = isDefault;
+               this.setIcon( this.default ? 'pushPin' : '' );
+               this.menu.findItemFromData( 'default' ).setLabel(
+                       this.default ?
+                               mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
+                               mw.msg( 'rcfilters-savedqueries-setdefault' )
+               );
+       }
+};
+
+/**
+ * Get item ID
+ *
+ * @return {string} Query identifier
+ */
+SavedLinksListItemWidget.prototype.getID = function () {
+       return this.model.getID();
+};
+
+module.exports = SavedLinksListItemWidget;
index 5422daf..a29a93f 100644 (file)
-( function () {
-       var GroupWidget = require( './GroupWidget.js' ),
-               SavedLinksListItemWidget = require( './SavedLinksListItemWidget.js' ),
-               SavedLinksListWidget;
-
-       /**
-        * Quick links widget
-        *
-        * @class mw.rcfilters.ui.SavedLinksListWidget
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
-               var $labelNoEntries = $( '<div>' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
-                                       .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
-                                       .text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
-                       );
-
-               config = config || {};
-
-               // Parent
-               SavedLinksListWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-               this.$overlay = config.$overlay || this.$element;
-
-               this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
-                       label: $labelNoEntries,
-                       icon: 'bookmark'
-               } );
-
-               this.menu = new GroupWidget( {
-                       events: {
-                               click: 'menuItemClick',
-                               delete: 'menuItemDelete',
-                               default: 'menuItemDefault',
-                               edit: 'menuItemEdit'
-                       },
-                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
-                       items: [ this.placeholderItem ]
-               } );
-               this.button = new OO.ui.PopupButtonWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
-                       label: mw.msg( 'rcfilters-quickfilters' ),
-                       icon: 'bookmark',
-                       indicator: 'down',
-                       $overlay: this.$overlay,
-                       popup: {
-                               width: 300,
-                               anchor: false,
-                               align: 'backwards',
-                               $autoCloseIgnore: this.$overlay,
-                               $content: this.menu.$element
-                       }
-               } );
-
-               // Events
-               this.model.connect( this, {
-                       add: 'onModelAddItem',
-                       remove: 'onModelRemoveItem'
-               } );
-               this.menu.connect( this, {
-                       menuItemClick: 'onMenuItemClick',
-                       menuItemDelete: 'onMenuItemRemove',
-                       menuItemDefault: 'onMenuItemDefault',
-                       menuItemEdit: 'onMenuItemEdit'
-               } );
-
-               this.placeholderItem.toggle( this.model.isEmpty() );
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget' )
-                       .append( this.button.$element );
-       };
-
-       /* Initialization */
-       OO.inheritClass( SavedLinksListWidget, OO.ui.Widget );
-
-       /* Methods */
-
-       /**
-        * Respond to menu item click event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) {
-               this.controller.applySavedQuery( item.getID() );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to menu item remove event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
-               this.controller.removeSavedQuery( item.getID() );
-       };
-
-       /**
-        * Respond to menu item default event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        * @param {boolean} isDefault Item is default
-        */
-       SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
-               this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
-       };
-
-       /**
-        * Respond to menu item edit event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        * @param {string} newLabel New label
-        */
-       SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
-               this.controller.renameSavedQuery( item.getID(), newLabel );
-       };
-
-       /**
-        * Respond to menu add item event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
-               if ( this.menu.findItemFromData( item.getID() ) ) {
-                       return;
+var GroupWidget = require( './GroupWidget.js' ),
+       SavedLinksListItemWidget = require( './SavedLinksListItemWidget.js' ),
+       SavedLinksListWidget;
+
+/**
+ * Quick links widget
+ *
+ * @class mw.rcfilters.ui.SavedLinksListWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
+ * @param {Object} [config] Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
+       var $labelNoEntries = $( '<div>' )
+               .append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
+                               .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
+                               .text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
+               );
+
+       config = config || {};
+
+       // Parent
+       SavedLinksListWidget.parent.call( this, config );
+
+       this.controller = controller;
+       this.model = model;
+       this.$overlay = config.$overlay || this.$element;
+
+       this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
+               classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
+               label: $labelNoEntries,
+               icon: 'bookmark'
+       } );
+
+       this.menu = new GroupWidget( {
+               events: {
+                       click: 'menuItemClick',
+                       delete: 'menuItemDelete',
+                       default: 'menuItemDefault',
+                       edit: 'menuItemEdit'
+               },
+               classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
+               items: [ this.placeholderItem ]
+       } );
+       this.button = new OO.ui.PopupButtonWidget( {
+               classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
+               label: mw.msg( 'rcfilters-quickfilters' ),
+               icon: 'bookmark',
+               indicator: 'down',
+               $overlay: this.$overlay,
+               popup: {
+                       width: 300,
+                       anchor: false,
+                       align: 'backwards',
+                       $autoCloseIgnore: this.$overlay,
+                       $content: this.menu.$element
                }
-
-               this.menu.addItems( [
-                       new SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
-               ] );
-               this.placeholderItem.toggle( this.model.isEmpty() );
-       };
-
-       /**
-        * Respond to menu remove item event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
-               this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
-               this.placeholderItem.toggle( this.model.isEmpty() );
-       };
-
-       module.exports = SavedLinksListWidget;
-}() );
+       } );
+
+       // Events
+       this.model.connect( this, {
+               add: 'onModelAddItem',
+               remove: 'onModelRemoveItem'
+       } );
+       this.menu.connect( this, {
+               menuItemClick: 'onMenuItemClick',
+               menuItemDelete: 'onMenuItemRemove',
+               menuItemDefault: 'onMenuItemDefault',
+               menuItemEdit: 'onMenuItemEdit'
+       } );
+
+       this.placeholderItem.toggle( this.model.isEmpty() );
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-savedLinksListWidget' )
+               .append( this.button.$element );
+};
+
+/* Initialization */
+OO.inheritClass( SavedLinksListWidget, OO.ui.Widget );
+
+/* Methods */
+
+/**
+ * Respond to menu item click event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) {
+       this.controller.applySavedQuery( item.getID() );
+       this.button.popup.toggle( false );
+};
+
+/**
+ * Respond to menu item remove event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
+       this.controller.removeSavedQuery( item.getID() );
+};
+
+/**
+ * Respond to menu item default event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ * @param {boolean} isDefault Item is default
+ */
+SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
+       this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
+};
+
+/**
+ * Respond to menu item edit event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ * @param {string} newLabel New label
+ */
+SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
+       this.controller.renameSavedQuery( item.getID(), newLabel );
+};
+
+/**
+ * Respond to menu add item event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
+       if ( this.menu.findItemFromData( item.getID() ) ) {
+               return;
+       }
+
+       this.menu.addItems( [
+               new SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
+       ] );
+       this.placeholderItem.toggle( this.model.isEmpty() );
+};
+
+/**
+ * Respond to menu remove item event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
+       this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
+       this.placeholderItem.toggle( this.model.isEmpty() );
+};
+
+module.exports = SavedLinksListWidget;
index d66c5b5..985e2c5 100644 (file)
-( function () {
-       /**
-        * Extend OOUI's TagItemWidget to also display a popup on hover.
-        *
-        * @class mw.rcfilters.ui.TagItemWidget
-        * @extends OO.ui.TagItemWidget
-        * @mixins OO.ui.mixin.PopupElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.FilterItem} invertModel
-        * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       var TagItemWidget = function MwRcfiltersUiTagItemWidget(
-               controller, filtersViewModel, invertModel, itemModel, config
-       ) {
-               // Configuration initialization
-               config = config || {};
-
-               this.controller = controller;
-               this.invertModel = invertModel;
-               this.filtersViewModel = filtersViewModel;
-               this.itemModel = itemModel;
-               this.selected = false;
-
-               TagItemWidget.parent.call( this, $.extend( {
-                       data: this.itemModel.getName()
-               }, config ) );
-
-               this.$overlay = config.$overlay || this.$element;
-               this.popupLabel = new OO.ui.LabelWidget();
-
-               // Mixin constructors
-               OO.ui.mixin.PopupElement.call( this, $.extend( {
-                       popup: {
-                               padded: false,
-                               align: 'center',
-                               position: 'above',
-                               $content: $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
-                                       .append( this.popupLabel.$element ),
-                               $floatableContainer: this.$element,
-                               classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
-                       }
-               }, config ) );
-
-               this.popupTimeoutShow = null;
-               this.popupTimeoutHide = null;
-
-               this.$highlight = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );
-
-               // Add title attribute with the item label to 'x' button
-               this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );
-
-               // Events
-               this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
-               this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
-               this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
-
-               // Initialization
-               this.$overlay.append( this.popup.$element );
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-tagItemWidget' )
-                       .prepend( this.$highlight )
-                       .attr( 'aria-haspopup', 'true' )
-                       .on( 'mouseenter', this.onMouseEnter.bind( this ) )
-                       .on( 'mouseleave', this.onMouseLeave.bind( this ) );
-
-               this.updateUiBasedOnState();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( TagItemWidget, OO.ui.TagItemWidget );
-       OO.mixinClass( TagItemWidget, OO.ui.mixin.PopupElement );
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       TagItemWidget.prototype.updateUiBasedOnState = function () {
-               // Update label if needed
-               var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel.isSelected() );
-               if ( labelMsg ) {
-                       this.setLabel( $( '<div>' ).append(
-                               $( '<bdi>' ).html(
-                                       mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
-                               )
-                       ).contents() );
-               } else {
-                       this.setLabel(
-                               $( '<bdi>' ).append(
-                                       this.itemModel.getLabel()
-                               )
-                       );
-               }
-
-               this.setCurrentMuteState();
-               this.setHighlightColor();
-       };
-
-       /**
-        * Set the current highlight color for this item
-        */
-       TagItemWidget.prototype.setHighlightColor = function () {
-               var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
-                       this.itemModel.getHighlightColor() :
-                       null;
-
-               this.$highlight
-                       .attr( 'data-color', selectedColor )
-                       .toggleClass(
-                               'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
-                               !!selectedColor
-                       );
-       };
-
-       /**
-        * Set the current mute state for this item
-        */
-       TagItemWidget.prototype.setCurrentMuteState = function () {};
-
-       /**
-        * Respond to mouse enter event
-        */
-       TagItemWidget.prototype.onMouseEnter = function () {
-               var labelText = this.itemModel.getStateMessage();
-
-               if ( labelText ) {
-                       this.popupLabel.setLabel( labelText );
-
-                       // Set timeout for the popup to show
-                       this.popupTimeoutShow = setTimeout( function () {
-                               this.popup.toggle( true );
-                       }.bind( this ), 500 );
-
-                       // Cancel the hide timeout
-                       clearTimeout( this.popupTimeoutHide );
-                       this.popupTimeoutHide = null;
-               }
-       };
-
-       /**
-        * Respond to mouse leave event
-        */
-       TagItemWidget.prototype.onMouseLeave = function () {
-               this.popupTimeoutHide = setTimeout( function () {
-                       this.popup.toggle( false );
-               }.bind( this ), 250 );
-
-               // Clear the show timeout
-               clearTimeout( this.popupTimeoutShow );
-               this.popupTimeoutShow = null;
-       };
-
-       /**
-        * Set selected state on this widget
-        *
-        * @param {boolean} [isSelected] Widget is selected
-        */
-       TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
-               isSelected = isSelected !== undefined ? isSelected : !this.selected;
-
-               if ( this.selected !== isSelected ) {
-                       this.selected = isSelected;
-
-                       this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
+/**
+ * Extend OOUI's TagItemWidget to also display a popup on hover.
+ *
+ * @class mw.rcfilters.ui.TagItemWidget
+ * @extends OO.ui.TagItemWidget
+ * @mixins OO.ui.mixin.PopupElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.FilterItem} invertModel
+ * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+var TagItemWidget = function MwRcfiltersUiTagItemWidget(
+       controller, filtersViewModel, invertModel, itemModel, config
+) {
+       // Configuration initialization
+       config = config || {};
+
+       this.controller = controller;
+       this.invertModel = invertModel;
+       this.filtersViewModel = filtersViewModel;
+       this.itemModel = itemModel;
+       this.selected = false;
+
+       TagItemWidget.parent.call( this, $.extend( {
+               data: this.itemModel.getName()
+       }, config ) );
+
+       this.$overlay = config.$overlay || this.$element;
+       this.popupLabel = new OO.ui.LabelWidget();
+
+       // Mixin constructors
+       OO.ui.mixin.PopupElement.call( this, $.extend( {
+               popup: {
+                       padded: false,
+                       align: 'center',
+                       position: 'above',
+                       $content: $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
+                               .append( this.popupLabel.$element ),
+                       $floatableContainer: this.$element,
+                       classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
                }
-       };
-
-       /**
-        * Get the selected state of this widget
-        *
-        * @return {boolean} Tag is selected
-        */
-       TagItemWidget.prototype.isSelected = function () {
-               return this.selected;
-       };
-
-       /**
-        * Get item name
-        *
-        * @return {string} Filter name
-        */
-       TagItemWidget.prototype.getName = function () {
-               return this.itemModel.getName();
-       };
-
-       /**
-        * Get item model
-        *
-        * @return {string} Filter model
-        */
-       TagItemWidget.prototype.getModel = function () {
-               return this.itemModel;
-       };
-
-       /**
-        * Get item view
-        *
-        * @return {string} Filter view
-        */
-       TagItemWidget.prototype.getView = function () {
-               return this.itemModel.getGroupModel().getView();
-       };
-
-       /**
-        * Remove and destroy external elements of this widget
-        */
-       TagItemWidget.prototype.destroy = function () {
-               // Destroy the popup
-               this.popup.$element.detach();
-
-               // Disconnect events
-               this.itemModel.disconnect( this );
-               this.closeButton.disconnect( this );
-       };
-
-       module.exports = TagItemWidget;
-}() );
+       }, config ) );
+
+       this.popupTimeoutShow = null;
+       this.popupTimeoutHide = null;
+
+       this.$highlight = $( '<div>' )
+               .addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );
+
+       // Add title attribute with the item label to 'x' button
+       this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );
+
+       // Events
+       this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
+       this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
+       this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
+
+       // Initialization
+       this.$overlay.append( this.popup.$element );
+       this.$element
+               .addClass( 'mw-rcfilters-ui-tagItemWidget' )
+               .prepend( this.$highlight )
+               .attr( 'aria-haspopup', 'true' )
+               .on( 'mouseenter', this.onMouseEnter.bind( this ) )
+               .on( 'mouseleave', this.onMouseLeave.bind( this ) );
+
+       this.updateUiBasedOnState();
+};
+
+/* Initialization */
+
+OO.inheritClass( TagItemWidget, OO.ui.TagItemWidget );
+OO.mixinClass( TagItemWidget, OO.ui.mixin.PopupElement );
+
+/* Methods */
+
+/**
+ * Respond to model update event
+ */
+TagItemWidget.prototype.updateUiBasedOnState = function () {
+       // Update label if needed
+       var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel.isSelected() );
+       if ( labelMsg ) {
+               this.setLabel( $( '<div>' ).append(
+                       $( '<bdi>' ).html(
+                               mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
+                       )
+               ).contents() );
+       } else {
+               this.setLabel(
+                       $( '<bdi>' ).append(
+                               this.itemModel.getLabel()
+                       )
+               );
+       }
+
+       this.setCurrentMuteState();
+       this.setHighlightColor();
+};
+
+/**
+ * Set the current highlight color for this item
+ */
+TagItemWidget.prototype.setHighlightColor = function () {
+       var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
+               this.itemModel.getHighlightColor() :
+               null;
+
+       this.$highlight
+               .attr( 'data-color', selectedColor )
+               .toggleClass(
+                       'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
+                       !!selectedColor
+               );
+};
+
+/**
+ * Set the current mute state for this item
+ */
+TagItemWidget.prototype.setCurrentMuteState = function () {};
+
+/**
+ * Respond to mouse enter event
+ */
+TagItemWidget.prototype.onMouseEnter = function () {
+       var labelText = this.itemModel.getStateMessage();
+
+       if ( labelText ) {
+               this.popupLabel.setLabel( labelText );
+
+               // Set timeout for the popup to show
+               this.popupTimeoutShow = setTimeout( function () {
+                       this.popup.toggle( true );
+               }.bind( this ), 500 );
+
+               // Cancel the hide timeout
+               clearTimeout( this.popupTimeoutHide );
+               this.popupTimeoutHide = null;
+       }
+};
+
+/**
+ * Respond to mouse leave event
+ */
+TagItemWidget.prototype.onMouseLeave = function () {
+       this.popupTimeoutHide = setTimeout( function () {
+               this.popup.toggle( false );
+       }.bind( this ), 250 );
+
+       // Clear the show timeout
+       clearTimeout( this.popupTimeoutShow );
+       this.popupTimeoutShow = null;
+};
+
+/**
+ * Set selected state on this widget
+ *
+ * @param {boolean} [isSelected] Widget is selected
+ */
+TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
+       isSelected = isSelected !== undefined ? isSelected : !this.selected;
+
+       if ( this.selected !== isSelected ) {
+               this.selected = isSelected;
+
+               this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
+       }
+};
+
+/**
+ * Get the selected state of this widget
+ *
+ * @return {boolean} Tag is selected
+ */
+TagItemWidget.prototype.isSelected = function () {
+       return this.selected;
+};
+
+/**
+ * Get item name
+ *
+ * @return {string} Filter name
+ */
+TagItemWidget.prototype.getName = function () {
+       return this.itemModel.getName();
+};
+
+/**
+ * Get item model
+ *
+ * @return {string} Filter model
+ */
+TagItemWidget.prototype.getModel = function () {
+       return this.itemModel;
+};
+
+/**
+ * Get item view
+ *
+ * @return {string} Filter view
+ */
+TagItemWidget.prototype.getView = function () {
+       return this.itemModel.getGroupModel().getView();
+};
+
+/**
+ * Remove and destroy external elements of this widget
+ */
+TagItemWidget.prototype.destroy = function () {
+       // Destroy the popup
+       this.popup.$element.detach();
+
+       // Disconnect events
+       this.itemModel.disconnect( this );
+       this.closeButton.disconnect( this );
+};
+
+module.exports = TagItemWidget;
index ebd81c8..3ce63ee 100644 (file)
-( function () {
-       /**
-        * Widget defining the behavior used to choose from a set of values
-        * in a single_value group
-        *
-        * @class mw.rcfilters.ui.ValuePickerWidget
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.LabelElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FilterGroup} model Group model
-        * @param {Object} [config] Configuration object
-        * @cfg {Function} [itemFilter] A filter function for the items from the
-        *  model. If not given, all items will be included. The function must
-        *  handle item models and return a boolean whether the item is included
-        *  or not. Example: function ( itemModel ) { return itemModel.isSelected(); }
-        */
-       var ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) {
-               config = config || {};
-
-               // Parent
-               ValuePickerWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, config );
-
-               this.model = model;
-               this.itemFilter = config.itemFilter || function () {
-                       return true;
-               };
-
-               // Build the selection from the item models
-               this.selectWidget = new OO.ui.ButtonSelectWidget();
-               this.initializeSelectWidget();
-
-               // Events
-               this.model.connect( this, { update: 'onModelUpdate' } );
-               this.selectWidget.connect( this, { choose: 'onSelectWidgetChoose' } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-valuePickerWidget' )
-                       .append(
-                               this.$label
-                                       .addClass( 'mw-rcfilters-ui-valuePickerWidget-title' ),
-                               this.selectWidget.$element
-                       );
+/**
+ * Widget defining the behavior used to choose from a set of values
+ * in a single_value group
+ *
+ * @class mw.rcfilters.ui.ValuePickerWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FilterGroup} model Group model
+ * @param {Object} [config] Configuration object
+ * @cfg {Function} [itemFilter] A filter function for the items from the
+ *  model. If not given, all items will be included. The function must
+ *  handle item models and return a boolean whether the item is included
+ *  or not. Example: function ( itemModel ) { return itemModel.isSelected(); }
+ */
+var ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) {
+       config = config || {};
+
+       // Parent
+       ValuePickerWidget.parent.call( this, config );
+       // Mixin constructors
+       OO.ui.mixin.LabelElement.call( this, config );
+
+       this.model = model;
+       this.itemFilter = config.itemFilter || function () {
+               return true;
        };
 
-       /* Initialization */
-
-       OO.inheritClass( ValuePickerWidget, OO.ui.Widget );
-       OO.mixinClass( ValuePickerWidget, OO.ui.mixin.LabelElement );
-
-       /* Events */
-
-       /**
-        * @event choose
-        * @param {string} name Item name
-        *
-        * An item has been chosen
-        */
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       ValuePickerWidget.prototype.onModelUpdate = function () {
-               this.selectCurrentModelItem();
-       };
-
-       /**
-        * Respond to select widget choose event
-        *
-        * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item
-        * @fires choose
-        */
-       ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) {
-               this.emit( 'choose', chosenItem.getData() );
-       };
-
-       /**
-        * Initialize the select widget
-        */
-       ValuePickerWidget.prototype.initializeSelectWidget = function () {
-               var items = this.model.getItems()
-                       .filter( this.itemFilter )
-                       .map( function ( filterItem ) {
-                               return new OO.ui.ButtonOptionWidget( {
-                                       data: filterItem.getName(),
-                                       label: filterItem.getLabel()
-                               } );
+       // Build the selection from the item models
+       this.selectWidget = new OO.ui.ButtonSelectWidget();
+       this.initializeSelectWidget();
+
+       // Events
+       this.model.connect( this, { update: 'onModelUpdate' } );
+       this.selectWidget.connect( this, { choose: 'onSelectWidgetChoose' } );
+
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-valuePickerWidget' )
+               .append(
+                       this.$label
+                               .addClass( 'mw-rcfilters-ui-valuePickerWidget-title' ),
+                       this.selectWidget.$element
+               );
+};
+
+/* Initialization */
+
+OO.inheritClass( ValuePickerWidget, OO.ui.Widget );
+OO.mixinClass( ValuePickerWidget, OO.ui.mixin.LabelElement );
+
+/* Events */
+
+/**
+ * @event choose
+ * @param {string} name Item name
+ *
+ * An item has been chosen
+ */
+
+/* Methods */
+
+/**
+ * Respond to model update event
+ */
+ValuePickerWidget.prototype.onModelUpdate = function () {
+       this.selectCurrentModelItem();
+};
+
+/**
+ * Respond to select widget choose event
+ *
+ * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item
+ * @fires choose
+ */
+ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) {
+       this.emit( 'choose', chosenItem.getData() );
+};
+
+/**
+ * Initialize the select widget
+ */
+ValuePickerWidget.prototype.initializeSelectWidget = function () {
+       var items = this.model.getItems()
+               .filter( this.itemFilter )
+               .map( function ( filterItem ) {
+                       return new OO.ui.ButtonOptionWidget( {
+                               data: filterItem.getName(),
+                               label: filterItem.getLabel()
                        } );
+               } );
 
-               this.selectWidget.clearItems();
-               this.selectWidget.addItems( items );
+       this.selectWidget.clearItems();
+       this.selectWidget.addItems( items );
 
-               this.selectCurrentModelItem();
-       };
+       this.selectCurrentModelItem();
+};
 
-       /**
       * Select the current item that corresponds with the model item
       * that is currently selected
       */
-       ValuePickerWidget.prototype.selectCurrentModelItem = function () {
-               var selectedItem = this.model.findSelectedItems()[ 0 ];
+/**
+ * Select the current item that corresponds with the model item
+ * that is currently selected
+ */
+ValuePickerWidget.prototype.selectCurrentModelItem = function () {
+       var selectedItem = this.model.findSelectedItems()[ 0 ];
 
-               if ( selectedItem ) {
-                       this.selectWidget.selectItemByData( selectedItem.getName() );
-               }
-       };
+       if ( selectedItem ) {
+               this.selectWidget.selectItemByData( selectedItem.getName() );
+       }
+};
 
-       module.exports = ValuePickerWidget;
-}() );
+module.exports = ValuePickerWidget;
index c00d414..e366277 100644 (file)
@@ -1,84 +1,82 @@
-( function () {
-       var GroupWidget = require( './GroupWidget.js' ),
-               ViewSwitchWidget;
+var GroupWidget = require( './GroupWidget.js' ),
+       ViewSwitchWidget;
 
-       /**
       * A widget for the footer for the default view, allowing to switch views
       *
       * @class mw.rcfilters.ui.ViewSwitchWidget
       * @extends OO.ui.Widget
       *
       * @constructor
       * @param {mw.rcfilters.Controller} controller Controller
       * @param {mw.rcfilters.dm.FiltersViewModel} model View model
       * @param {Object} [config] Configuration object
       */
-       ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
-               config = config || {};
+/**
+ * A widget for the footer for the default view, allowing to switch views
+ *
+ * @class mw.rcfilters.ui.ViewSwitchWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} [config] Configuration object
+ */
+ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
+       config = config || {};
 
-               // Parent
-               ViewSwitchWidget.parent.call( this, config );
+       // Parent
+       ViewSwitchWidget.parent.call( this, config );
 
-               this.controller = controller;
-               this.model = model;
+       this.controller = controller;
+       this.model = model;
 
-               this.buttons = new GroupWidget( {
-                       events: {
-                               click: 'buttonClick'
-                       },
-                       items: [
-                               new OO.ui.ButtonWidget( {
-                                       data: 'namespaces',
-                                       icon: 'article',
-                                       label: mw.msg( 'namespaces' )
-                               } ),
-                               new OO.ui.ButtonWidget( {
-                                       data: 'tags',
-                                       icon: 'tag',
-                                       label: mw.msg( 'rcfilters-view-tags' )
-                               } )
-                       ]
-               } );
+       this.buttons = new GroupWidget( {
+               events: {
+                       click: 'buttonClick'
+               },
+               items: [
+                       new OO.ui.ButtonWidget( {
+                               data: 'namespaces',
+                               icon: 'article',
+                               label: mw.msg( 'namespaces' )
+                       } ),
+                       new OO.ui.ButtonWidget( {
+                               data: 'tags',
+                               icon: 'tag',
+                               label: mw.msg( 'rcfilters-view-tags' )
+                       } )
+               ]
+       } );
 
-               // Events
-               this.model.connect( this, { update: 'onModelUpdate' } );
-               this.buttons.connect( this, { buttonClick: 'onButtonClick' } );
+       // Events
+       this.model.connect( this, { update: 'onModelUpdate' } );
+       this.buttons.connect( this, { buttonClick: 'onButtonClick' } );
 
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
-                       .append(
-                               new OO.ui.LabelWidget( {
-                                       label: mw.msg( 'rcfilters-advancedfilters' )
-                               } ).$element,
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
-                                       .append( this.buttons.$element )
-                       );
-       };
+       this.$element
+               .addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
+               .append(
+                       new OO.ui.LabelWidget( {
+                               label: mw.msg( 'rcfilters-advancedfilters' )
+                       } ).$element,
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
+                               .append( this.buttons.$element )
+               );
+};
 
-       /* Initialize */
+/* Initialize */
 
-       OO.inheritClass( ViewSwitchWidget, OO.ui.Widget );
+OO.inheritClass( ViewSwitchWidget, OO.ui.Widget );
 
-       /**
       * Respond to model update event
       */
-       ViewSwitchWidget.prototype.onModelUpdate = function () {
-               var currentView = this.model.getCurrentView();
+/**
+ * Respond to model update event
+ */
+ViewSwitchWidget.prototype.onModelUpdate = function () {
+       var currentView = this.model.getCurrentView();
 
-               this.buttons.getItems().forEach( function ( buttonWidget ) {
-                       buttonWidget.setActive( buttonWidget.getData() === currentView );
-               } );
-       };
+       this.buttons.getItems().forEach( function ( buttonWidget ) {
+               buttonWidget.setActive( buttonWidget.getData() === currentView );
+       } );
+};
 
-       /**
       * Respond to button switch click
       *
       * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
       */
-       ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
-               this.controller.switchView( buttonWidget.getData() );
-       };
+/**
+ * Respond to button switch click
+ *
+ * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
+ */
+ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
+       this.controller.switchView( buttonWidget.getData() );
+};
 
-       module.exports = ViewSwitchWidget;
-}() );
+module.exports = ViewSwitchWidget;
index 16c0533..7796148 100644 (file)
@@ -1,88 +1,86 @@
-( function () {
-       var MarkSeenButtonWidget = require( './MarkSeenButtonWidget.js' ),
-               WatchlistTopSectionWidget;
-       /**
-        * Top section (between page title and filters) on Special:Watchlist
-        *
-        * @class mw.rcfilters.ui.WatchlistTopSectionWidget
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
-        * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
-        * @param {Object} [config] Configuration object
-        */
-       WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
-               controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
-       ) {
-               var editWatchlistButton,
-                       markSeenButton,
-                       $topTable,
-                       $bottomTable,
-                       $separator;
-               config = config || {};
+var MarkSeenButtonWidget = require( './MarkSeenButtonWidget.js' ),
+       WatchlistTopSectionWidget;
+/**
+ * Top section (between page title and filters) on Special:Watchlist
+ *
+ * @class mw.rcfilters.ui.WatchlistTopSectionWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+ * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
+ * @param {Object} [config] Configuration object
+ */
+WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
+       controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
+) {
+       var editWatchlistButton,
+               markSeenButton,
+               $topTable,
+               $bottomTable,
+               $separator;
+       config = config || {};
 
-               // Parent
-               WatchlistTopSectionWidget.parent.call( this, config );
+       // Parent
+       WatchlistTopSectionWidget.parent.call( this, config );
 
-               editWatchlistButton = new OO.ui.ButtonWidget( {
-                       label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
-                       icon: 'edit',
-                       href: require( '../config.json' ).StructuredChangeFiltersEditWatchlistUrl
-               } );
-               markSeenButton = new MarkSeenButtonWidget( controller, changesListModel );
+       editWatchlistButton = new OO.ui.ButtonWidget( {
+               label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
+               icon: 'edit',
+               href: require( '../config.json' ).StructuredChangeFiltersEditWatchlistUrl
+       } );
+       markSeenButton = new MarkSeenButtonWidget( controller, changesListModel );
 
-               $topTable = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-table' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-row' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
-                                                       .append( $watchlistDetails )
-                                       )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
-                                                       .append( editWatchlistButton.$element )
-                                       )
-                       );
+       $topTable = $( '<div>' )
+               .addClass( 'mw-rcfilters-ui-table' )
+               .append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-row' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                               .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
+                                               .append( $watchlistDetails )
+                               )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                               .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
+                                               .append( editWatchlistButton.$element )
+                               )
+               );
 
-               $bottomTable = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-table' )
-                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-row' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .append( markSeenButton.$element )
-                                       )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
-                                                       .append( savedLinksListWidget.$element )
-                                       )
-                       );
+       $bottomTable = $( '<div>' )
+               .addClass( 'mw-rcfilters-ui-table' )
+               .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
+               .append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-row' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                               .append( markSeenButton.$element )
+                               )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                               .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
+                                               .append( savedLinksListWidget.$element )
+                               )
+               );
 
-               $separator = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
+       $separator = $( '<div>' )
+               .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
 
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
-                       .append( $topTable, $separator, $bottomTable );
-       };
+       this.$element
+               .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
+               .append( $topTable, $separator, $bottomTable );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget );
+OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget );
 
-       module.exports = WatchlistTopSectionWidget;
-}() );
+module.exports = WatchlistTopSectionWidget;
index 90bcc2e..647c983 100644 (file)
@@ -57,7 +57,7 @@
 .mw-widget-dateInputWidget {
        &.oo-ui-textInputWidget {
                display: inline-block;
-               width: 21em;
+               max-width: 21em;
                // .oo-ui-inline-spacing( 0.5em ); already inherited from `.oo-ui-inputWidget`
 
                .oo-ui-labelElement-label {
index fb4c6ed..0a8dfb8 100644 (file)
                                 *  "text/javascript"; if no type is provided, text/javascript is assumed.
                                 */
                                load: function ( modules, type ) {
-                                       var filtered, l;
+                                       var l;
 
                                        // Allow calling with a url or single dependency as a string
                                        if ( typeof modules === 'string' ) {
                                                modules = [ modules ];
                                        }
 
-                                       // Filter out top-level modules that are unknown or failed to load before.
-                                       filtered = modules.filter( function ( module ) {
-                                               var state = mw.loader.getState( module );
-                                               return state !== 'error' && state !== 'missing';
-                                       } );
-                                       // Resolve remaining list using the known dependency tree.
-                                       // This also filters out modules with unknown dependencies. (T36853)
-                                       filtered = resolveStubbornly( filtered );
-                                       // Some modules are not yet ready, add to module load queue.
-                                       enqueue( filtered, undefined, undefined );
+                                       // Resolve modules into flat list for internal queuing.
+                                       // This also filters out unknown modules and modules with
+                                       // unknown dependencies, allowing the rest to continue. (T36853)
+                                       enqueue( resolveStubbornly( modules ), undefined, undefined );
                                },
 
                                /**
index bebf4dc..240757c 100644 (file)
  *
  * Other browsers that pass the check are considered Grade X.
  *
- * @param {string} [str] User agent, defaults to navigator.userAgent
+ * @private
+ * @param {string} ua User agent string
  * @return {boolean} User agent is compatible with MediaWiki JS
  */
-function isCompatible( str ) {
-       var ua = str || navigator.userAgent;
+function isCompatible( ua ) {
        return !!(
                // https://caniuse.com/#feat=es5
                // https://caniuse.com/#feat=use-strict
@@ -76,7 +76,7 @@ function isCompatible( str ) {
        );
 }
 
-if ( !isCompatible() ) {
+if ( !isCompatible( navigator.userAgent ) ) {
        // Handle Grade C
        // Undo speculative Grade A <html> class. See ResourceLoaderClientHtml::getDocumentAttributes().
        document.documentElement.className = document.documentElement.className
index c35f5d6..fd0cea1 100644 (file)
@@ -1627,6 +1627,10 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
 
                if ( $tablesToRestore ) {
                        $this->recloneMockTables( $db, $tablesToRestore );
+
+                       // Reset the restored tables, mainly for the side effect of
+                       // re-calling $this->addCoreDBData() if necessary.
+                       $this->resetDB( $db, $tablesToRestore );
                }
        }
 
@@ -1641,6 +1645,7 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
 
                if ( $oldOverrides['alter'] || $oldOverrides['create'] || $oldOverrides['drop'] ) {
                        $this->undoSchemaOverrides( $db, $oldOverrides );
+                       unset( $db->_schemaOverrides );
                }
 
                // Determine new overrides.
@@ -1792,6 +1797,12 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                        if ( array_intersect( $tablesUsed, $userTables ) ) {
                                $tablesUsed = array_unique( array_merge( $tablesUsed, $userTables ) );
                                TestUserRegistry::clear();
+
+                               // Reset $wgUser, which is probably 127.0.0.1, as its loaded data is probably not valid
+                               // @todo Should we start setting $wgUser to something nondeterministic
+                               //  to encourage tests to be updated to not depend on it?
+                               global $wgUser;
+                               $wgUser->clearInstanceCache( $wgUser->mFrom );
                        }
                        if ( array_intersect( $tablesUsed, $pageTables ) ) {
                                $tablesUsed = array_unique( array_merge( $tablesUsed, $pageTables ) );
index b03a309..2fa662b 100644 (file)
@@ -239,7 +239,7 @@ class MessageCacheTest extends MediaWikiLangTestCase {
                $importRevision->setComment( 'Imported edit' );
                $importRevision->setTimestamp( '19991122001122' );
                $importRevision->setText( 'IMPORTED OLD TEST' );
-               $importRevision->setUsername( 'Alan Smithee' );
+               $importRevision->setUsername( 'ext>Alan Smithee' );
 
                $importer = MediaWikiServices::getInstance()->getWikiRevisionOldRevisionImporterNoUpdates();
                $importer->import( $importRevision );
index ac5fef9..5487556 100644 (file)
@@ -483,7 +483,7 @@ abstract class WikiPageDbTestBase extends MediaWikiLangTestCase {
                                'delete',
                                'delete',
                                'testing user 0 deletion',
-                               '0',
+                               null,
                                '127.0.0.1',
                                (string)$page->getTitle()->getNamespace(),
                                $page->getTitle()->getDBkey(),
index 5b4c6ef..ad33f6e 100644 (file)
@@ -238,7 +238,8 @@ class DumpAsserter {
 
                $this->assertNodeStart( "contributor" );
                $this->skipWhitespace();
-               $this->assertTextNode( "ip", false );
+               $this->assertTextNode( "username", false );
+               $this->assertTextNode( "id", false );
                $this->assertNodeEnd( "contributor" );
                $this->skipWhitespace();
 
index 26c9b92..7647915 100644 (file)
@@ -68,7 +68,7 @@ abstract class DumpTestCase extends MediaWikiLangTestCase {
        ) {
                $status = $page->doEditContent(
                        ContentHandler::makeContent( $text, $page->getTitle(), $model ),
-                       $summary
+                       $summary, 0, false, $this->getTestUser()->getUser()
                );
 
                if ( $status->isGood() ) {
index 0d4bc56..e059834 100644 (file)
@@ -561,6 +561,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
 
                $content = $header;
                $iterations = intval( $iterations );
+               $username = $this->getTestUser()->getUser()->getName();
+               $userid = $this->getTestUser()->getUser()->getId();
                for ( $i = 0; $i < $iterations; $i++ ) {
                        $page1 = '  <page>
     <title>BackupDumperTestP1</title>
@@ -570,7 +572,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <id>' . ( $this->revId1_1 + $i * self::$numOfRevs ) . '</id>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>BackupDumperTestP1Summary1</comment>
       <model>wikitext</model>
@@ -588,7 +591,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <id>' . ( $this->revId2_1 + $i * self::$numOfRevs ) . '</id>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>BackupDumperTestP2Summary1</comment>
       <model>wikitext</model>
@@ -601,7 +605,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <parentid>' . ( $this->revId2_1 + $i * self::$numOfRevs ) . '</parentid>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>BackupDumperTestP2Summary2</comment>
       <model>wikitext</model>
@@ -614,7 +619,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <parentid>' . ( $this->revId2_2 + $i * self::$numOfRevs ) . '</parentid>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>BackupDumperTestP2Summary3</comment>
       <model>wikitext</model>
@@ -627,7 +633,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <parentid>' . ( $this->revId2_3 + $i * self::$numOfRevs ) . '</parentid>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>BackupDumperTestP2Summary4 extra</comment>
       <model>wikitext</model>
@@ -647,7 +654,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <id>' . ( $this->revId4_1 + $i * self::$numOfRevs ) . '</id>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>Talk BackupDumperTestP1 Summary1</comment>
       <model>BackupTextPassTestModel</model>