Back-end of new RecentChanges page, refactoring
authorMatthew Flaschen <mflaschen@wikimedia.org>
Tue, 14 Feb 2017 07:55:37 +0000 (02:55 -0500)
committerCatrope <roan@wikimedia.org>
Sat, 11 Mar 2017 01:42:01 +0000 (01:42 +0000)
Generate old RC, Related changes (it was already displayed and working
on 'Related changes' before this change), and Watchlist/etc. and data
for new UI from back-end.

This moves everything used for defining the old (unstructured) and new
(structured) filters into unified objects, ChangesListFilter and
ChangesListFilterGroup (and sub-classes).

This includes the query logic (see below) and logic for adding
CSS attribution classes.

This is a breaking change (for subclasses of ChangesListSpecialpage)
due to the signature (and name) change of buildMainQueryConds and
doMainQuery.  An alternative that I don't think is a breaking change
would be to put the filter->DB logic in runMainQueryHook, but then it's
doing more than just running a hook.

This is because it used to only build $conds here, but it's clear from
filterOnUserExperienceLevel filters need more than this.  I added all
the DB parameters from the hook, but this could be debated.

I have an checked and fixed the WMF-deployed extensions affected by
this.

Other than that, there should be full back-compat (including legacy
filters not using the new system).

* add hidepatrolled/hideunpatrolled to new UI.

* Move userExpLevel from RC to ChangesListSpecialPage.  Although for
now the structured UI only displays on RC anyway, when it displays on
watchlist, it seems we'll want userExpLevel there.

  Change this to make 'all' exclude unregistered users.

* Don't have front-end convert none-selected to 'all' on string_options.

* Needs the hideanons/hideliu special redirect to be done before this
is merged (T151873)

Bug: T152754
Bug: T152797
Change-Id: Iec2d82f6a830403d1c948a280efa58992e0cdee7

29 files changed:
RELEASE-NOTES-1.29
autoload.php
docs/hooks.txt
includes/changes/ChangesList.php
includes/changes/ChangesListBooleanFilter.php [new file with mode: 0644]
includes/changes/ChangesListBooleanFilterGroup.php [new file with mode: 0644]
includes/changes/ChangesListFilter.php [new file with mode: 0644]
includes/changes/ChangesListFilterGroup.php [new file with mode: 0644]
includes/changes/ChangesListStringOptionsFilter.php [new file with mode: 0644]
includes/changes/ChangesListStringOptionsFilterGroup.php [new file with mode: 0644]
includes/changes/EnhancedChangesList.php
includes/specialpage/ChangesListSpecialPage.php
includes/specials/SpecialRecentchanges.php
includes/specials/SpecialRecentchangeslinked.php
includes/specials/SpecialWatchlist.php
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
tests/common/TestsAutoLoader.php
tests/phpunit/includes/changes/ChangesListBooleanFilterGroupTest.php [new file with mode: 0644]
tests/phpunit/includes/changes/ChangesListBooleanFilterTest.php [new file with mode: 0644]
tests/phpunit/includes/changes/ChangesListStringOptionsFilterGroupTest.php [new file with mode: 0644]
tests/phpunit/includes/specialpage/AbstractChangesListSpecialPageTestCase.php [new file with mode: 0644]
tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php [new file with mode: 0644]
tests/phpunit/includes/specials/SpecialRecentchangesTest.php
tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js

index 5bc66fd..c68c677 100644 (file)
@@ -54,6 +54,9 @@ production.
   This might affect some forms that used them and only worked because the
   attributes were not actually being set.
 * Expiry times can now be specified when users are added to user groups.
+* Completely new user interface for the RecentChanges page, which
+  structures filters into user-friendly groups.  This has corresponding
+  changes to how filters are registered by core and extensions.
 
 === External library changes in 1.29 ===
 
@@ -245,6 +248,15 @@ changes to languages because of Phabricator reports.
 * User::comparePasswords() (deprecated in 1.24) was removed.
 * ArchivedFile::getUserText() (deprecated in 1.23) was removed.
 * HTMLFileCache::newFromTitle() (deprecated in 1.24) was removed.
+* BREAKING CHANGE: Internal signature changes to ChangesListSpecialPage
+  and subclasses.  It should only break if you call buildMainQueryConds
+  (changed to buildQuery with new signature) or doMainQuery (new
+  signature).  Subclasses are likely to call at least doMainQuery
+  (possibly both), but other classes might too, because they were
+  public.
+
+  Also, some related hooks were deprecated, but this is not yet a
+  breaking change.
 
 == Compatibility ==
 
index 0f79323..4ffaa11 100644 (file)
@@ -237,7 +237,13 @@ $wgAutoloadLocalClasses = [
        'ChangeTagsRevisionList' => __DIR__ . '/includes/changetags/ChangeTagsRevisionList.php',
        'ChangesFeed' => __DIR__ . '/includes/changes/ChangesFeed.php',
        'ChangesList' => __DIR__ . '/includes/changes/ChangesList.php',
+       'ChangesListBooleanFilter' => __DIR__ . '/includes/changes/ChangesListBooleanFilter.php',
+       'ChangesListBooleanFilterGroup' => __DIR__ . '/includes/changes/ChangesListBooleanFilterGroup.php',
+       'ChangesListFilter' => __DIR__ . '/includes/changes/ChangesListFilter.php',
+       'ChangesListFilterGroup' => __DIR__ . '/includes/changes/ChangesListFilterGroup.php',
        'ChangesListSpecialPage' => __DIR__ . '/includes/specialpage/ChangesListSpecialPage.php',
+       'ChangesListStringOptionsFilter' => __DIR__ . '/includes/changes/ChangesListStringOptionsFilter.php',
+       'ChangesListStringOptionsFilterGroup' => __DIR__ . '/includes/changes/ChangesListStringOptionsFilterGroup.php',
        'ChannelFeed' => __DIR__ . '/includes/Feed.php',
        'CheckBadRedirects' => __DIR__ . '/maintenance/checkBadRedirects.php',
        'CheckComposerLockUpToDate' => __DIR__ . '/maintenance/checkComposerLockUpToDate.php',
index 846a073..149ee4b 100644 (file)
@@ -982,7 +982,9 @@ $rows: The data that will be rendered. May be a ResultWrapper instance or
 $unpatrolled: Whether or not we are showing unpatrolled changes.
 $watched: Whether or not the change is watched by the user.
 
-'ChangesListSpecialPageFilters': Called after building form options on pages
+'ChangesListSpecialPageFilters': DEPRECATED! Use 'ChangesListSpecialPageStructuredFilters'
+instead.
+Called after building form options on pages
 inheriting from ChangesListSpecialPage (in core: RecentChanges,
 RecentChangesLinked and Watchlist).
 $special: ChangesListSpecialPage instance
@@ -993,6 +995,15 @@ $special: ChangesListSpecialPage instance
 'ChangesListSpecialPageQuery': Called when building SQL query on pages
 inheriting from ChangesListSpecialPage (in core: RecentChanges,
 RecentChangesLinked and Watchlist).
+
+Do not use this to implement individual filters if they are compatible with the
+ChangesListFilter and ChangesListFilterGroup structure.
+
+Instead, use sub-classes of those classes, in conjunction with the
+ChangesListSpecialPageStructuredFilters hook.
+
+This hook can be used to implement filters that do not implement that structure,
+or custom behavior that is not an individual filter.
 $name: name of the special page, e.g. 'Watchlist'
 &$tables: array of tables to be queried
 &$fields: array of columns to select
@@ -1001,6 +1012,15 @@ $name: name of the special page, e.g. 'Watchlist'
 &$join_conds: join conditions for the tables
 $opts: FormOptions for this request
 
+'ChangesListSpecialPageStructuredFilters': Called to allow extensions to register
+filters for pages inheriting from ChangesListSpecialPage (in core: RecentChanges,
+RecentChangesLinked, and Watchlist).  Generally, you will want to construct
+new ChangesListBooleanFilter or ChangesListStringOptionsFilter objects.  You can
+then either add them to existing ChangesListFilterGroup objects (accessed through
+$special->getFilterGroup), or create your own.  If you create new groups, you
+must register them with $special->registerFilterGroup.
+$special: ChangesListSpecialPage instance
+
 'ChangeTagAfterDelete': Called after a change tag has been deleted (that is,
 removed from all revisions and log entries to which it was applied). This gives
 extensions a chance to take it off their books.
@@ -1093,7 +1113,7 @@ $title: the Title in question
 a given content model name, but no entry for that model exists in
 $wgContentHandlers.
 Note: if your extension implements additional models via this hook, please
-use GetContentModels hook to make them known to core. 
+use GetContentModels hook to make them known to core.
 $modeName: the requested content model name
 &$handler: set this to a ContentHandler object, if desired.
 
@@ -3095,7 +3115,7 @@ use this to change some selection criteria or substitute a different title.
 &$title: If the hook returns false, a Title object to use instead of the
   result from the normal query
 
-'SpecialRecentChangesFilters': DEPRECATED! Use ChangesListSpecialPageFilters
+'SpecialRecentChangesFilters': DEPRECATED! Use ChangesListSpecialPageStructuredFilters
 instead.
 Called after building form options at RecentChanges.
 $special: the special page object
@@ -3108,8 +3128,8 @@ SpecialRecentChanges.
 &$extraOpts: array of added items, to which can be added
 $opts: FormOptions for this request
 
-'SpecialRecentChangesQuery': DEPRECATED! Use ChangesListSpecialPageQuery
-instead.
+'SpecialRecentChangesQuery': DEPRECATED! Use ChangesListSpecialPageStructuredFilters
+or ChangesListSpecialPageQuery instead.
 Called when building SQL query for SpecialRecentChanges and
 SpecialRecentChangesLinked.
 &$conds: array of WHERE conditionals for query
@@ -3211,7 +3231,7 @@ Special:Upload.
 $wgVersion: Current $wgVersion for you to use
 &$versionUrl: Raw url to link to (eg: release notes)
 
-'SpecialWatchlistFilters': DEPRECATED! Use ChangesListSpecialPageFilters
+'SpecialWatchlistFilters': DEPRECATED! Use ChangesListSpecialPageStructuredFilters
 instead.
 Called after building form options at Watchlist.
 $special: the special page object
@@ -3224,7 +3244,8 @@ SpecialWatchlist. Allows extensions to register custom values they have
 inserted to rc_type so they can be returned as part of the watchlist.
 &$nonRevisionTypes: array of values in the rc_type field of recentchanges table
 
-'SpecialWatchlistQuery': DEPRECATED! Use ChangesListSpecialPageQuery instead.
+'SpecialWatchlistQuery': DEPRECATED! Use ChangesListSpecialPageStructuredFilters
+or ChangesListSpecialPageQuery instead.
 Called when building sql query for SpecialWatchlist.
 &$conds: array of WHERE conditionals for query
 &$tables: array of tables to be queried
index 3f4ad14..92a3d3f 100644 (file)
@@ -26,6 +26,8 @@ use MediaWiki\MediaWikiServices;
 use Wikimedia\Rdbms\ResultWrapper;
 
 class ChangesList extends ContextSource {
+       const CSS_CLASS_PREFIX = 'mw-changeslist-';
+
        /**
         * @var Skin
         */
@@ -47,12 +49,18 @@ class ChangesList extends ContextSource {
         */
        protected $linkRenderer;
 
+       /**
+        * @var array
+        */
+       protected $filterGroups;
+
        /**
         * Changeslist constructor
         *
         * @param Skin|IContextSource $obj
+        * @param array $filterGroups Array of ChangesListFilterGroup objects (currently optional)
         */
-       public function __construct( $obj ) {
+       public function __construct( $obj, array $filterGroups = [] ) {
                if ( $obj instanceof IContextSource ) {
                        $this->setContext( $obj );
                        $this->skin = $obj->getSkin();
@@ -63,6 +71,7 @@ class ChangesList extends ContextSource {
                $this->preCacheMessages();
                $this->watchMsgCache = new HashBagOStuff( [ 'maxKeys' => 50 ] );
                $this->linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+               $this->filterGroups = $filterGroups;
        }
 
        /**
@@ -70,16 +79,19 @@ class ChangesList extends ContextSource {
         * Some users might want to use an enhanced list format, for instance
         *
         * @param IContextSource $context
+        * @param array $groups Array of ChangesListFilterGroup objects (currently optional)
         * @return ChangesList
         */
-       public static function newFromContext( IContextSource $context ) {
+       public static function newFromContext( IContextSource $context, array $groups = [] ) {
                $user = $context->getUser();
                $sk = $context->getSkin();
                $list = null;
                if ( Hooks::run( 'FetchChangesList', [ $user, &$sk, &$list ] ) ) {
                        $new = $context->getRequest()->getBool( 'enhanced', $user->getOption( 'usenewrc' ) );
 
-                       return $new ? new EnhancedChangesList( $context ) : new OldChangesList( $context );
+                       return $new ?
+                               new EnhancedChangesList( $context, $groups ) :
+                               new OldChangesList( $context, $groups );
                } else {
                        return $list;
                }
@@ -159,42 +171,40 @@ class ChangesList extends ContextSource {
        protected function getHTMLClasses( $rc, $watched ) {
                $classes = [];
                $logType = $rc->mAttribs['rc_log_type'];
-               $prefix = 'mw-changeslist-';
 
                if ( $logType ) {
-                       $classes[] = Sanitizer::escapeClass( $prefix . 'log-' . $logType );
+                       $classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'log-' . $logType );
                } else {
-                       $classes[] = Sanitizer::escapeClass( $prefix . 'ns' .
+                       $classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'ns' .
                                $rc->mAttribs['rc_namespace'] . '-' . $rc->mAttribs['rc_title'] );
                }
 
                // Indicate watched status on the line to allow for more
                // comprehensive styling.
                $classes[] = $watched && $rc->mAttribs['rc_timestamp'] >= $watched
-                       ? $prefix . 'line-watched'
-                       : $prefix . 'line-not-watched';
+                       ? self::CSS_CLASS_PREFIX . 'line-watched'
+                       : self::CSS_CLASS_PREFIX . 'line-not-watched';
 
                $classes = array_merge( $classes, $this->getHTMLClassesForFilters( $rc ) );
 
                return $classes;
        }
 
+       /**
+        * Get an array of CSS classes attributed to filters for this row
+        *
+        * @param RecentChange $rc
+        * @return array Array of CSS classes
+        */
        protected function getHTMLClassesForFilters( $rc ) {
                $classes = [];
-               $prefix = 'mw-changeslist-';
-
-               $classes[] = $prefix . ( $rc->getAttribute( 'rc_bot' ) ? 'bot' : 'human' );
-               $classes[] = $prefix . ( $rc->getAttribute( 'rc_user' ) ? 'liu' : 'anon' );
-               $classes[] = $prefix . ( $rc->getAttribute( 'rc_minor' ) ? 'minor' : 'major' );
-               $classes[] = $prefix .
-                       ( $rc->getAttribute( 'rc_patrolled' ) ? 'patrolled' : 'unpatrolled' );
-               $classes[] = $prefix .
-                       ( $this->getUser()->equals( $rc->getPerformer() ) ? 'self' : 'others' );
-               $classes[] = $prefix . 'src-' . str_replace( '.', '-', $rc->getAttribute( 'rc_source' ) );
-
-               $performer = $rc->getPerformer();
-               if ( $performer && $performer->isLoggedIn() ) {
-                       $classes[] = $prefix . 'user-' . $performer->getExperienceLevel();
+
+               if ( $this->filterGroups !== null ) {
+                       foreach ( $this->filterGroups as $filterGroup ) {
+                               foreach ( $filterGroup->getFilters() as $filter ) {
+                                       $filter->applyCssClassIfNeeded( $this, $rc, $classes );
+                               }
+                       }
                }
 
                return $classes;
diff --git a/includes/changes/ChangesListBooleanFilter.php b/includes/changes/ChangesListBooleanFilter.php
new file mode 100644 (file)
index 0000000..b6be1f9
--- /dev/null
@@ -0,0 +1,225 @@
+<?php
+/**
+ * Represents a hide-based boolean filter (used on ChangesListSpecialPage and descendants)
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Matthew Flaschen
+ */
+
+/**
+ * An individual filter in a boolean group
+ *
+ * @since 1.29
+ */
+class ChangesListBooleanFilter extends ChangesListFilter {
+       /**
+        * Name.  Used as URL parameter
+        *
+        * @var string $name
+        */
+
+       // This can sometimes be different on Special:RecentChanges
+       // and Special:Watchlist, due to the double-legacy hooks
+       // (SpecialRecentChangesFilters and SpecialWatchlistFilters)
+
+       // but there will be separate sets of ChangesListFilterGroup and ChangesListFilter instances
+       // for those pages (it should work even if they're both loaded
+       // at once, but that can't happen).
+       /**
+        * Main unstructured UI i18n key
+        *
+        * @var string $showHide
+        */
+       protected $showHide;
+
+       /**
+        * Whether there is a feature designed to replace this filter available on the
+        * structured UI
+        *
+        * @var bool $isReplacedInStructuredUi
+        */
+       protected $isReplacedInStructuredUi;
+
+       /**
+        * Default
+        *
+        * @var bool $defaultValue
+        */
+       protected $defaultValue;
+
+       /**
+        * Callable used to do the actual query modification; see constructor
+        *
+        * @var callable $queryCallable
+        */
+       protected $queryCallable;
+
+       /**
+        * Create a new filter with the specified configuration.
+        *
+        * It infers which UI (it can be either or both) to display the filter on based on
+        * which messages are provided.
+        *
+        * If 'label' is provided, it will be displayed on the structured UI.  If
+        * 'showHide' is provided, it will be displayed on the unstructured UI.  Thus,
+        * 'label', 'description', and 'showHide' are optional depending on which UI
+        * it's for.
+        *
+        * @param array $filterDefinition ChangesListFilter definition
+        *
+        * $filterDefinition['name'] string Name.  Used as URL parameter.
+        * $filterDefinition['group'] ChangesListFilterGroup Group.  Filter group this
+        *  belongs to.
+        * $filterDefinition['label'] string i18n key of label for structured UI.
+        * $filterDefinition['description'] string i18n key of description for structured
+        *  UI.
+        * $filterDefinition['showHide'] string Main i18n key used for unstructured UI.
+        * $filterDefinition['isReplacedInStructuredUi'] bool Whether there is an
+        *  equivalent feature available in the structured UI; this is optional, defaulting
+        *  to true.  It does not need to be set if the exact same filter is simply visible
+        *  on both.
+        * $filterDefinition['default'] bool Default
+        * $filterDefinition['isAllowedCallable'] callable Callable taking two parameters,
+        *  the class name of the special page and an IContextSource, and returning true
+        *  if and only if the current user is permitted to use this filter on the current
+        *  wiki.  If it returns false, it will both hide the UI (in all UIs) and prevent
+        *  the DB query modification from taking effect. (optional, defaults to allowed)
+        * $filterDefinition['priority'] int Priority integer.  Higher value means higher
+        *  up in the group's filter list.
+        * $filterDefinition['queryCallable'] callable Callable accepting parameters, used
+        *  to implement filter's DB query modification.  Callback parameters:
+        *   string $specialPageClassName Class name of current special page
+        *   IContextSource $context Context, for e.g. user
+        *   IDatabase $dbr Database, for addQuotes, makeList, and similar
+        *   array &$tables Array of tables; see IDatabase::select $table
+        *   array &$fields Array of fields; see IDatabase::select $vars
+        *   array &$conds Array of conditions; see IDatabase::select $conds
+        *   array &$query_options Array of query options; see IDatabase::select $options
+        *   array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+        *   Optional only for legacy filters that still use the query hooks directly
+        */
+       public function __construct( $filterDefinition ) {
+               parent::__construct( $filterDefinition );
+
+               if ( isset( $filterDefinition['showHide'] ) ) {
+                       $this->showHide = $filterDefinition['showHide'];
+               }
+
+               if ( isset( $filterDefinition['isReplacedInStructuredUi'] ) ) {
+                       $this->isReplacedInStructuredUi = $filterDefinition['isReplacedInStructuredUi'];
+               } else {
+                       $this->isReplacedInStructuredUi = false;
+               }
+
+               if ( isset( $filterDefinition['default'] ) ) {
+                       $this->defaultValue = $filterDefinition['default'];
+               } else {
+                       throw new MWException( 'You must set a default' );
+               }
+
+               if ( isset( $filterDefinition['queryCallable'] ) ) {
+                       $this->queryCallable = $filterDefinition['queryCallable'];
+               }
+       }
+
+       /**
+        * @return bool|null Default value
+        */
+       public function getDefault() {
+               return $this->defaultValue;
+       }
+
+       /**
+        * Sets default
+        *
+        * @param bool Default value
+        */
+       public function setDefault( $defaultValue ) {
+               $this->defaultValue = $defaultValue;
+       }
+
+       /**
+        * @return string Main i18n key for unstructured UI
+        */
+       public function getShowHide() {
+               return $this->showHide;
+       }
+
+       /**
+        * @inheritdoc
+        */
+       public function displaysOnUnstructuredUi( ChangesListSpecialPage $specialPage ) {
+               return $this->showHide &&
+                       $this->isAllowed( $specialPage );
+       }
+
+       /**
+        * @inheritdoc
+        */
+       public function isFeatureAvailableOnStructuredUi( ChangesListSpecialPage $specialPage ) {
+               return $this->isReplacedInStructuredUi ||
+                       parent::isFeatureAvailableOnStructuredUi( $specialPage );
+       }
+
+       /**
+        * Modifies the query to include the filter.  This is only called if the filter is
+        * in effect (taking into account the default).
+        *
+        * @param IDatabase $dbr Database, for addQuotes, makeList, and similar
+        * @param ChangesListSpecialPage $specialPage Current special page
+        * @param array &$tables Array of tables; see IDatabase::select $table
+        * @param array &$fields Array of fields; see IDatabase::select $vars
+        * @param array &$conds Array of conditions; see IDatabase::select $conds
+        * @param array &$query_options Array of query options; see IDatabase::select $options
+        * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+        */
+       public function modifyQuery( IDatabase $dbr, ChangesListSpecialPage $specialPage,
+               &$tables, &$fields, &$conds, &$query_options, &$join_conds ) {
+
+               if ( $this->queryCallable === null ) {
+                       return;
+               }
+
+               call_user_func_array(
+                       $this->queryCallable,
+                       [
+                               get_class( $specialPage ),
+                               $specialPage->getContext(),
+                               $dbr,
+                               &$tables,
+                               &$fields,
+                               &$conds,
+                               &$query_options,
+                               &$join_conds
+                       ]
+               );
+       }
+
+       /**
+        * @inheritdoc
+        */
+       public function getJsData() {
+               $output = parent::getJsData();
+
+               $output['default'] = $this->defaultValue;
+
+               return $output;
+       }
+
+}
diff --git a/includes/changes/ChangesListBooleanFilterGroup.php b/includes/changes/ChangesListBooleanFilterGroup.php
new file mode 100644 (file)
index 0000000..1fdcd00
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * If the group is active, any unchecked filters will
+ * translate to hide parameters in the URL.  E.g. if 'Human (not bot)' is checked,
+ * but 'Bot' is unchecked, hidebots=1 will be sent.
+ *
+ * @since 1.29
+ */
+class ChangesListBooleanFilterGroup extends ChangesListFilterGroup {
+       /**
+        * Type marker, used by JavaScript
+        */
+       const TYPE = 'send_unselected_if_any';
+
+       /**
+        * Create a new filter group with the specified configuration
+        *
+        * @param array $groupDefinition Configuration of group
+        * * $groupDefinition['name'] string Group name
+        * * $groupDefinition['title'] string i18n key for title (optional, can be omitted
+        * *  only if none of the filters in the group display in the structured UI)
+        * * $groupDefinition['priority'] int Priority integer.  Higher means higher in the
+        * *  group list.
+        * * $groupDefinition['filters'] array Numeric array of filter definitions, each of which
+        * *  is an associative array to be passed to the filter constructor.  However,
+        * *  'priority' is optional for the filters.  Any filter that has priority unset
+        * *  will be put to the bottom, in the order given.
+        */
+       public function __construct( array $groupDefinition ) {
+               $groupDefinition['isFullCoverage'] = true;
+               $groupDefinition['type'] = self::TYPE;
+
+               parent::__construct( $groupDefinition );
+       }
+
+       /**
+        * @inheritdoc
+        */
+       protected function createFilter( array $filterDefinition ) {
+               return new ChangesListBooleanFilter( $filterDefinition );
+       }
+
+       /**
+        * Registers a filter in this group
+        *
+        * @param ChangesListBooleanFilter $filter ChangesListBooleanFilter
+        */
+       public function registerFilter( ChangesListBooleanFilter $filter ) {
+               $this->filters[$filter->getName()] = $filter;
+       }
+
+       /**
+        * @inheritdoc
+        */
+       public function isPerGroupRequestParameter() {
+               return false;
+       }
+}
diff --git a/includes/changes/ChangesListFilter.php b/includes/changes/ChangesListFilter.php
new file mode 100644 (file)
index 0000000..4ac6387
--- /dev/null
@@ -0,0 +1,418 @@
+<?php
+/**
+ * Represents a filter (used on ChangesListSpecialPage and descendants)
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Matthew Flaschen
+ */
+
+/**
+ * Represents a filter (used on ChangesListSpecialPage and descendants)
+ *
+ * @since 1.29
+ */
+abstract class ChangesListFilter {
+       /**
+        * Filter name
+        *
+        * @var string $name
+        */
+       protected $name;
+
+       /**
+        * CSS class suffix used for attribution, e.g. 'bot'.
+        *
+        * In this example, if bot actions are included in the result set, this CSS class
+        * will then be included in all bot-flagged actions.
+        *
+        * @var string|null $cssClassSuffix
+        */
+       protected $cssClassSuffix;
+
+       /**
+        * Callable that returns true if and only if a row is attributed to this filter
+        *
+        * @var callable $isRowApplicableCallable
+        */
+       protected $isRowApplicableCallable;
+
+       /**
+        * Group.  ChangesListFilterGroup this belongs to
+        *
+        * @var ChangesListFilterGroup $group
+        */
+       protected $group;
+
+       /**
+        * i18n key of label for structured UI
+        *
+        * @var string $label
+        */
+       protected $label;
+
+       /**
+        * i18n key of description for structured UI
+        *
+        * @var string $description
+        */
+       protected $description;
+
+       /**
+        * Callable used to check whether this filter is allowed to take effect
+        *
+        * @var callable $isAllowedCallable
+        */
+       protected $isAllowedCallable;
+
+       /**
+        * List of conflicting groups
+        *
+        * @var array $conflictingGroups Array of associative arrays with conflict
+        *   information.  See setUnidirectionalConflict
+        */
+       protected $conflictingGroups = [];
+
+       /**
+        * List of conflicting filters
+        *
+        * @var array $conflictingFilters Array of associative arrays with conflict
+        *   information.  See setUnidirectionalConflict
+        */
+       protected $conflictingFilters = [];
+
+       /**
+        * List of filters that are a subset of the current filter
+        *
+        * @var array $subsetFilters Array of associative arrays with subset information
+        */
+       protected $subsetFilters = [];
+
+       /**
+        * Priority integer.  Higher value means higher up in the group's filter list.
+        *
+        * @var string $priority
+        */
+       protected $priority;
+
+       /**
+        * Create a new filter with the specified configuration.
+        *
+        * It infers which UI (it can be either or both) to display the filter on based on
+        * which messages are provided.
+        *
+        * If 'label' is provided, it will be displayed on the structured UI.  Thus,
+        * 'label', 'description', and sub-class parameters are optional depending on which
+        * UI it's for.
+        *
+        * @param array $filterDefinition ChangesListFilter definition
+        *
+        * $filterDefinition['name'] string Name of filter
+        * $filterDefinition['cssClassSuffix'] string CSS class suffix, used to mark
+        *  that a particular row belongs to this filter (when a row is included by the
+        *  filter) (optional)
+        * $filterDefinition['isRowApplicableCallable'] Callable taking two parameters, the
+        *  IContextSource, and the RecentChange object for the row, and returning true if
+        *  the row is attributed to this filter.  The above CSS class will then be
+        *  automatically added (optional, required if cssClassSuffix is used).
+        * $filterDefinition['group'] ChangesListFilterGroup Group.  Filter group this
+        *  belongs to.
+        * $filterDefinition['label'] string i18n key of label for structured UI.
+        * $filterDefinition['description'] string i18n key of description for structured
+        *  UI.
+        * $filterDefinition['isAllowedCallable'] callable Callable taking two parameters,
+        *  the class name of the special page and an IContextSource, and returning true
+        *  if and only if the current user is permitted to use this filter on the current
+        *  wiki.  If it returns false, it will both hide the UI (in all UIs) and prevent
+        *  the DB query modification from taking effect. (optional, defaults to allowed)
+        * $filterDefinition['priority'] int Priority integer.  Higher value means higher
+        *  up in the group's filter list.
+        */
+       public function __construct( array $filterDefinition ) {
+               if ( isset( $filterDefinition['group'] ) ) {
+                       $this->group = $filterDefinition['group'];
+               } else {
+                       throw new MWException( 'You must use \'group\' to specify the ' .
+                               'ChangesListFilterGroup this filter belongs to' );
+               }
+
+               $this->name = $filterDefinition['name'];
+
+               if ( isset( $filterDefinition['cssClassSuffix'] ) ) {
+                       $this->cssClassSuffix = $filterDefinition['cssClassSuffix'];
+                       $this->isRowApplicableCallable = $filterDefinition['isRowApplicableCallable'];
+               }
+
+               if ( isset( $filterDefinition['label'] ) ) {
+                       $this->label = $filterDefinition['label'];
+                       $this->description = $filterDefinition['description'];
+               }
+
+               if ( isset( $filterDefinition['isAllowedCallable'] ) ) {
+                       $this->isAllowedCallable = $filterDefinition['isAllowedCallable'];
+               }
+
+               $this->priority = $filterDefinition['priority'];
+
+               $this->group->registerFilter( $this );
+       }
+
+       /**
+        * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with this object.
+        *
+        * WARNING: This means there is a conflict when both things are *shown*
+        * (not filtered out), even for the hide-based filters.  So e.g. conflicting with
+        * 'hideanons' means there is a conflict if only anonymous users are *shown*.
+        *
+        * @param ChangesListFilterGroup|ChangesListFilter $other Other
+        *  ChangesListFilterGroup or ChangesListFilter
+        * @param string $globalKey i18n key for top-level conflict message
+        * @param string $forwardKey i18n key for conflict message in this
+        *  direction (when in UI context of $this object)
+        * @param string $backwardKey i18n key for conflict message in reverse
+        *  direction (when in UI context of $other object)
+        */
+       public function conflictsWith( $other, $globalKey, $forwardKey,
+               $backwardKey ) {
+
+               if ( $globalKey === null || $forwardKey === null ||
+                       $backwardKey === null ) {
+
+                       throw new MWException( 'All messages must be specified' );
+               }
+
+               $this->setUnidirectionalConflict(
+                       $other,
+                       $globalKey,
+                       $forwardKey
+               );
+
+               $other->setUnidirectionalConflict(
+                       $this,
+                       $globalKey,
+                       $backwardKey
+               );
+       }
+
+       /**
+        * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with
+        * this object.
+        *
+        * Internal use ONLY.
+        *
+        * @param ChangesListFilterGroup|ChangesListFilter $other Other
+        *  ChangesListFilterGroup or ChangesListFilter
+        * @param string $globalDescription i18n key for top-level conflict message
+        * @param string $contextDescription i18n key for conflict message in this
+        *  direction (when in UI context of $this object)
+        */
+       public function setUnidirectionalConflict( $other, $globalDescription,
+               $contextDescription ) {
+
+               if ( $other instanceof ChangesListFilterGroup ) {
+                       $this->conflictingGroups[] = [
+                               'group' => $other->getName(),
+                               'globalDescription' => $globalDescription,
+                               'contextDescription' => $contextDescription,
+                       ];
+               } elseif ( $other instanceof ChangesListFilter ) {
+                       $this->conflictingFilters[] = [
+                               'group' => $other->getGroup()->getName(),
+                               'filter' => $other->getName(),
+                               'globalDescription' => $globalDescription,
+                               'contextDescription' => $contextDescription,
+                       ];
+               } else {
+                       throw new MWException( 'You can only pass in a ChangesListFilterGroup or a ChangesListFilter' );
+               }
+       }
+
+       /**
+        * Marks that the current instance is (also) a superset of the filter passed in.
+        * This can be called more than once.
+        *
+        * This means that anything in the results for the other filter is also in the
+        * results for this one.
+        *
+        * @param ChangesListFilter The filter the current instance is a superset of
+        */
+       public function setAsSupersetOf( ChangesListFilter $other ) {
+               if ( $other->getGroup() !== $this->getGroup() ) {
+                       throw new MWException( 'Supersets can only be defined for filters in the same group' );
+               }
+
+               $this->subsetFilters[] = [
+                       // It's always the same group, but this makes the representation
+                       // more consistent with conflicts.
+                       'group' => $other->getGroup()->getName(),
+                       'filter' => $other->getName(),
+               ];
+       }
+
+       /**
+        * @return string Name, e.g. hideanons
+        */
+       public function getName() {
+               return $this->name;
+       }
+
+       /**
+        * @return ChangesListFilterGroup Group this belongs to
+        */
+       public function getGroup() {
+               return $this->group;
+       }
+
+       /**
+        * @return string i18n key of label for structured UI
+        */
+       public function getLabel() {
+               return $this->label;
+       }
+
+       /**
+        * @return string i18n key of description for structured UI
+        */
+       public function getDescription() {
+               return $this->description;
+       }
+
+       /**
+        * Checks whether the filter should display on the unstructured UI
+        *
+        * @param ChangesListSpecialPage $specialPage Current special page
+        * @return bool Whether to display
+        */
+       abstract public function displaysOnUnstructuredUi( ChangesListSpecialPage $specialPage );
+
+       /**
+        * Checks whether the filter should display on the structured UI
+        * This refers to the exact filter.  See also isFeatureAvailableOnStructuredUi.
+        *
+        * @param ChangesListSpecialPage $specialPage Current special page
+        * @return bool Whether to display
+        */
+       public function displaysOnStructuredUi( ChangesListSpecialPage $specialPage ) {
+               return $this->label !== null && $this->isAllowed( $specialPage );
+       }
+
+       /**
+        * Checks whether an equivalent feature for this filter is available on the
+        * structured UI.
+        *
+        * This can either be the exact filter, or a new filter that replaces it.
+        */
+       public function isFeatureAvailableOnStructuredUi( ChangesListSpecialPage $specialPage ) {
+               return $this->displaysOnStructuredUi( $specialPage );
+       }
+
+       /**
+        * @return int Priority.  Higher value means higher up in the group list
+        */
+       public function getPriority() {
+               return $this->priority;
+       }
+
+       /**
+        * Checks whether the filter is allowed for the current context
+        *
+        * @param ChangesListSpecialPage $specialPage Current special page
+        * @return bool Whether it is allowed
+        */
+       public function isAllowed( ChangesListSpecialPage $specialPage ) {
+               if ( $this->isAllowedCallable === null ) {
+                       return true;
+               } else {
+                       return call_user_func(
+                               $this->isAllowedCallable,
+                               get_class( $specialPage ),
+                               $specialPage->getContext()
+                       );
+               }
+       }
+
+       /**
+        * Gets the CSS class
+        *
+        * @return string|null CSS class, or null if not defined
+        */
+       protected function getCssClass() {
+               if ( $this->cssClassSuffix !== null ) {
+                       return ChangesList::CSS_CLASS_PREFIX . $this->cssClassSuffix;
+               } else {
+                       return null;
+               }
+       }
+
+       /**
+        * Add CSS class if needed
+        *
+        * @param IContextSource $ctx Context source
+        * @param RecentChange $rc Recent changes object
+        * @param Non-associative array of CSS class names; appended to if needed
+        */
+       public function applyCssClassIfNeeded( IContextSource $ctx, RecentChange $rc, array &$classes ) {
+               if ( $this->isRowApplicableCallable === null ) {
+                       return;
+               }
+
+               if ( call_user_func( $this->isRowApplicableCallable, $ctx, $rc ) ) {
+                       $classes[] = $this->getCssClass();
+               }
+       }
+
+       /**
+        * Gets the JS data required by the front-end of the structured UI
+        *
+        * @return array Associative array Data required by the front-end.  messageKeys is
+        *  a special top-level value, with the value being an array of the message keys to
+        *  send to the client.
+        */
+       public function getJsData() {
+               $output = [
+                       'name' => $this->getName(),
+                       'label' => $this->getLabel(),
+                       'description' => $this->getDescription(),
+                       'cssClass' => $this->getCssClass(),
+                       'priority' => $this->priority,
+                       'subset' => $this->subsetFilters,
+                       'conflicts' => [],
+               ];
+
+               $output['messageKeys'] = [
+                       $this->getLabel(),
+                       $this->getDescription(),
+               ];
+
+               $conflicts = array_merge(
+                       $this->conflictingGroups,
+                       $this->conflictingFilters
+               );
+
+               foreach ( $conflicts as $conflictInfo ) {
+                       $output['conflicts'][] = $conflictInfo;
+                       array_push(
+                               $output['messageKeys'],
+                               $conflictInfo['globalDescription'],
+                               $conflictInfo['contextDescription']
+                       );
+               }
+
+               return $output;
+       }
+}
diff --git a/includes/changes/ChangesListFilterGroup.php b/includes/changes/ChangesListFilterGroup.php
new file mode 100644 (file)
index 0000000..a4cc287
--- /dev/null
@@ -0,0 +1,394 @@
+<?php
+/**
+ * Represents a filter group (used on ChangesListSpecialPage and descendants)
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Matthew Flaschen
+ */
+
+// TODO: Might want to make a super-class or trait to share behavior (especially re
+// conflicts) between ChangesListFilter and ChangesListFilterGroup.
+// What to call it.  FilterStructure?  That would also let me make
+// setUnidirectionalConflict protected.
+
+/**
+ * Represents a filter group (used on ChangesListSpecialPage and descendants)
+ *
+ * @since 1.29
+ */
+abstract class ChangesListFilterGroup {
+       /**
+        * Name (internal identifier)
+        *
+        * @var string $name
+        */
+       protected $name;
+
+       /**
+        * i18n key for title
+        *
+        * @var string $title
+        */
+       protected $title;
+
+       /**
+        * i18n key for header of What's This?
+        *
+        * @var string|null $whatsThisHeader
+        */
+       protected $whatsThisHeader;
+
+       /**
+        * i18n key for body of What's This?
+        *
+        * @var string|null $whatsThisBody
+        */
+       protected $whatsThisBody;
+
+       /**
+        * URL of What's This? link
+        *
+        * @var string|null $whatsThisUrl
+        */
+       protected $whatsThisUrl;
+
+       /**
+        * i18n key for What's This? link
+        *
+        * @var string|null $whatsThisLinkText
+        */
+       protected $whatsThisLinkText;
+
+       /**
+        * Type, from a TYPE constant of a subclass
+        *
+        * @var string $type
+        */
+       protected $type;
+
+       /**
+        * Priority integer.  Higher values means higher up in the
+        * group list.
+        *
+        * @var string $priority
+        */
+       protected $priority;
+
+       /**
+        * Associative array of filters, as ChangesListFilter objects, with filter name as key
+        *
+        * @var array $filters
+        */
+       protected $filters;
+
+       /**
+        * Whether this group is full coverage.  This means that checking every item in the
+        * group means no changes list (e.g. RecentChanges) entries are filtered out.
+        *
+        * @var bool $isFullCoverage
+        */
+       protected $isFullCoverage;
+
+       /**
+        * List of conflicting groups
+        *
+        * @var array $conflictingGroups Array of associative arrays with conflict
+        *   information.  See setUnidirectionalConflict
+        */
+       protected $conflictingGroups = [];
+
+       /**
+        * List of conflicting filters
+        *
+        * @var array $conflictingFilters Array of associative arrays with conflict
+        *   information.  See setUnidirectionalConflict
+        */
+       protected $conflictingFilters = [];
+
+       const DEFAULT_PRIORITY = -100;
+
+       /**
+        * Create a new filter group with the specified configuration
+        *
+        * @param array $groupDefinition Configuration of group
+        * * $groupDefinition['name'] string Group name
+        * * $groupDefinition['title'] string i18n key for title (optional, can be omitted
+        * *  only if none of the filters in the group display in the structured UI)
+        * * $groupDefinition['type'] string A type constant from a subclass of this one
+        * * $groupDefinition['priority'] int Priority integer.  Higher value means higher
+        * *  up in the group list (optional, defaults to -100).
+        * * $groupDefinition['filters'] array Numeric array of filter definitions, each of which
+        * *  is an associative array to be passed to the filter constructor.  However,
+        * *  'priority' is optional for the filters.  Any filter that has priority unset
+        * *  will be put to the bottom, in the order given.
+        * * $groupDefinition['isFullCoverage'] bool Whether the group is full coverage;
+        * *  if true, this means that checking every item in the group means no
+        * *  changes list entries are filtered out.
+        */
+       public function __construct( array $groupDefinition ) {
+               $this->name = $groupDefinition['name'];
+
+               if ( isset( $groupDefinition['title'] ) ) {
+                       $this->title = $groupDefinition['title'];
+               }
+
+               if ( isset ( $groupDefinition['whatsThisHeader'] ) ) {
+                       $this->whatsThisHeader = $groupDefinition['whatsThisHeader'];
+                       $this->whatsThisBody = $groupDefinition['whatsThisBody'];
+                       $this->whatsThisUrl = $groupDefinition['whatsThisUrl'];
+                       $this->whatsThisLinkText = $groupDefinition['whatsThisLinkText'];
+               }
+
+               $this->type = $groupDefinition['type'];
+               if ( isset( $groupDefinition['priority'] ) ) {
+                       $this->priority = $groupDefinition['priority'];
+               } else {
+                       $this->priority = self::DEFAULT_PRIORITY;
+               }
+
+               $this->isFullCoverage = $groupDefinition['isFullCoverage'];
+
+               $this->filters = [];
+               $lowestSpecifiedPriority = -1;
+               foreach ( $groupDefinition['filters'] as $filterDefinition ) {
+                       if ( isset( $filterDefinition['priority'] ) ) {
+                               $lowestSpecifiedPriority = min( $lowestSpecifiedPriority, $filterDefinition['priority'] );
+                       }
+               }
+
+               // Convenience feature: If you specify a group (and its filters) all in
+               // one place, you don't have to specify priority.  You can just put them
+               // in order.  However, if you later add one (e.g. an extension adds a filter
+               // to a core-defined group), you need to specify it.
+               $autoFillPriority = $lowestSpecifiedPriority - 1;
+               foreach ( $groupDefinition['filters'] as $filterDefinition ) {
+                       if ( !isset( $filterDefinition['priority'] ) ) {
+                               $filterDefinition['priority'] = $autoFillPriority;
+                               $autoFillPriority--;
+                       }
+                       $filterDefinition['group'] = $this;
+
+                       $filter = $this->createFilter( $filterDefinition );
+                       $this->registerFilter( $filter );
+               }
+       }
+
+       /**
+        * Creates a filter of the appropriate type for this group, from the definition
+        *
+        * @param array $filterDefinition Filter definition
+        * @return ChangesListFilter Filter
+        */
+       abstract protected function createFilter( array $filterDefinition );
+
+       /**
+        * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with this object.
+        *
+        * WARNING: This means there is a conflict when both things are *shown*
+        * (not filtered out), even for the hide-based filters.  So e.g. conflicting with
+        * 'hideanons' means there is a conflict if only anonymous users are *shown*.
+        *
+        * @param ChangesListFilterGroup|ChangesListFilter $other Other
+        *  ChangesListFilterGroup or ChangesListFilter
+        * @param string $globalKey i18n key for top-level conflict message
+        * @param string $forwardKey i18n key for conflict message in this
+        *  direction (when in UI context of $this object)
+        * @param string $backwardKey i18n key for conflict message in reverse
+        *  direction (when in UI context of $other object)
+        */
+       public function conflictsWith( $other, $globalKey, $forwardKey,
+               $backwardKey ) {
+
+               if ( $globalKey === null || $forwardKey === null ||
+                       $backwardKey === null ) {
+
+                       throw new MWException( 'All messages must be specified' );
+               }
+
+               $this->setUnidirectionalConflict(
+                       $other,
+                       $globalKey,
+                       $forwardKey
+               );
+
+               $other->setUnidirectionalConflict(
+                       $this,
+                       $globalKey,
+                       $backwardKey
+               );
+       }
+
+       /**
+        * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with
+        * this object.
+        *
+        * Internal use ONLY.
+        *
+        * @param ChangesListFilterGroup|ChangesListFilter $other Other
+        *  ChangesListFilterGroup or ChangesListFilter
+        * @param string $globalDescription i18n key for top-level conflict message
+        * @param string $contextDescription i18n key for conflict message in this
+        *  direction (when in UI context of $this object)
+        */
+       public function setUnidirectionalConflict( $other, $globalDescription,
+               $contextDescription ) {
+
+               if ( $other instanceof ChangesListFilterGroup ) {
+                       $this->conflictingGroups[] = [
+                               'group' => $other->getName(),
+                               'globalDescription' => $globalDescription,
+                               'contextDescription' => $contextDescription,
+                       ];
+               } elseif ( $other instanceof ChangesListFilter ) {
+                       $this->conflictingFilters[] = [
+                               'group' => $other->getGroup()->getName(),
+                               'filter' => $other->getName(),
+                               'globalDescription' => $globalDescription,
+                               'contextDescription' => $contextDescription,
+                       ];
+               } else {
+                       throw new MWException( 'You can only pass in a ChangesListFilterGroup or a ChangesListFilter' );
+               }
+       }
+
+       /**
+        * @return string Internal name
+        */
+       public function getName() {
+               return $this->name;
+       }
+
+       /**
+        * @return string i18n key for title
+        */
+       public function getTitle() {
+               return $this->title;
+       }
+
+       /**
+        * @return string Type (TYPE constant from a subclass)
+        */
+       public function getType() {
+               return $this->type;
+       }
+
+       /**
+        * @return int Priority.  Higher means higher in the group list
+        */
+       public function getPriority() {
+               return $this->priority;
+       }
+
+       /**
+        * @return array Associative array of ChangesListFilter objects, with filter name as key
+        */
+       public function getFilters() {
+               return $this->filters;
+       }
+
+       /**
+        * Get filter by name
+        *
+        * @param string $name Filter name
+        * @return ChangesListFilter Specified filter
+        */
+       public function getFilter( $name ) {
+               return $this->filters[$name];
+       }
+
+       /**
+        * Check whether the URL parameter is for the group, or for individual filters.
+        * Defaults can also be defined on the group if and only if this is true.
+        *
+        * @return bool True if and only if the URL parameter is per-group
+        */
+       abstract public function isPerGroupRequestParameter();
+
+       /**
+        * Gets the JS data in the format required by the front-end of the structured UI
+        *
+        * @param ChangesListSpecialPage $specialPage
+        * @return array|null Associative array, or null if there are no filters that
+        *  display in the structured UI.  messageKeys is a special top-level value, with
+        *  the value being an array of the message keys to send to the client.
+        */
+       public function getJsData( ChangesListSpecialPage $specialPage ) {
+               $output = [
+                       'name' => $this->name,
+                       'type' => $this->type,
+                       'fullCoverage' => $this->isFullCoverage,
+                       'filters' => [],
+                       'priority' => $this->priority,
+                       'conflicts' => [],
+                       'messageKeys' => [ $this->title ]
+               ];
+
+               if ( isset ( $this->whatsThisHeader ) ) {
+                       $output['whatsThisHeader'] = $this->whatsThisHeader;
+                       $output['whatsThisBody'] = $this->whatsThisBody;
+                       $output['whatsThisUrl'] = $this->whatsThisUrl;
+                       $output['whatsThisLinkText'] = $this->whatsThisLinkText;
+
+                       array_push(
+                               $output['messageKeys'],
+                               $output['whatsThisHeader'],
+                               $output['whatsThisBody'],
+                               $output['whatsThisLinkText']
+                       );
+               }
+
+               usort( $this->filters, function ( $a, $b ) {
+                       return $b->getPriority() - $a->getPriority();
+               } );
+
+               foreach ( $this->filters as $filterName => $filter ) {
+                       if ( $filter->displaysOnStructuredUi( $specialPage ) ) {
+                               $filterData = $filter->getJsData();
+                               $output['messageKeys'] = array_merge(
+                                       $output['messageKeys'],
+                                       $filterData['messageKeys']
+                               );
+                               unset( $filterData['messageKeys'] );
+                               $output['filters'][] = $filterData;
+                       }
+               }
+
+               if ( count( $output['filters'] ) === 0 ) {
+                       return null;
+               }
+
+               $output['title'] = $this->title;
+
+               $conflicts = array_merge(
+                       $this->conflictingGroups,
+                       $this->conflictingFilters
+               );
+
+               foreach ( $conflicts as $conflictInfo ) {
+                       $output['conflicts'][] = $conflictInfo;
+                       array_push(
+                               $output['messageKeys'],
+                               $conflictInfo['globalDescription'],
+                               $conflictInfo['contextDescription']
+                       );
+               }
+
+               return $output;
+       }
+}
diff --git a/includes/changes/ChangesListStringOptionsFilter.php b/includes/changes/ChangesListStringOptionsFilter.php
new file mode 100644 (file)
index 0000000..b6a8774
--- /dev/null
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * An individual filter in a ChangesListStringOptionsFilterGroup.
+ *
+ * This filter type will only be displayed on the structured UI currently.
+ *
+ * @since 1.29
+ */
+class ChangesListStringOptionsFilter extends ChangesListFilter {
+       /**
+        * @inheritdoc
+        */
+       public function displaysOnUnstructuredUi( ChangesListSpecialPage $specialPage ) {
+               return false;
+       }
+}
diff --git a/includes/changes/ChangesListStringOptionsFilterGroup.php b/includes/changes/ChangesListStringOptionsFilterGroup.php
new file mode 100644 (file)
index 0000000..befc213
--- /dev/null
@@ -0,0 +1,243 @@
+<?php
+/**
+ * Represents a filter group (used on ChangesListSpecialPage and descendants)
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Matthew Flaschen
+ */
+
+/**
+ * Represents a filter group with multiple string options. They are passed to the server as
+ * a single form parameter separated by a delimiter.  The parameter name is the
+ * group name.  E.g. groupname=opt1;opt2 .
+ *
+ * If all options are selected they are replaced by the term "all".
+ *
+ * There is also a single DB query modification for the whole group.
+ *
+ * @since 1.29
+ */
+
+class ChangesListStringOptionsFilterGroup extends ChangesListFilterGroup {
+       /**
+        * Type marker, used by JavaScript
+        */
+       const TYPE = 'string_options';
+
+       /**
+        * Delimiter
+        */
+       const SEPARATOR = ';';
+
+       /**
+        * Signifies that all options in the group are selected.
+        */
+       const ALL = 'all';
+
+       /**
+        * Signifies that no options in the group are selected, meaning the group has no effect.
+        *
+        * For full-coverage groups, this is the same as ALL if all filters are allowed.
+        * For others, it is not.
+        */
+       const NONE = '';
+
+       /**
+        * Group name; used as form parameter.
+        *
+        * @var string $name
+        */
+
+       /**
+        * Defaul parameter value
+        *
+        * @var string $defaultValue
+        */
+       protected $defaultValue;
+
+       /**
+        * Callable used to do the actual query modification; see constructor
+        *
+        * @var callable $queryCallable
+        */
+       protected $queryCallable;
+
+       /**
+        * Create a new filter group with the specified configuration
+        *
+        * @param array $groupDefinition Configuration of group
+        * * $groupDefinition['name'] string Group name
+        * * $groupDefinition['title'] string i18n key for title (optional, can be omitted
+        * *  only if none of the filters in the group display in the structured UI)
+        * * $groupDefinition['priority'] int Priority integer.  Higher means higher in the
+        * *  group list.
+        * * $groupDefinition['filters'] array Numeric array of filter definitions, each of which
+        * *  is an associative array to be passed to the filter constructor.  However,
+        * *  'priority' is optional for the filters.  Any filter that has priority unset
+        * *  will be put to the bottom, in the order given.
+        * * $groupDefinition['default'] string Default for group.
+        * * $groupDefinition['isFullCoverage'] bool Whether the group is full coverage;
+        * *  if true, this means that checking every item in the group means no
+        * *  changes list entries are filtered out.
+        * * $groupDefinition['queryCallable'] callable Callable accepting parameters:
+        * *  string $specialPageClassName Class name of current special page
+        * *  IContextSource $context Context, for e.g. user
+        * *  IDatabase $dbr Database, for addQuotes, makeList, and similar
+        * *  array &$tables Array of tables; see IDatabase::select $table
+        * *  array &$fields Array of fields; see IDatabase::select $vars
+        * *  array &$conds Array of conditions; see IDatabase::select $conds
+        * *  array &$query_options Array of query options; see IDatabase::select $options
+        * *  array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+        * *  array $selectedValues The allowed and requested values, lower-cased and sorted
+        */
+       public function __construct( array $groupDefinition ) {
+               if ( !isset( $groupDefinition['isFullCoverage'] ) ) {
+                       throw new MWException( 'You must specify isFullCoverage' );
+               }
+
+               $groupDefinition['type'] = self::TYPE;
+
+               parent::__construct( $groupDefinition );
+
+               $this->queryCallable = $groupDefinition['queryCallable'];
+
+               if ( isset( $groupDefinition['default'] ) ) {
+                       $this->setDefault( $groupDefinition['default'] );
+               } else {
+                       throw new MWException( 'You must specify a default' );
+               }
+       }
+
+       /**
+        * @inheritdoc
+        */
+       public function isPerGroupRequestParameter() {
+               return true;
+       }
+
+       /**
+        * Sets default of filter group.
+        *
+        * @param string $defaultValue
+        */
+       public function setDefault( $defaultValue ) {
+               $this->defaultValue = $defaultValue;
+       }
+
+       /**
+        * Gets default of filter group
+        *
+        * @return string $defaultValue
+        */
+       public function getDefault() {
+               return $this->defaultValue;
+       }
+
+       /**
+        * @inheritdoc
+        */
+       protected function createFilter( array $filterDefinition ) {
+               return new ChangesListStringOptionsFilter( $filterDefinition );
+       }
+
+       /**
+        * Registers a filter in this group
+        *
+        * @param ChangesListStringOptionsFilter $filter ChangesListStringOptionsFilter
+        */
+       public function registerFilter( ChangesListStringOptionsFilter $filter ) {
+               $this->filters[$filter->getName()] = $filter;
+       }
+
+       /**
+        * Modifies the query to include the filter group.
+        *
+        * The modification is only done if the filter group is in effect.  This means that
+        * one or more valid and allowed filters were selected.
+        *
+        * @param IDatabase $dbr Database, for addQuotes, makeList, and similar
+        * @param ChangesListSpecialPage $specialPage Current special page
+        * @param array &$tables Array of tables; see IDatabase::select $table
+        * @param array &$fields Array of fields; see IDatabase::select $vars
+        * @param array &$conds Array of conditions; see IDatabase::select $conds
+        * @param array &$query_options Array of query options; see IDatabase::select $options
+        * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+        * @param string $value URL parameter value
+        */
+       public function modifyQuery( IDatabase $dbr, ChangesListSpecialPage $specialPage,
+               &$tables, &$fields, &$conds, &$query_options, &$join_conds, $value ) {
+
+               $allowedFilterNames = [];
+               foreach ( $this->filters as $filter ) {
+                       if ( $filter->isAllowed( $specialPage ) ) {
+                               $allowedFilterNames[] = $filter->getName();
+                       }
+               }
+
+               if ( $value === self::ALL ) {
+                       $selectedValues = $allowedFilterNames;
+               } else {
+                       $selectedValues = explode( self::SEPARATOR, strtolower( $value ) );
+
+                       // remove values that are not recognized or not currently allowed
+                       $selectedValues = array_intersect(
+                               $selectedValues,
+                               $allowedFilterNames
+                       );
+               }
+
+               // If there are now no values, because all are disallowed or invalid (also,
+               // the user may not have selected any), this is a no-op.
+
+               // If everything is unchecked, the group always has no effect, regardless
+               // of full-coverage.
+               if ( count( $selectedValues ) === 0 ) {
+                       return;
+               }
+
+               sort( $selectedValues );
+
+               call_user_func_array(
+                       $this->queryCallable,
+                       [
+                               get_class( $specialPage ),
+                               $specialPage->getContext(),
+                               $dbr,
+                               &$tables,
+                               &$fields,
+                               &$conds,
+                               &$query_options,
+                               &$join_conds,
+                               $selectedValues
+                       ]
+               );
+       }
+
+       /**
+        * @inheritdoc
+        */
+       public function getJsData( ChangesListSpecialPage $specialPage ) {
+               $output = parent::getJsData( $specialPage );
+
+               $output['separator'] = self::SEPARATOR;
+               $output['default'] = $this->getDefault();
+
+               return $output;
+       }
+}
index 3aad60e..b8a2ac8 100644 (file)
@@ -34,9 +34,10 @@ class EnhancedChangesList extends ChangesList {
 
        /**
         * @param IContextSource|Skin $obj
+        * @param array $filterGroups Array of ChangesListFilterGroup objects (currently optional)
         * @throws MWException
         */
-       public function __construct( $obj ) {
+       public function __construct( $obj, array $filterGroups = [] ) {
                if ( $obj instanceof Skin ) {
                        // @todo: deprecate constructing with Skin
                        $context = $obj->getContext();
@@ -49,7 +50,7 @@ class EnhancedChangesList extends ChangesList {
                        $context = $obj;
                }
 
-               parent::__construct( $context );
+               parent::__construct( $context, $filterGroups );
 
                // message is set by the parent ChangesList class
                $this->cacheEntryFactory = new RCCacheEntryFactory(
index f62b302..e92f697 100644 (file)
@@ -39,6 +39,384 @@ abstract class ChangesListSpecialPage extends SpecialPage {
        /** @var array */
        protected $customFilters;
 
+       // Order of both groups and filters is significant; first is top-most priority,
+       // descending from there.
+       // 'showHideSuffix' is a shortcut to and avoid spelling out
+       // details specific to subclasses here.
+       /**
+        * Definition information for the filters and their groups
+        *
+        * The value is $groupDefinition, a parameter to the ChangesListFilterGroup constructor.
+        * However, priority is dynamically added for the core groups, to ease maintenance.
+        *
+        * Groups are displayed to the user in the structured UI.  However, if necessary,
+        * all of the filters in a group can be configured to only display on the
+        * unstuctured UI, in which case you don't need a group title.  This is done in
+        * getFilterGroupDefinitionFromLegacyCustomFilters, for example.
+        *
+        * @var array $filterGroupDefinitions
+        */
+       private $filterGroupDefinitions;
+
+       /**
+        * Filter groups, and their contained filters
+        * This is an associative array (with group name as key) of ChangesListFilterGroup objects.
+        *
+        * @var array $filterGroups
+        */
+       protected $filterGroups = [];
+
+       public function __construct( $name, $restriction ) {
+               parent::__construct( $name, $restriction );
+
+               $this->filterGroupDefinitions = [
+                       [
+                               'name' => 'registration',
+                               'title' => 'rcfilters-filtergroup-registration',
+                               'class' => ChangesListBooleanFilterGroup::class,
+                               'filters' => [
+                                       [
+                                               'name' => 'hideliu',
+                                               'label' => 'rcfilters-filter-registered-label',
+                                               'description' => 'rcfilters-filter-registered-description',
+                                               // rcshowhideliu-show, rcshowhideliu-hide,
+                                               // wlshowhideliu
+                                               'showHideSuffix' => 'showhideliu',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_user = 0';
+                                               },
+                                               'cssClassSuffix' => 'liu',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $rc->getAttribute( 'rc_user' );
+                                               },
+
+                                       ],
+                                       [
+                                               'name' => 'hideanons',
+                                               'label' => 'rcfilters-filter-unregistered-label',
+                                               'description' => 'rcfilters-filter-unregistered-description',
+                                               // rcshowhideanons-show, rcshowhideanons-hide,
+                                               // wlshowhideanons
+                                               'showHideSuffix' => 'showhideanons',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_user != 0';
+                                               },
+                                               'cssClassSuffix' => 'anon',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return !$rc->getAttribute( 'rc_user' );
+                                               },
+                                       ]
+                               ],
+                       ],
+
+                       [
+                               'name' => 'userExpLevel',
+                               'title' => 'rcfilters-filtergroup-userExpLevel',
+                               'class' => ChangesListStringOptionsFilterGroup::class,
+                               // Excludes unregistered users
+                               'isFullCoverage' => false,
+                               'filters' => [
+                                       [
+                                               'name' => 'newcomer',
+                                               'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
+                                               'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
+                                               'cssClassSuffix' => 'user-newcomer',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       $performer = $rc->getPerformer();
+                                                       return $performer && $performer->isLoggedIn() &&
+                                                               $performer->getExperienceLevel() === 'newcomer';
+                                               }
+                                       ],
+                                       [
+                                               'name' => 'learner',
+                                               'label' => 'rcfilters-filter-user-experience-level-learner-label',
+                                               'description' => 'rcfilters-filter-user-experience-level-learner-description',
+                                               'cssClassSuffix' => 'user-learner',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       $performer = $rc->getPerformer();
+                                                       return $performer && $performer->isLoggedIn() &&
+                                                               $performer->getExperienceLevel() === 'learner';
+                                               },
+                                       ],
+                                       [
+                                               'name' => 'experienced',
+                                               'label' => 'rcfilters-filter-user-experience-level-experienced-label',
+                                               'description' => 'rcfilters-filter-user-experience-level-experienced-description',
+                                               'cssClassSuffix' => 'user-experienced',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       $performer = $rc->getPerformer();
+                                                       return $performer && $performer->isLoggedIn() &&
+                                                               $performer->getExperienceLevel() === 'experienced';
+                                               },
+                                       ]
+                               ],
+                               'default' => ChangesListStringOptionsFilterGroup::NONE,
+                               'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ],
+                       ],
+
+                       [
+                               'name' => 'authorship',
+                               'title' => 'rcfilters-filtergroup-authorship',
+                               'class' => ChangesListBooleanFilterGroup::class,
+                               'filters' => [
+                                       [
+                                               'name' => 'hidemyself',
+                                               'label' => 'rcfilters-filter-editsbyself-label',
+                                               'description' => 'rcfilters-filter-editsbyself-description',
+                                               // rcshowhidemine-show, rcshowhidemine-hide,
+                                               // wlshowhidemine
+                                               'showHideSuffix' => 'showhidemine',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $user = $ctx->getUser();
+                                                       if ( $user->getId() ) {
+                                                               $conds[] = 'rc_user != ' . $dbr->addQuotes( $user->getId() );
+                                                       } else {
+                                                               $conds[] = 'rc_user_text != ' . $dbr->addQuotes( $user->getName() );
+                                                       }
+                                               },
+                                               'cssClassSuffix' => 'self',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $ctx->getUser()->equals( $rc->getPerformer() );
+                                               },
+                                       ],
+                                       [
+                                               'name' => 'hidebyothers',
+                                               'label' => 'rcfilters-filter-editsbyother-label',
+                                               'description' => 'rcfilters-filter-editsbyother-description',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $user = $ctx->getUser();
+                                                       if ( $user->getId() ) {
+                                                               $conds[] = 'rc_user = ' . $dbr->addQuotes( $user->getId() );
+                                                       } else {
+                                                               $conds[] = 'rc_user_text = ' . $dbr->addQuotes( $user->getName() );
+                                                       }
+                                               },
+                                               'cssClassSuffix' => 'others',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return !$ctx->getUser()->equals( $rc->getPerformer() );
+                                               },
+                                       ]
+                               ]
+                       ],
+
+                       [
+                               'name' => 'automated',
+                               'title' => 'rcfilters-filtergroup-automated',
+                               'class' => ChangesListBooleanFilterGroup::class,
+                               'filters' => [
+                                       [
+                                               'name' => 'hidebots',
+                                               'label' => 'rcfilters-filter-bots-label',
+                                               'description' => 'rcfilters-filter-bots-description',
+                                               // rcshowhidebots-show, rcshowhidebots-hide,
+                                               // wlshowhidebots
+                                               'showHideSuffix' => 'showhidebots',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_bot = 0';
+                                               },
+                                               'cssClassSuffix' => 'bot',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $rc->getAttribute( 'rc_bot' );
+                                               },
+                                       ],
+                                       [
+                                               'name' => 'hidehumans',
+                                               'label' => 'rcfilters-filter-humans-label',
+                                               'description' => 'rcfilters-filter-humans-description',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_bot = 1';
+                                               },
+                                               'cssClassSuffix' => 'human',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return !$rc->getAttribute( 'rc_bot' );
+                                               },
+                                       ]
+                               ]
+                       ],
+
+                       [
+                               'name' => 'reviewStatus',
+                               'title' => 'rcfilters-filtergroup-reviewstatus',
+                               'class' => ChangesListBooleanFilterGroup::class,
+                               'filters' => [
+                                       [
+                                               'name' => 'hidepatrolled',
+                                               'label' => 'rcfilters-filter-patrolled-label',
+                                               'description' => 'rcfilters-filter-patrolled-description',
+                                               // rcshowhidepatr-show, rcshowhidepatr-hide
+                                               // wlshowhidepatr
+                                               'showHideSuffix' => 'showhidepatr',
+                                               'default' => false,
+                                               'isAllowedCallable' => function ( $pageClassName, $context ) {
+                                                       return $context->getUser()->useRCPatrol();
+                                               },
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_patrolled = 0';
+                                               },
+                                               'cssClassSuffix' => 'patrolled',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $rc->getAttribute( 'rc_patrolled' );
+                                               },
+                                       ],
+                                       [
+                                               'name' => 'hideunpatrolled',
+                                               'label' => 'rcfilters-filter-unpatrolled-label',
+                                               'description' => 'rcfilters-filter-unpatrolled-description',
+                                               'default' => false,
+                                               'isAllowedCallable' => function ( $pageClassName, $context ) {
+                                                       return $context->getUser()->useRCPatrol();
+                                               },
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_patrolled = 1';
+                                               },
+                                               'cssClassSuffix' => 'unpatrolled',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return !$rc->getAttribute( 'rc_patrolled' );
+                                               },
+                                       ],
+                               ],
+                       ],
+
+                       [
+                               'name' => 'significance',
+                               'title' => 'rcfilters-filtergroup-significance',
+                               'class' => ChangesListBooleanFilterGroup::class,
+                               'filters' => [
+                                       [
+                                               'name' => 'hideminor',
+                                               'label' => 'rcfilters-filter-minor-label',
+                                               'description' => 'rcfilters-filter-minor-description',
+                                               // rcshowhideminor-show, rcshowhideminor-hide,
+                                               // wlshowhideminor
+                                               'showHideSuffix' => 'showhideminor',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_minor = 0';
+                                               },
+                                               'cssClassSuffix' => 'minor',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $rc->getAttribute( 'rc_minor' );
+                                               }
+                                       ],
+                                       [
+                                               'name' => 'hidemajor',
+                                               'label' => 'rcfilters-filter-major-label',
+                                               'description' => 'rcfilters-filter-major-description',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_minor = 1';
+                                               },
+                                               'cssClassSuffix' => 'major',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return !$rc->getAttribute( 'rc_minor' );
+                                               }
+                                       ]
+                               ]
+                       ],
+
+                       // With extensions, there can be change types that will not be hidden by any of these.
+                       [
+                               'name' => 'changeType',
+                               'title' => 'rcfilters-filtergroup-changetype',
+                               'class' => ChangesListBooleanFilterGroup::class,
+                               'filters' => [
+                                       [
+                                               'name' => 'hidepageedits',
+                                               'label' => 'rcfilters-filter-pageedits-label',
+                                               'description' => 'rcfilters-filter-pageedits-description',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
+                                               },
+                                               'cssClassSuffix' => 'src-mw-edit',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT;
+                                               },
+                                       ],
+                                       [
+                                               'name' => 'hidenewpages',
+                                               'label' => 'rcfilters-filter-newpages-label',
+                                               'description' => 'rcfilters-filter-newpages-description',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
+                                               },
+                                               'cssClassSuffix' => 'src-mw-new',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW;
+                                               },
+                                       ],
+                                       [
+                                               'name' => 'hidecategorization',
+                                               'label' => 'rcfilters-filter-categorization-label',
+                                               'description' => 'rcfilters-filter-categorization-description',
+                                               // rcshowhidecategorization-show, rcshowhidecategorization-hide.
+                                               // wlshowhidecategorization
+                                               'showHideSuffix' => 'showhidecategorization',
+                                               'isAllowedCallable' => function ( $pageClassName, $context ) {
+                                                       return $context->getConfig()->get( 'RCWatchCategoryMembership' );
+                                               },
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
+                                               },
+                                               'cssClassSuffix' => 'src-mw-categorize',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE;
+                                               },
+                                       ],
+                                       [
+                                               'name' => 'hidelog',
+                                               'label' => 'rcfilters-filter-logactions-label',
+                                               'description' => 'rcfilters-filter-logactions-description',
+                                               'default' => false,
+                                               'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+                                                       &$query_options, &$join_conds ) {
+
+                                                       $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
+                                               },
+                                               'cssClassSuffix' => 'src-mw-log',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG;
+                                               }
+                                       ],
+                               ],
+                       ],
+               ];
+       }
+
        /**
         * Main execution point
         *
@@ -96,9 +474,15 @@ abstract class ChangesListSpecialPage extends SpecialPage {
         */
        public function getRows() {
                $opts = $this->getOptions();
-               $conds = $this->buildMainQueryConds( $opts );
 
-               return $this->doMainQuery( $conds, $opts );
+               $tables = [];
+               $fields = [];
+               $conds = [];
+               $query_options = [];
+               $join_conds = [];
+               $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
+
+               return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
        }
 
        /**
@@ -115,17 +499,99 @@ abstract class ChangesListSpecialPage extends SpecialPage {
        }
 
        /**
-        * Create a FormOptions object with options as specified by the user
+        * Register all filters and their groups, plus conflicts
+        *
+        * You might want to customize these in the same method, in subclasses.  You can
+        * call getFilterGroup to access a group, and (on the group) getFilter to access a
+        * filter, then make necessary modfications to the filter or group (e.g. with
+        * setDefault).
+        */
+       protected function registerFilters() {
+               $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions );
+
+               Hooks::run( 'ChangesListSpecialPageStructuredFilters', [ $this ] );
+
+               $unstructuredGroupDefinition =
+                       $this->getFilterGroupDefinitionFromLegacyCustomFilters(
+                               $this->getCustomFilters()
+                       );
+               $this->registerFiltersFromDefinitions( [ $unstructuredGroupDefinition ] );
+
+               $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
+
+               $registration = $this->getFilterGroup( 'registration' );
+               $anons = $registration->getFilter( 'hideanons' );
+
+               // This means there is a conflict between any item in user experience level
+               // being checked and only anons being *shown* (hideliu=1&hideanons=0 in the
+               // URL, or equivalent).
+               $userExperienceLevel->conflictsWith(
+                       $anons,
+                       'rcfilters-filtergroup-user-experience-level-conflicts-unregistered-global',
+                       'rcfilters-filtergroup-user-experience-level-conflicts-unregistered',
+                       'rcfilters-filter-unregistered-conflicts-user-experience-level'
+               );
+       }
+
+       /**
+        * Register filters from a definition object
+        *
+        * Array specifying groups and their filters; see Filter and
+        * ChangesListFilterGroup constructors.
+        *
+        * There is light processing to simplify core maintenance.  See overrides
+        * of this method as well.
+        */
+       protected function registerFiltersFromDefinitions( array $definition ) {
+               $priority = -1;
+               foreach ( $definition as $groupDefinition ) {
+                       $groupDefinition['priority'] = $priority;
+                       $priority--;
+
+                       $className = $groupDefinition['class'];
+                       unset( $groupDefinition['class'] );
+                       $this->registerFilterGroup( new $className( $groupDefinition ) );
+               }
+       }
+
+       /**
+        * Get filter group definition from legacy custom filters
+        *
+        * @param array Custom filters from legacy hooks
+        * @return array Group definition
+        */
+       protected function getFilterGroupDefinitionFromLegacyCustomFilters( $customFilters ) {
+               // Special internal unstructured group
+               $unstructuredGroupDefinition = [
+                       'name' => 'unstructured',
+                       'class' => ChangesListBooleanFilterGroup::class,
+                       'priority' => -1, // Won't display in structured
+                       'filters' => [],
+               ];
+
+               foreach ( $customFilters as $name => $params ) {
+                       $unstructuredGroupDefinition['filters'][] = [
+                               'name' => $name,
+                               'showHide' => $params['msg'],
+                               'default' => $params['default'],
+                       ];
+               }
+
+               return $unstructuredGroupDefinition;
+       }
+
+       /**
+        * Register all the filters, including legacy hook-driven ones.
+        * Then create a FormOptions object with options as specified by the user
         *
         * @param array $parameters
         *
         * @return FormOptions
         */
        public function setup( $parameters ) {
+               $this->registerFilters();
+
                $opts = $this->getDefaultOptions();
-               foreach ( $this->getCustomFilters() as $key => $params ) {
-                       $opts->add( $key, $params['default'] );
-               }
 
                $opts = $this->fetchOptionsFromRequest( $opts );
 
@@ -140,8 +606,11 @@ abstract class ChangesListSpecialPage extends SpecialPage {
        }
 
        /**
-        * Get a FormOptions object containing the default options. By default returns some basic options,
-        * you might want to not call parent method and discard them, or to override default values.
+        * Get a FormOptions object containing the default options. By default, returns
+        * some basic options.  The filters listed explicitly here are overriden in this
+        * method, in subclasses, but most filters (e.g. hideminor, userExpLevel filters,
+        * and more) are structured.  Structured filters are overriden in registerFilters.
+        * not here.
         *
         * @return FormOptions
         */
@@ -149,23 +618,18 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                $config = $this->getConfig();
                $opts = new FormOptions();
 
-               $opts->add( 'hideminor', false );
-               $opts->add( 'hidemajor', false );
-               $opts->add( 'hidebots', false );
-               $opts->add( 'hidehumans', false );
-               $opts->add( 'hideanons', false );
-               $opts->add( 'hideliu', false );
-               $opts->add( 'hidepatrolled', false );
-               $opts->add( 'hideunpatrolled', false );
-               $opts->add( 'hidemyself', false );
-               $opts->add( 'hidebyothers', false );
-
-               if ( $config->get( 'RCWatchCategoryMembership' ) ) {
-                       $opts->add( 'hidecategorization', false );
+               // Add all filters
+               foreach ( $this->filterGroups as $filterGroup ) {
+                       // URL parameters can be per-group, like 'userExpLevel',
+                       // or per-filter, like 'hideminor'.
+                       if ( $filterGroup->isPerGroupRequestParameter() ) {
+                               $opts->add( $filterGroup->getName(), $filterGroup->getDefault() );
+                       } else {
+                               foreach ( $filterGroup->getFilters() as $filter ) {
+                                       $opts->add( $filter->getName(), $filter->getDefault() );
+                               }
+                       }
                }
-               $opts->add( 'hidepageedits', false );
-               $opts->add( 'hidenewpages', false );
-               $opts->add( 'hidelog', false );
 
                $opts->add( 'namespace', '', FormOptions::INTNULL );
                $opts->add( 'invert', false );
@@ -175,14 +639,86 @@ abstract class ChangesListSpecialPage extends SpecialPage {
        }
 
        /**
-        * Get custom show/hide filters
+        * Register a structured changes list filter group
+        *
+        * @param ChangesListFilterGroup $group
+        */
+       public function registerFilterGroup( ChangesListFilterGroup $group ) {
+               $groupName = $group->getName();
+
+               $this->filterGroups[$groupName] = $group;
+       }
+
+       /**
+        * Gets the currently registered filters groups
+        *
+        * @return array Associative array of ChangesListFilterGroup objects, with group name as key
+        */
+       protected function getFilterGroups() {
+               return $this->filterGroups;
+       }
+
+       /**
+        * Gets a specified ChangesListFilterGroup by name
+        *
+        * @param string $groupName Name of group
+        *
+        * @return ChangesListFilterGroup
+        */
+       public function getFilterGroup( $groupName ) {
+               return $this->filterGroups[$groupName];
+       }
+
+       // Currently, this intentionally only includes filters that display
+       // in the structured UI.  This can be changed easily, though, if we want
+       // to include data on filters that use the unstructured UI.  messageKeys is a
+       // special top-level value, with the value being an array of the message keys to
+       // send to the client.
+       /**
+        * Gets structured filter information needed by JS
+        *
+        * @return array Associative array
+        * * array $return['groups'] Group data
+        * * array $return['messageKeys'] Array of message keys
+        */
+       public function getStructuredFilterJsData() {
+               $output = [
+                       'groups' => [],
+                       'messageKeys' => [],
+               ];
+
+               $context = $this->getContext();
+
+               usort( $this->filterGroups, function ( $a, $b ) {
+                       return $b->getPriority() - $a->getPriority();
+               } );
+
+               foreach ( $this->filterGroups as $groupName => $group ) {
+                       $groupOutput = $group->getJsData( $this );
+                       if ( $groupOutput !== null ) {
+                               $output['messageKeys'] = array_merge(
+                                       $output['messageKeys'],
+                                       $groupOutput['messageKeys']
+                               );
+
+                               unset( $groupOutput['messageKeys'] );
+                               $output['groups'][] = $groupOutput;
+                       }
+               }
+
+               return $output;
+       }
+
+       /**
+        * Get custom show/hide filters using deprecated ChangesListSpecialPageFilters
+        * hook.
         *
         * @return array Map of filter URL param names to properties (msg/default)
         */
        protected function getCustomFilters() {
                if ( $this->customFilters === null ) {
                        $this->customFilters = [];
-                       Hooks::run( 'ChangesListSpecialPageFilters', [ $this, &$this->customFilters ] );
+                       Hooks::run( 'ChangesListSpecialPageFilters', [ $this, &$this->customFilters ], '1.29' );
                }
 
                return $this->customFilters;
@@ -209,7 +745,37 @@ abstract class ChangesListSpecialPage extends SpecialPage {
         * @param FormOptions $opts
         */
        public function parseParameters( $par, FormOptions $opts ) {
-               // nothing by default
+               $stringParameterNameSet = [];
+               $hideParameterNameSet = [];
+
+               // URL parameters can be per-group, like 'userExpLevel',
+               // or per-filter, like 'hideminor'.
+
+               foreach ( $this->filterGroups as $filterGroup ) {
+                       if ( $filterGroup->isPerGroupRequestParameter() ) {
+                               $stringParameterNameSet[$filterGroup->getName()] = true;
+                       } elseif ( $filterGroup->getType() === ChangesListBooleanFilterGroup::TYPE ) {
+                               foreach ( $filterGroup->getFilters() as $filter ) {
+                                       $hideParameterNameSet[$filter->getName()] = true;
+                               }
+                       }
+               }
+
+               $bits = preg_split( '/\s*,\s*/', trim( $par ) );
+               foreach ( $bits as $bit ) {
+                       $m = [];
+                       if ( isset( $hideParameterNameSet[$bit] ) ) {
+                               // hidefoo => hidefoo=true
+                               $opts[$bit] = true;
+                       } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
+                               // foo => hidefoo=false
+                               $opts["hide$bit"] = false;
+                       } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
+                               if ( isset( $stringParameterNameSet[$m[1]] ) ) {
+                                       $opts[$m[1]] = $m[2];
+                               }
+                       }
+               }
        }
 
        /**
@@ -222,90 +788,39 @@ abstract class ChangesListSpecialPage extends SpecialPage {
        }
 
        /**
-        * Return an array of conditions depending of options set in $opts
+        * Sets appropriate tables, fields, conditions, etc. depending on which filters
+        * the user requested.
         *
+        * @param array &$tables Array of tables; see IDatabase::select $table
+        * @param array &$fields Array of fields; see IDatabase::select $vars
+        * @param array &$conds Array of conditions; see IDatabase::select $conds
+        * @param array &$query_options Array of query options; see IDatabase::select $options
+        * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
         * @param FormOptions $opts
-        * @return array
         */
-       public function buildMainQueryConds( FormOptions $opts ) {
+       protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
+               &$join_conds, FormOptions $opts ) {
+
                $dbr = $this->getDB();
                $user = $this->getUser();
-               $conds = [];
-
-               // It makes no sense to hide both anons and logged-in users. When this occurs, try a guess on
-               // what the user meant and either show only bots or force anons to be shown.
-               $botsonly = false;
-               $hideanons = $opts['hideanons'];
-               if ( $opts['hideanons'] && $opts['hideliu'] ) {
-                       if ( $opts['hidebots'] ) {
-                               $hideanons = false;
-                       } else {
-                               $botsonly = true;
-                       }
-               }
 
-               // Toggles
-               if ( $opts['hideminor'] ) {
-                       $conds[] = 'rc_minor = 0';
-               }
-               if ( $opts['hidemajor'] ) {
-                       $conds[] = 'rc_minor = 1';
-               }
-               if ( $opts['hidebots'] ) {
-                       $conds['rc_bot'] = 0;
-               }
-               if ( $opts['hidehumans'] ) {
-                       $conds[] = 'rc_bot = 1';
-               }
-               if ( $user->useRCPatrol() ) {
-                       if ( $opts['hidepatrolled'] ) {
-                               $conds[] = 'rc_patrolled = 0';
-                       }
-                       if ( $opts['hideunpatrolled'] ) {
-                               $conds[] = 'rc_patrolled = 1';
-                       }
-               }
-               if ( $botsonly ) {
-                       $conds['rc_bot'] = 1;
-               } else {
-                       if ( $opts['hideliu'] ) {
-                               $conds[] = 'rc_user = 0';
-                       }
-                       if ( $hideanons ) {
-                               $conds[] = 'rc_user != 0';
-                       }
-               }
-
-               if ( $opts['hidemyself'] ) {
-                       if ( $user->getId() ) {
-                               $conds[] = 'rc_user != ' . $dbr->addQuotes( $user->getId() );
-                       } else {
-                               $conds[] = 'rc_user_text != ' . $dbr->addQuotes( $user->getName() );
-                       }
-               }
-               if ( $opts['hidebyothers'] ) {
-                       if ( $user->getId() ) {
-                               $conds[] = 'rc_user = ' . $dbr->addQuotes( $user->getId() );
+               $context = $this->getContext();
+               foreach ( $this->filterGroups as $filterGroup ) {
+                       // URL parameters can be per-group, like 'userExpLevel',
+                       // or per-filter, like 'hideminor'.
+                       if ( $filterGroup->isPerGroupRequestParameter() ) {
+                               $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
+                                       $query_options, $join_conds, $opts[$filterGroup->getName()] );
                        } else {
-                               $conds[] = 'rc_user_text = ' . $dbr->addQuotes( $user->getName() );
+                               foreach ( $filterGroup->getFilters() as $filter ) {
+                                       if ( $opts[$filter->getName()] && $filter->isAllowed( $this ) ) {
+                                               $filter->modifyQuery( $dbr, $this, $tables, $fields, $conds,
+                                                       $query_options, $join_conds );
+                                       }
+                               }
                        }
                }
 
-               if ( $this->getConfig()->get( 'RCWatchCategoryMembership' )
-                       && $opts['hidecategorization'] === true
-               ) {
-                       $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
-               }
-               if ( $opts['hidepageedits'] ) {
-                       $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
-               }
-               if ( $opts['hidenewpages'] ) {
-                       $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
-               }
-               if ( $opts['hidelog'] ) {
-                       $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
-               }
-
                // Namespace filtering
                if ( $opts['namespace'] !== '' ) {
                        $selectedNS = $dbr->addQuotes( $opts['namespace'] );
@@ -327,22 +842,24 @@ abstract class ChangesListSpecialPage extends SpecialPage {
 
                        $conds[] = $condition;
                }
-
-               return $conds;
        }
 
        /**
         * Process the query
         *
-        * @param array $conds
+        * @param array $tables Array of tables; see IDatabase::select $table
+        * @param array $fields Array of fields; see IDatabase::select $vars
+        * @param array $conds Array of conditions; see IDatabase::select $conds
+        * @param array $query_options Array of query options; see IDatabase::select $options
+        * @param array $join_conds Array of join conditions; see IDatabase::select $join_conds
         * @param FormOptions $opts
         * @return bool|ResultWrapper Result or false
         */
-       public function doMainQuery( $conds, $opts ) {
-               $tables = [ 'recentchanges' ];
-               $fields = RecentChange::selectFields();
-               $query_options = [];
-               $join_conds = [];
+       protected function doMainQuery( $tables, $fields, $conds,
+               $query_options, $join_conds, FormOptions $opts ) {
+
+               $tables[] = 'recentchanges';
+               $fields = array_merge( RecentChange::selectFields(), $fields );
 
                ChangeTags::modifyDisplayQuery(
                        $tables,
@@ -353,6 +870,15 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                        ''
                );
 
+               // It makes no sense to hide both anons and logged-in users. When this occurs, try a guess on
+               // what the user meant and either show only bots or force anons to be shown.
+
+               // -------
+
+               // XXX: We're no longer doing this handling.  To preserve back-compat, we need to complete
+               // T151873 (particularly the hideanons/hideliu/hidebots/hidehumans part) in conjunction
+               // with merging this.
+
                if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
                        $opts )
                ) {
@@ -456,7 +982,8 @@ abstract class ChangesListSpecialPage extends SpecialPage {
        /**
         * Get options to be displayed in a form
         * @todo This should handle options returned by getDefaultOptions().
-        * @todo Not called by anything, should be called by something… doHeader() maybe?
+        * @todo Not called by anything in this class (but is in subclasses), should be
+        * called by something… doHeader() maybe?
         *
         * @param FormOptions $opts
         * @return array
@@ -533,21 +1060,78 @@ abstract class ChangesListSpecialPage extends SpecialPage {
        }
 
        /**
-        * Get filters that can be rendered.
-        *
-        * Filters with 'msg' => false can be used to filter data but won't
-        * be presented as show/hide toggles in the UI. They are not returned
-        * by this function.
+        * Filter on users' experience levels; this will not be called if nothing is
+        * selected.
         *
-        * @param array $allFilters Map of filter URL param names to properties (msg/default)
-        * @return array Map of filter URL param names to properties (msg/default)
+        * @param string $specialPageClassName Class name of current special page
+        * @param IContextSource $context Context, for e.g. user
+        * @param IDatabase $dbr Database, for addQuotes, makeList, and similar
+        * @param array &$tables Array of tables; see IDatabase::select $table
+        * @param array &$fields Array of fields; see IDatabase::select $vars
+        * @param array &$conds Array of conditions; see IDatabase::select $conds
+        * @param array &$query_options Array of query options; see IDatabase::select $options
+        * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+        * @param array $selectedExpLevels The allowed active values, sorted
         */
-       protected function getRenderableCustomFilters( $allFilters ) {
-               return array_filter(
-                       $allFilters,
-                       function( $filter ) {
-                               return isset( $filter['msg'] ) && ( $filter['msg'] !== false );
-                       }
+       public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
+               &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels ) {
+
+               global $wgLearnerEdits,
+                          $wgExperiencedUserEdits,
+                          $wgLearnerMemberSince,
+                          $wgExperiencedUserMemberSince;
+
+               $LEVEL_COUNT = 3;
+
+               // If all levels are selected, all logged-in users are included (but no
+               // anons), so we can short-circuit.
+               if ( count( $selectedExpLevels ) === $LEVEL_COUNT ) {
+                       $conds[] = 'rc_user != 0';
+                       return;
+               }
+
+               $tables[] = 'user';
+               $join_conds['user'] = [ 'LEFT JOIN', 'rc_user = user_id' ];
+
+               $now = time();
+               $secondsPerDay = 86400;
+               $learnerCutoff = $now - $wgLearnerMemberSince * $secondsPerDay;
+               $experiencedUserCutoff = $now - $wgExperiencedUserMemberSince * $secondsPerDay;
+
+               $aboveNewcomer = $dbr->makeList(
+                       [
+                               'user_editcount >= ' . intval( $wgLearnerEdits ),
+                               'user_registration <= ' . $dbr->timestamp( $learnerCutoff ),
+                       ],
+                       IDatabase::LIST_AND
                );
+
+               $aboveLearner = $dbr->makeList(
+                       [
+                               'user_editcount >= ' . intval( $wgExperiencedUserEdits ),
+                               'user_registration <= ' . $dbr->timestamp( $experiencedUserCutoff ),
+                       ],
+                       IDatabase::LIST_AND
+               );
+
+               if ( $selectedExpLevels === [ 'newcomer' ] ) {
+                       $conds[] =  "NOT ( $aboveNewcomer )";
+               } elseif ( $selectedExpLevels === [ 'learner' ] ) {
+                       $conds[] = $dbr->makeList(
+                               [ $aboveNewcomer, "NOT ( $aboveLearner )" ],
+                               IDatabase::LIST_AND
+                       );
+               } elseif ( $selectedExpLevels === [ 'experienced' ] ) {
+                       $conds[] = $aboveLearner;
+               } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) {
+                       $conds[] = "NOT ( $aboveLearner )";
+               } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) {
+                       $conds[] = $dbr->makeList(
+                               [ "NOT ( $aboveNewcomer )", $aboveLearner ],
+                               IDatabase::LIST_OR
+                       );
+               } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) {
+                       $conds[] = $aboveNewcomer;
+               }
        }
 }
index eb29907..29e8900 100644 (file)
@@ -53,7 +53,8 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                }
 
                // 10 seconds server-side caching max
-               $this->getOutput()->setCdnMaxage( 10 );
+               $out = $this->getOutput();
+               $out->setCdnMaxage( 10 );
                // Check if the client has a cached version
                $lastmod = $this->checkLastModified();
                if ( $lastmod === false ) {
@@ -65,6 +66,65 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                        true
                );
                parent::execute( $subpage );
+
+               if ( $this->isStructuredFilterUiEnabled() ) {
+                       $jsData = $this->getStructuredFilterJsData();
+
+                       $messages = [];
+                       foreach ( $jsData['messageKeys'] as $key ){
+                               $messages[$key] = $this->msg( $key )->plain();
+                       }
+
+                       $out->addHTML(
+                               ResourceLoader::makeInlineScript(
+                                       Xml::encodeJsCall( 'mw.messages.set', [
+                                               $messages
+                                       ] )
+                               )
+                       );
+
+                       $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
+               }
+       }
+
+       /**
+        * @inheritdoc
+        */
+       protected function registerFiltersFromDefinitions( array $definition ) {
+               foreach ( $definition as $groupName => &$groupDefinition ) {
+                       foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
+                               if ( isset( $filterDefinition['showHideSuffix'] ) ) {
+                                       $filterDefinition['showHide'] = 'rc' . $filterDefinition['showHideSuffix'];
+                               }
+                       }
+               }
+
+               parent::registerFiltersFromDefinitions( $definition );
+       }
+
+       /**
+        * @inheritdoc
+        */
+       protected function registerFilters() {
+               parent::registerFilters();
+
+               $user = $this->getUser();
+
+               $significance = $this->getFilterGroup( 'significance' );
+               $hideMinor = $significance->getFilter( 'hideminor' );
+               $hideMinor->setDefault( $user->getBoolOption( 'hideminor' ) );
+
+               $automated = $this->getFilterGroup( 'automated' );
+               $hideBots = $automated->getFilter( 'hidebots' );
+               $hideBots->setDefault( true );
+
+               $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
+               $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' );
+               $hidePatrolled->setDefault( $user->getBoolOption( 'hidepatrolled' ) );
+
+               $changeType = $this->getFilterGroup( 'changeType' );
+               $hideCategorization = $changeType->getFilter( 'hidecategorization' );
+               $hideCategorization->setDefault( $user->getBoolOption( 'hidecategorization' ) );
        }
 
        /**
@@ -80,20 +140,10 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                $opts->add( 'limit', $user->getIntOption( 'rclimit' ) );
                $opts->add( 'from', '' );
 
-               $opts->add( 'hideminor', $user->getBoolOption( 'hideminor' ) );
-               $opts->add( 'hidebots', true );
-               $opts->add( 'hideanons', false );
-               $opts->add( 'hideliu', false );
-               $opts->add( 'hidepatrolled', $user->getBoolOption( 'hidepatrolled' ) );
-               $opts->add( 'hidemyself', false );
-               $opts->add( 'hidecategorization', $user->getBoolOption( 'hidecategorization' ) );
-
                $opts->add( 'categories', '' );
                $opts->add( 'categories_any', false );
                $opts->add( 'tagfilter', '' );
 
-               $opts->add( 'userExpLevel', 'all' );
-
                return $opts;
        }
 
@@ -118,36 +168,10 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
         * @param FormOptions $opts
         */
        public function parseParameters( $par, FormOptions $opts ) {
+               parent::parseParameters( $par, $opts );
+
                $bits = preg_split( '/\s*,\s*/', trim( $par ) );
                foreach ( $bits as $bit ) {
-                       if ( 'hidebots' === $bit ) {
-                               $opts['hidebots'] = true;
-                       }
-                       if ( 'bots' === $bit ) {
-                               $opts['hidebots'] = false;
-                       }
-                       if ( 'hideminor' === $bit ) {
-                               $opts['hideminor'] = true;
-                       }
-                       if ( 'minor' === $bit ) {
-                               $opts['hideminor'] = false;
-                       }
-                       if ( 'hideliu' === $bit ) {
-                               $opts['hideliu'] = true;
-                       }
-                       if ( 'hidepatrolled' === $bit ) {
-                               $opts['hidepatrolled'] = true;
-                       }
-                       if ( 'hideanons' === $bit ) {
-                               $opts['hideanons'] = true;
-                       }
-                       if ( 'hidemyself' === $bit ) {
-                               $opts['hidemyself'] = true;
-                       }
-                       if ( 'hidecategorization' === $bit ) {
-                               $opts['hidecategorization'] = true;
-                       }
-
                        if ( is_numeric( $bit ) ) {
                                $opts['limit'] = $bit;
                        }
@@ -174,14 +198,14 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
        }
 
        /**
-        * Return an array of conditions depending of options set in $opts
-        *
-        * @param FormOptions $opts
-        * @return array
+        * @inheritdoc
         */
-       public function buildMainQueryConds( FormOptions $opts ) {
+       protected function buildQuery( &$tables, &$fields, &$conds,
+               &$query_options, &$join_conds, FormOptions $opts ) {
+
                $dbr = $this->getDB();
-               $conds = parent::buildMainQueryConds( $opts );
+               parent::buildQuery( $tables, $fields, $conds,
+                       $query_options, $join_conds, $opts );
 
                // Calculate cutoff
                $cutoff_unixtime = time() - ( $opts['days'] * 86400 );
@@ -196,25 +220,19 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                }
 
                $conds[] = 'rc_timestamp >= ' . $dbr->addQuotes( $cutoff );
-
-               return $conds;
        }
 
        /**
-        * Process the query
-        *
-        * @param array $conds
-        * @param FormOptions $opts
-        * @return bool|ResultWrapper Result or false (for Recentchangeslinked only)
+        * @inheritdoc
         */
-       public function doMainQuery( $conds, $opts ) {
+       protected function doMainQuery( $tables, $fields, $conds, $query_options,
+               $join_conds, FormOptions $opts ) {
+
                $dbr = $this->getDB();
                $user = $this->getUser();
 
-               $tables = [ 'recentchanges' ];
-               $fields = RecentChange::selectFields();
-               $query_options = [];
-               $join_conds = [];
+               $tables[] = 'recentchanges';
+               $fields = array_merge( RecentChange::selectFields(), $fields );
 
                // JOIN on watchlist for users
                if ( $user->getId() && $user->isAllowed( 'viewmywatchlist' ) ) {
@@ -243,8 +261,6 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                        $opts['tagfilter']
                );
 
-               $this->filterOnUserExperienceLevel( $tables, $conds, $join_conds, $opts );
-
                if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
                        $opts )
                ) {
@@ -331,7 +347,7 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                $dbr = $this->getDB();
 
                $counter = 1;
-               $list = ChangesList::newFromContext( $this->getContext() );
+               $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
                $list->initChangesListRows( $rows );
 
                $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' );
@@ -537,6 +553,17 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                return $extraOpts;
        }
 
+       /**
+        * Check whether the structured filter UI is enabled
+        *
+        * @return bool
+        */
+       protected function isStructuredFilterUiEnabled() {
+               return $this->getUser()->getOption(
+                       'rcenhancedfilters'
+               );
+       }
+
        /**
         * Add page-specific modules.
         */
@@ -544,7 +571,7 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                parent::addModules();
                $out = $this->getOutput();
                $out->addModules( 'mediawiki.special.recentchanges' );
-               if ( $this->getUser()->getOption( 'rcenhancedfilters' ) ) {
+               if ( $this->isStructuredFilterUiEnabled() ) {
                        $out->addModules( 'mediawiki.rcfilters.filters.ui' );
                        $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
                }
@@ -760,49 +787,45 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                }
                $dl = $lang->pipeList( $dl );
 
-               // show/hide links
-               $filters = [
-                       'hideminor' => 'rcshowhideminor',
-                       'hidebots' => 'rcshowhidebots',
-                       'hideanons' => 'rcshowhideanons',
-                       'hideliu' => 'rcshowhideliu',
-                       'hidepatrolled' => 'rcshowhidepatr',
-                       'hidemyself' => 'rcshowhidemine'
-               ];
-
-               if ( $config->get( 'RCWatchCategoryMembership' ) ) {
-                       $filters['hidecategorization'] = 'rcshowhidecategorization';
-               }
-
                $showhide = [ 'show', 'hide' ];
 
-               foreach ( $this->getRenderableCustomFilters( $this->getCustomFilters() ) as $key => $params ) {
-                       $filters[$key] = $params['msg'];
-               }
-
-               // Disable some if needed
-               if ( !$user->useRCPatrol() ) {
-                       unset( $filters['hidepatrolled'] );
-               }
-
                $links = [];
-               foreach ( $filters as $key => $msg ) {
-                       // The following messages are used here:
-                       // rcshowhideminor-show, rcshowhideminor-hide, rcshowhidebots-show, rcshowhidebots-hide,
-                       // rcshowhideanons-show, rcshowhideanons-hide, rcshowhideliu-show, rcshowhideliu-hide,
-                       // rcshowhidepatr-show, rcshowhidepatr-hide, rcshowhidemine-show, rcshowhidemine-hide,
-                       // rcshowhidecategorization-show, rcshowhidecategorization-hide.
-                       $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] );
-                       // Extensions can define additional filters, but don't need to define the corresponding
-                       // messages. If they don't exist, just fall back to 'show' and 'hide'.
-                       if ( !$linkMessage->exists() ) {
-                               $linkMessage = $this->msg( $showhide[1 - $options[$key]] );
-                       }
 
-                       $link = $this->makeOptionsLink( $linkMessage->text(),
-                               [ $key => 1 - $options[$key] ], $nondefaults );
-                       $links[] = "<span class=\"$msg rcshowhideoption\">"
-                               . $this->msg( $msg )->rawParams( $link )->escaped() . '</span>';
+               $filterGroups = $this->getFilterGroups();
+
+               $context = $this->getContext();
+               foreach ( $filterGroups as $groupName => $group ) {
+                       if ( !$group->isPerGroupRequestParameter() ) {
+                               foreach ( $group->getFilters() as $key => $filter ) {
+                                       if ( $filter->displaysOnUnstructuredUi( $this ) ) {
+                                               $msg = $filter->getShowHide();
+                                               $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] );
+                                               // Extensions can define additional filters, but don't need to define the corresponding
+                                               // messages. If they don't exist, just fall back to 'show' and 'hide'.
+                                               if ( !$linkMessage->exists() ) {
+                                                       $linkMessage = $this->msg( $showhide[1 - $options[$key]] );
+                                               }
+
+                                               $link = $this->makeOptionsLink( $linkMessage->text(),
+                                                       [ $key => 1 - $options[$key] ], $nondefaults );
+
+                                               $attribs = [
+                                                       'class' => "$msg rcshowhideoption",
+                                                       'data-filter-name' => $filter->getName(),
+                                               ];
+
+                                               if ( $filter->isFeatureAvailableOnStructuredUi( $this ) ) {
+                                                       $attribs['data-feature-in-structured-ui'] = true;
+                                               }
+
+                                               $links[] = Html::rawElement(
+                                                       'span',
+                                                       $attribs,
+                                                       $this->msg( $msg )->rawParams( $link )->escaped()
+                                               );
+                                       }
+                               }
+                       }
                }
 
                // show from this onward link
@@ -831,66 +854,4 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
        protected function getCacheTTL() {
                return 60 * 5;
        }
-
-       function filterOnUserExperienceLevel( &$tables, &$conds, &$join_conds, $opts ) {
-               global $wgLearnerEdits,
-                       $wgExperiencedUserEdits,
-                       $wgLearnerMemberSince,
-                       $wgExperiencedUserMemberSince;
-
-               $selectedExpLevels = explode( ',', strtolower( $opts['userExpLevel'] ) );
-               // remove values that are not recognized
-               $selectedExpLevels = array_intersect(
-                       $selectedExpLevels,
-                       [ 'newcomer', 'learner', 'experienced' ]
-               );
-               sort( $selectedExpLevels );
-
-               if ( $selectedExpLevels ) {
-                       $tables[] = 'user';
-                       $join_conds['user'] = [ 'LEFT JOIN', 'rc_user = user_id' ];
-
-                       $now = time();
-                       $secondsPerDay = 86400;
-                       $learnerCutoff = $now - $wgLearnerMemberSince * $secondsPerDay;
-                       $experiencedUserCutoff = $now - $wgExperiencedUserMemberSince * $secondsPerDay;
-
-                       $aboveNewcomer = $this->getDB()->makeList(
-                               [
-                                       'user_editcount >= ' . intval( $wgLearnerEdits ),
-                                       'user_registration <= ' . $this->getDB()->timestamp( $learnerCutoff ),
-                               ],
-                               IDatabase::LIST_AND
-                       );
-
-                       $aboveLearner = $this->getDB()->makeList(
-                               [
-                                       'user_editcount >= ' . intval( $wgExperiencedUserEdits ),
-                                       'user_registration <= ' . $this->getDB()->timestamp( $experiencedUserCutoff ),
-                               ],
-                               IDatabase::LIST_AND
-                       );
-
-                       if ( $selectedExpLevels === [ 'newcomer' ] ) {
-                               $conds[] =  "NOT ( $aboveNewcomer )";
-                       } elseif ( $selectedExpLevels === [ 'learner' ] ) {
-                               $conds[] = $this->getDB()->makeList(
-                                       [ $aboveNewcomer, "NOT ( $aboveLearner )" ],
-                                       IDatabase::LIST_AND
-                               );
-                       } elseif ( $selectedExpLevels === [ 'experienced' ] ) {
-                               $conds[] = $aboveLearner;
-                       } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) {
-                               $conds[] = "NOT ( $aboveLearner )";
-                       } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) {
-                               $conds[] = $this->getDB()->makeList(
-                                       [ "NOT ( $aboveNewcomer )", $aboveLearner ],
-                                       IDatabase::LIST_OR
-                               );
-                       } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) {
-                               $conds[] = $aboveNewcomer;
-                       }
-               }
-       }
-
 }
index aab0f6d..873285b 100644 (file)
@@ -46,7 +46,12 @@ class SpecialRecentChangesLinked extends SpecialRecentChanges {
                $opts['target'] = $par;
        }
 
-       public function doMainQuery( $conds, $opts ) {
+       /**
+        * @inheritdoc
+        */
+       protected function doMainQuery( $tables, $select, $conds, $query_options,
+               $join_conds, FormOptions $opts ) {
+
                $target = $opts['target'];
                $showlinkedto = $opts['showlinkedto'];
                $limit = $opts['limit'];
@@ -79,10 +84,8 @@ class SpecialRecentChangesLinked extends SpecialRecentChanges {
                $ns = $title->getNamespace();
                $dbkey = $title->getDBkey();
 
-               $tables = [ 'recentchanges' ];
-               $select = RecentChange::selectFields();
-               $join_conds = [];
-               $query_options = [];
+               $tables[] = 'recentchanges';
+               $select = array_merge( RecentChange::selectFields(), $select );
 
                // left join with watchlist table to highlight watched rows
                $uid = $this->getUser()->getId();
index 822648b..5d7fa5d 100644 (file)
@@ -104,6 +104,56 @@ class SpecialWatchlist extends ChangesListSpecialPage {
                ];
        }
 
+       /**
+        * @inheritdoc
+        */
+       protected function registerFiltersFromDefinitions( array $definition ) {
+               foreach ( $definition as $groupName => &$groupDefinition ) {
+                       foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
+                               if ( isset( $filterDefinition['showHideSuffix'] ) ) {
+                                       $filterDefinition['showHide'] = 'wl' . $filterDefinition['showHideSuffix'];
+                               }
+                       }
+               }
+
+               parent::registerFiltersFromDefinitions( $definition );
+       }
+
+       /**
+        * @inheritdoc
+        */
+       protected function registerFilters() {
+               parent::registerFilters();
+
+               $user = $this->getUser();
+
+               $significance = $this->getFilterGroup( 'significance' );
+               $hideMinor = $significance->getFilter( 'hideminor' );
+               $hideMinor->setDefault( $user->getBoolOption( 'watchlisthideminor' ) );
+
+               $automated = $this->getFilterGroup( 'automated' );
+               $hideBots = $automated->getFilter( 'hidebots' );
+               $hideBots->setDefault( $user->getBoolOption( 'watchlisthidebots' ) );
+
+               $registration = $this->getFilterGroup( 'registration' );
+               $hideAnons = $registration->getFilter( 'hideanons' );
+               $hideAnons->setDefault( $user->getBoolOption( 'watchlisthideanons' ) );
+               $hideLiu = $registration->getFilter( 'hideliu' );
+               $hideLiu->setDefault( $user->getBoolOption( 'watchlisthideliu' ) );
+
+               $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
+               $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' );
+               $hidePatrolled->setDefault( $user->getBoolOption( 'watchlisthidepatrolled' ) );
+
+               $authorship = $this->getFilterGroup( 'authorship' );
+               $hideMyself = $authorship->getFilter( 'hidemyself' );
+               $hideMyself->setDefault( $user->getBoolOption( 'watchlisthideown' ) );
+
+               $changeType = $this->getFilterGroup( 'changeType' );
+               $hideCategorization = $changeType->getFilter( 'hidecategorization' );
+               $hideCategorization->setDefault( $user->getBoolOption( 'watchlisthidecategorization' ) );
+       }
+
        /**
         * Get a FormOptions object containing the default options
         *
@@ -120,14 +170,6 @@ class SpecialWatchlist extends ChangesListSpecialPage {
                        return $opts;
                }
 
-               $opts->add( 'hideminor', $user->getBoolOption( 'watchlisthideminor' ) );
-               $opts->add( 'hidebots', $user->getBoolOption( 'watchlisthidebots' ) );
-               $opts->add( 'hideanons', $user->getBoolOption( 'watchlisthideanons' ) );
-               $opts->add( 'hideliu', $user->getBoolOption( 'watchlisthideliu' ) );
-               $opts->add( 'hidepatrolled', $user->getBoolOption( 'watchlisthidepatrolled' ) );
-               $opts->add( 'hidemyself', $user->getBoolOption( 'watchlisthideown' ) );
-               $opts->add( 'hidecategorization', $user->getBoolOption( 'watchlisthidecategorization' ) );
-
                return $opts;
        }
 
@@ -181,32 +223,28 @@ class SpecialWatchlist extends ChangesListSpecialPage {
        }
 
        /**
-        * Return an array of conditions depending of options set in $opts
-        *
-        * @param FormOptions $opts
-        * @return array
+        * @inheritdoc
         */
-       public function buildMainQueryConds( FormOptions $opts ) {
+       protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
+               &$join_conds, FormOptions $opts ) {
+
                $dbr = $this->getDB();
-               $conds = parent::buildMainQueryConds( $opts );
+               parent::buildQuery( $tables, $fields, $conds, $query_options, $join_conds,
+                       $opts );
 
                // Calculate cutoff
                if ( $opts['days'] > 0 ) {
                        $conds[] = 'rc_timestamp > ' .
                                $dbr->addQuotes( $dbr->timestamp( time() - intval( $opts['days'] * 86400 ) ) );
                }
-
-               return $conds;
        }
 
        /**
-        * Process the query
-        *
-        * @param array $conds
-        * @param FormOptions $opts
-        * @return bool|ResultWrapper Result or false (for Recentchangeslinked only)
+        * @inheritdoc
         */
-       public function doMainQuery( $conds, $opts ) {
+       protected function doMainQuery( $tables, $fields, $conds, $query_options,
+               $join_conds, FormOptions $opts ) {
+
                $dbr = $this->getDB();
                $user = $this->getUser();
 
@@ -231,19 +269,23 @@ class SpecialWatchlist extends ChangesListSpecialPage {
                        $usePage = true;
                }
 
-               $tables = [ 'recentchanges', 'watchlist' ];
-               $fields = RecentChange::selectFields();
-               $query_options = [ 'ORDER BY' => 'rc_timestamp DESC' ];
-               $join_conds = [
-                       'watchlist' => [
-                               'INNER JOIN',
-                               [
-                                       'wl_user' => $user->getId(),
-                                       'wl_namespace=rc_namespace',
-                                       'wl_title=rc_title'
+               $tables = array_merge( [ 'recentchanges', 'watchlist' ], $tables );
+               $fields = array_merge( RecentChange::selectFields(), $fields );
+
+               $query_options = array_merge( [ 'ORDER BY' => 'rc_timestamp DESC' ], $query_options );
+               $join_conds = array_merge(
+                       [
+                               'watchlist' => [
+                                       'INNER JOIN',
+                                       [
+                                               'wl_user' => $user->getId(),
+                                               'wl_namespace=rc_namespace',
+                                               'wl_title=rc_title'
+                                       ],
                                ],
                        ],
-               ];
+                       $join_conds
+               );
 
                if ( $this->getConfig()->get( 'ShowUpdatedMarker' ) ) {
                        $fields[] = 'wl_notificationtimestamp';
@@ -361,7 +403,7 @@ class SpecialWatchlist extends ChangesListSpecialPage {
 
                $dbr->dataSeek( $rows, 0 );
 
-               $list = ChangesList::newFromContext( $this->getContext() );
+               $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
                $list->setWatchlistDivs();
                $list->initChangesListRows( $rows );
                $dbr->dataSeek( $rows, 0 );
@@ -448,31 +490,23 @@ class SpecialWatchlist extends ChangesListSpecialPage {
                $cutofflinks = $this->msg( 'wlshowtime' ) . ' ' . $this->cutoffselector( $opts );
 
                # Spit out some control panel links
-               $filters = [
-                       'hideminor' => 'wlshowhideminor',
-                       'hidebots' => 'wlshowhidebots',
-                       'hideanons' => 'wlshowhideanons',
-                       'hideliu' => 'wlshowhideliu',
-                       'hidemyself' => 'wlshowhidemine',
-                       'hidepatrolled' => 'wlshowhidepatr'
-               ];
-
-               if ( $this->getConfig()->get( 'RCWatchCategoryMembership' ) ) {
-                       $filters['hidecategorization'] = 'wlshowhidecategorization';
-               }
-
-               foreach ( $this->getRenderableCustomFilters( $this->getCustomFilters() ) as $key => $params ) {
-                       $filters[$key] = $params['msg'];
-               }
-
-               // Disable some if needed
-               if ( !$user->useRCPatrol() ) {
-                       unset( $filters['hidepatrolled'] );
-               }
-
                $links = [];
-               foreach ( $filters as $name => $msg ) {
-                       $links[] = $this->showHideCheck( $nondefaults, $msg, $name, $opts[$name] );
+               $context = $this->getContext();
+               $namesOfDisplayedFilters = [];
+               foreach ( $this->getFilterGroups() as $groupName => $group ) {
+                       if ( !$group->isPerGroupRequestParameter() ) {
+                               foreach ( $group->getFilters() as $filterName => $filter ) {
+                                       if ( $filter->displaysOnUnstructuredUi( $this ) ) {
+                                               $namesOfDisplayedFilters[] = $filterName;
+                                               $links[] = $this->showHideCheck(
+                                                       $nondefaults,
+                                                       $filter->getShowHide(),
+                                                       $filterName,
+                                                       $opts[$filterName]
+                                               );
+                                       }
+                               }
+                       }
                }
 
                $hiddenFields = $nondefaults;
@@ -481,8 +515,8 @@ class SpecialWatchlist extends ChangesListSpecialPage {
                unset( $hiddenFields['invert'] );
                unset( $hiddenFields['associated'] );
                unset( $hiddenFields['days'] );
-               foreach ( $filters as $key => $value ) {
-                       unset( $hiddenFields[$key] );
+               foreach ( $namesOfDisplayedFilters as $filterName ) {
+                       unset( $hiddenFields[$filterName] );
                }
 
                # Create output
index 18e75b4..2816f31 100644 (file)
        "rcfilters-filter-registered-label": "Registered",
        "rcfilters-filter-registered-description": "Logged-in editors.",
        "rcfilters-filter-unregistered-label": "Unregistered",
-       "rcfilters-filter-unregistered-description": " Editors who aren’t logged in.",
+       "rcfilters-filter-unregistered-description": "Editors who aren’t logged in.",
+       "rcfilters-filter-unregistered-conflicts-user-experience-level": "The \"Unregistered\" filter is inactive because its effect is being canceled by the following Experience {{PLURAL:$2|filter|filters}}, which {{PLURAL:$2|finds|find}} only registered users: $1",
        "rcfilters-filtergroup-authorship": "Edit authorship",
        "rcfilters-filter-editsbyself-label": "Your own edits",
        "rcfilters-filter-editsbyself-description": "Edits by you.",
        "rcfilters-filter-editsbyother-label": "Edits by others",
        "rcfilters-filter-editsbyother-description": "Edits created by other users (not you).",
        "rcfilters-filtergroup-userExpLevel": "Experience level (for registered users only)",
-       "rcfilters-filter-userExpLevel-newcomer-label": "Newcomers",
-       "rcfilters-filter-userExpLevel-newcomer-description": "Fewer than 10 edits and 4 days of activity.",
-       "rcfilters-filter-userExpLevel-learner-label": "Learners",
-       "rcfilters-filter-userExpLevel-learner-description": "More days of activity and edits than \"Newcomers\" but fewer than \"Experienced users\".",
-       "rcfilters-filter-userExpLevel-experienced-label": "Experienced users",
-       "rcfilters-filter-userExpLevel-experienced-description": "More than 30 days of activity and 500 edits.",
+       "rcfilters-filtergroup-user-experience-level-conflicts-unregistered": "This filter is inactive because it finds only registered users, so the \"Unregistered\" filter is canceling its effect.",
+       "rcfilters-filtergroup-user-experience-level-conflicts-unregistered-global": "The \"Unregistered\" filter is in conflict with one or more Experience filters. Experience filters find registered users only. The conflicting filters are marked as inactive above.",
+       "rcfilters-filter-user-experience-level-newcomer-label": "Newcomers",
+       "rcfilters-filter-user-experience-level-newcomer-description": "Fewer than 10 edits and 4 days of activity.",
+       "rcfilters-filter-user-experience-level-learner-label": "Learners",
+       "rcfilters-filter-user-experience-level-learner-description": "More days of activity and edits than \"Newcomers\" but fewer than \"Experienced users\".",
+       "rcfilters-filter-user-experience-level-experienced-label": "Experienced users",
+       "rcfilters-filter-user-experience-level-experienced-description": "More than 30 days of activity and 500 edits.",
        "rcfilters-filtergroup-automated": "Automated contributions",
        "rcfilters-filter-bots-label": "Bot",
        "rcfilters-filter-bots-description": "Edits made by automated tools.",
        "rcfilters-filter-humans-label": "Human (not bot)",
        "rcfilters-filter-humans-description": "Edits made by human editors.",
+       "rcfilters-filtergroup-reviewstatus": "Review status",
+       "rcfilters-filter-patrolled-label": "Patrolled",
+       "rcfilters-filter-patrolled-description": "Edits marked as patrolled.",
+       "rcfilters-filter-unpatrolled-label": "Unpatrolled",
+       "rcfilters-filter-unpatrolled-description": "Edits not marked as patrolled.",
        "rcfilters-filtergroup-significance": "Significance",
        "rcfilters-filter-minor-label": "Minor edits",
        "rcfilters-filter-minor-description": "Edits the author labeled as minor.",
index e2dfa9a..bc66c6b 100644 (file)
        "rcfilters-filter-registered-description": "Description for the filter for showing edits made by logged-in users.",
        "rcfilters-filter-unregistered-label": "Label for the filter for showing edits made by logged-out users.",
        "rcfilters-filter-unregistered-description": " Description for the filter for showing edits made by logged-out users.",
+       "rcfilters-filter-unregistered-conflicts-user-experience-level": "Tooltip shown when hovering over a Unregistered filter tag, when a User Experience Level filter is also selected.  This indicates that no results will be shown, because users matched by the User Experience Level groups are never unregistered.  Parameters:\n* $1 - Comma-separated string of selected User Experience Level filters, e.g. \"Newcomer, Experienced\"\n* $2 - Count of selected User Experience Level filters, for PLURAL",
        "rcfilters-filtergroup-authorship": "Title for the filter group for edit authorship. This filter group allows the user to choose between \"Your own edits\" and \"Edits by others\". More info: https://phabricator.wikimedia.org/T149859\n\n{{doc-important|This is another typical example of ambiguity in the English language. Only the documentation will reveal that this message means \"(filter by) authorship of these edits\", not \"edit the authorship\". That is, \"edit\" is a modifying noun, not a verb.}}",
        "rcfilters-filter-editsbyself-label": "Label for the filter for showing edits made by the current user.",
        "rcfilters-filter-editsbyself-description": "Description for the filter for showing edits made by the current user.",
        "rcfilters-filter-editsbyother-label": "Label for the filter for showing edits made by anyone other than the current user.",
        "rcfilters-filter-editsbyother-description": "Description for the filter for showing edits made by anyone other than the current user.",
        "rcfilters-filtergroup-userExpLevel": "Title for the filter group for user experience levels.",
-       "rcfilters-filter-userExpLevel-newcomer-label": "Label for the filter for showing edits made by new editors.",
-       "rcfilters-filter-userExpLevel-newcomer-description": "Description for the filter for showing edits made by new editors.",
-       "rcfilters-filter-userExpLevel-learner-label": "Label for the filter for showing edits made by learning editors.",
-       "rcfilters-filter-userExpLevel-learner-description": "Description for the filter for showing edits made by learning editors.",
-       "rcfilters-filter-userExpLevel-experienced-label": "Label for the filter for showing edits made by experienced editors.",
-       "rcfilters-filter-userExpLevel-experienced-description": "Description for the filter for showing edits made by experienced editors.",
+       "rcfilters-filtergroup-user-experience-level-conflicts-unregistered": "Tooltip shown when hovering over a User Experience Level filter tag, when only Unregistered users are being shown.  This indicates that no results will be shown, because users matched by the User Experience Level groups are never unregistered.",
+       "rcfilters-filtergroup-user-experience-level-conflicts-unregistered-global": "Message shown in the result area when both a User Experience Level filter and the Unregistered filter are selected.  This indicates that no results will be shown because users selected by the User Experience Filter are never unregistered",
+       "rcfilters-filter-user-experience-level-newcomer-label": "Label for the filter for showing edits made by new editors.",
+       "rcfilters-filter-user-experience-level-newcomer-description": "Description for the filter for showing edits made by new editors.",
+       "rcfilters-filter-user-experience-level-learner-label": "Label for the filter for showing edits made by learning editors.",
+       "rcfilters-filter-user-experience-level-learner-description": "Description for the filter for showing edits made by learning editors.",
+       "rcfilters-filter-user-experience-level-experienced-label": "Label for the filter for showing edits made by experienced editors.",
+       "rcfilters-filter-user-experience-level-experienced-description": "Description for the filter for showing edits made by experienced editors.",
        "rcfilters-filtergroup-automated": "Title for the filter group for editor automation type.",
        "rcfilters-filter-bots-label": "Label for the filter for showing edits made by automated tools.\n{{Identical|Bot}}",
        "rcfilters-filter-bots-description": "Description for the filter for showing edits made by automated tools.",
        "rcfilters-filter-humans-label": "Label for the filter for showing edits made by human editors.",
        "rcfilters-filter-humans-description": "Description for the filter for showing edits made by human editors.",
+       "rcfilters-filtergroup-reviewstatus": "Title for the filter group about review status (in core this is whether it's been patrolled)",
+       "rcfilters-filter-patrolled-label": "Label for the filter for showing patrolled edits",
+       "rcfilters-filter-patrolled-description": "Label for the filter showing patrolled edits",
+       "rcfilters-filter-unpatrolled-label": "Label for the filter for showing unpatrolled edits",
+       "rcfilters-filter-unpatrolled-description": "Description for the filter for showing unpatrolled edits",
        "rcfilters-filtergroup-significance": "Title for the filter group for edit significance.\n{{Identical|Significance}}",
        "rcfilters-filter-minor-label": "Label for the filter for showing edits marked as minor.",
        "rcfilters-filter-minor-description": "Description for the filter for showing edits marked as minor.",
index 0c3d27d..7a2ba69 100644 (file)
@@ -1807,42 +1807,6 @@ return [
                        'rcfilters-filterlist-title',
                        'rcfilters-filterlist-feedbacklink',
                        'rcfilters-filterlist-noresults',
-                       'rcfilters-filtergroup-registration',
-                       'rcfilters-filter-registered-label',
-                       'rcfilters-filter-registered-description',
-                       'rcfilters-filter-unregistered-label',
-                       'rcfilters-filter-unregistered-description',
-                       'rcfilters-filtergroup-authorship',
-                       'rcfilters-filter-editsbyself-label',
-                       'rcfilters-filter-editsbyself-description',
-                       'rcfilters-filter-editsbyother-label',
-                       'rcfilters-filter-editsbyother-description',
-                       'rcfilters-filtergroup-userExpLevel',
-                       'rcfilters-filter-userExpLevel-newcomer-label',
-                       'rcfilters-filter-userExpLevel-newcomer-description',
-                       'rcfilters-filter-userExpLevel-learner-label',
-                       'rcfilters-filter-userExpLevel-learner-description',
-                       'rcfilters-filter-userExpLevel-experienced-label',
-                       'rcfilters-filter-userExpLevel-experienced-description',
-                       'rcfilters-filtergroup-automated',
-                       'rcfilters-filter-bots-label',
-                       'rcfilters-filter-bots-description',
-                       'rcfilters-filter-humans-label',
-                       'rcfilters-filter-humans-description',
-                       'rcfilters-filtergroup-significance',
-                       'rcfilters-filter-minor-label',
-                       'rcfilters-filter-minor-description',
-                       'rcfilters-filter-major-label',
-                       'rcfilters-filter-major-description',
-                       'rcfilters-filtergroup-changetype',
-                       'rcfilters-filter-pageedits-label',
-                       'rcfilters-filter-pageedits-description',
-                       'rcfilters-filter-newpages-label',
-                       'rcfilters-filter-newpages-description',
-                       'rcfilters-filter-categorization-label',
-                       'rcfilters-filter-categorization-description',
-                       'rcfilters-filter-logactions-label',
-                       'rcfilters-filter-logactions-description',
                        'rcfilters-highlightbutton-title',
                        'rcfilters-highlightmenu-title',
                        'rcfilters-highlightmenu-help',
index 5be3656..3bb7716 100644 (file)
         * Set filters and preserve a group relationship based on
         * the definition given by an object
         *
-        * @param {Object} filters Filter group definition
+        * @param {Array} filters Filter group definition
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
                var i, filterItem, selectedFilterNames,
                this.clearItems();
                this.groups = {};
 
-               $.each( filters, function ( group, data ) {
+               filters.forEach( function ( data ) {
+                       var group = data.name;
+
                        if ( !model.groups[ group ] ) {
                                model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, {
                                        type: data.type,
-                                       title: data.title,
+                                       title: mw.msg( data.title ),
                                        separator: data.separator,
                                        fullCoverage: !!data.fullCoverage
                                } );
 
                        selectedFilterNames = [];
                        for ( i = 0; i < data.filters.length; i++ ) {
+                               data.filters[ i ].subset = data.filters[ i ].subset || [];
+                               data.filters[ i ].subset = data.filters[ i ].subset.map( function ( el ) {
+                                       return el.filter;
+                               } );
+
                                filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, model.groups[ group ], {
                                        group: group,
-                                       label: data.filters[ i ].label,
-                                       description: data.filters[ i ].description,
+                                       label: mw.msg( data.filters[ i ].label ),
+                                       description: mw.msg( data.filters[ i ].description ),
                                        subset: data.filters[ i ].subset,
-                                       cssClass: data.filters[ i ].class
+                                       cssClass: data.filters[ i ].cssClass
                                } );
 
                                // For convenience, we should store each filter's "supersets" -- these are
                                        }
                                }
 
-                               if ( values.length === 0 || values.length === filterItems.length ) {
+                               if ( values.length === filterItems.length ) {
                                        result[ group ] = 'all';
                                } else {
                                        result[ group ] = values.join( model.getSeparator() );
index e562057..f8008b6 100644 (file)
@@ -17,7 +17,7 @@
        /**
         * Initialize the filter and parameter states
         *
-        * @param {Object} filterStructure Filter definition and structure for the model
+        * @param {Array} filterStructure Filter definition and structure for the model
         */
        mw.rcfilters.Controller.prototype.initialize = function ( filterStructure ) {
                // Initialize the model
index a0b785d..4a586e4 100644 (file)
                        new mw.rcfilters.ui.ChangesListWrapperWidget(
                                filtersModel, changesListModel, $( '.mw-changeslist, .mw-changeslist-empty' ) );
 
-                       controller.initialize( {
-                               registration: {
-                                       title: mw.msg( 'rcfilters-filtergroup-registration' ),
-                                       type: 'send_unselected_if_any',
-                                       fullCoverage: true,
-                                       filters: [
-                                               {
-                                                       name: 'hideliu',
-                                                       label: mw.msg( 'rcfilters-filter-registered-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-registered-description' ),
-                                                       'class': 'mw-changeslist-liu'
-                                               },
-                                               {
-                                                       name: 'hideanons',
-                                                       label: mw.msg( 'rcfilters-filter-unregistered-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-unregistered-description' ),
-                                                       'class': 'mw-changeslist-anon'
-                                               }
-                                       ]
-                               },
-                               userExpLevel: {
-                                       title: mw.msg( 'rcfilters-filtergroup-userExpLevel' ),
-                                       // Type 'string_options' means that the group is evaluated by
-                                       // string values separated by comma; for example, param=opt1,opt2
-                                       // If all options are selected they are replaced by the term "all".
-                                       // The filters are the values for the parameter defined by the group.
-                                       // ** In this case, the parameter name is the group name. **
-                                       type: 'string_options',
-                                       separator: ',',
-                                       fullCoverage: false,
-                                       filters: [
-                                               {
-                                                       name: 'newcomer',
-                                                       label: mw.msg( 'rcfilters-filter-userExpLevel-newcomer-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-userExpLevel-newcomer-description' ),
-                                                       conflicts: [ 'hideanons' ],
-                                                       'class': 'mw-changeslist-user-newcomer'
-                                               },
-                                               {
-                                                       name: 'learner',
-                                                       label: mw.msg( 'rcfilters-filter-userExpLevel-learner-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-userExpLevel-learner-description' ),
-                                                       conflicts: [ 'hideanons' ],
-                                                       'class': 'mw-changeslist-user-learner'
-                                               },
-                                               {
-                                                       name: 'experienced',
-                                                       label: mw.msg( 'rcfilters-filter-userExpLevel-experienced-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-userExpLevel-experienced-description' ),
-                                                       conflicts: [ 'hideanons' ],
-                                                       'class': 'mw-changeslist-user-experienced'
-                                               }
-                                       ]
-                               },
-                               authorship: {
-                                       title: mw.msg( 'rcfilters-filtergroup-authorship' ),
-                                       // Type 'send_unselected_if_any' means that the controller will go over
-                                       // all unselected filters in the group and use their parameters
-                                       // as truthy in the query string.
-                                       // This is to handle the "negative" filters. We are showing users
-                                       // a positive message ("Show xxx") but the filters themselves are
-                                       // based on "hide YYY". The purpose of this is to correctly map
-                                       // the functionality to the UI, whether we are dealing with 2
-                                       // parameters in the group or more.
-                                       type: 'send_unselected_if_any',
-                                       fullCoverage: true,
-                                       filters: [
-                                               {
-                                                       name: 'hidemyself',
-                                                       label: mw.msg( 'rcfilters-filter-editsbyself-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-editsbyself-description' ),
-                                                       'class': 'mw-changeslist-self'
-                                               },
-                                               {
-                                                       name: 'hidebyothers',
-                                                       label: mw.msg( 'rcfilters-filter-editsbyother-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-editsbyother-description' ),
-                                                       'class': 'mw-changeslist-others'
-                                               }
-                                       ]
-                               },
-                               automated: {
-                                       title: mw.msg( 'rcfilters-filtergroup-automated' ),
-                                       type: 'send_unselected_if_any',
-                                       fullCoverage: true,
-                                       filters: [
-                                               {
-                                                       name: 'hidebots',
-                                                       label: mw.msg( 'rcfilters-filter-bots-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-bots-description' ),
-                                                       'default': true,
-                                                       'class': 'mw-changeslist-bot'
-                                               },
-                                               {
-                                                       name: 'hidehumans',
-                                                       label: mw.msg( 'rcfilters-filter-humans-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-humans-description' ),
-                                                       'default': false,
-                                                       'class': 'mw-changeslist-human'
-                                               }
-                                       ]
-                               },
-                               significance: {
-                                       title: mw.msg( 'rcfilters-filtergroup-significance' ),
-                                       type: 'send_unselected_if_any',
-                                       fullCoverage: true,
-                                       filters: [
-                                               {
-                                                       name: 'hideminor',
-                                                       label: mw.msg( 'rcfilters-filter-minor-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-minor-description' ),
-                                                       'class': 'mw-changeslist-minor'
-                                               },
-                                               {
-                                                       name: 'hidemajor',
-                                                       label: mw.msg( 'rcfilters-filter-major-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-major-description' ),
-                                                       'class': 'mw-changeslist-major'
-                                               }
-                                       ]
-                               },
-                               changetype: {
-                                       title: mw.msg( 'rcfilters-filtergroup-changetype' ),
-                                       type: 'send_unselected_if_any',
-                                       fullCoverage: true,
-                                       filters: [
-                                               {
-                                                       name: 'hidepageedits',
-                                                       label: mw.msg( 'rcfilters-filter-pageedits-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-pageedits-description' ),
-                                                       'default': false,
-                                                       'class': 'mw-changeslist-src-mw-edit'
-
-                                               },
-                                               {
-                                                       name: 'hidenewpages',
-                                                       label: mw.msg( 'rcfilters-filter-newpages-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-newpages-description' ),
-                                                       'default': false,
-                                                       'class': 'mw-changeslist-src-mw-new'
-                                               },
-                                               {
-                                                       name: 'hidecategorization',
-                                                       label: mw.msg( 'rcfilters-filter-categorization-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-categorization-description' ),
-                                                       'default': true,
-                                                       'class': 'mw-changeslist-src-mw-categorize'
-                                               },
-                                               {
-                                                       name: 'hidelog',
-                                                       label: mw.msg( 'rcfilters-filter-logactions-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-logactions-description' ),
-                                                       'default': false,
-                                                       'class': 'mw-changeslist-src-mw-log'
-                                               }
-                                       ]
-                               }
-                       } );
+                       controller.initialize( mw.config.get( 'wgStructuredChangeFilters' ) );
 
                        // eslint-disable-next-line no-new
                        new mw.rcfilters.ui.FormWrapperWidget(
index b67c9ab..ebd3c53 100644 (file)
@@ -24,6 +24,7 @@
 global $wgAutoloadClasses;
 $testDir = __DIR__ . "/..";
 
+// @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong
 $wgAutoloadClasses += [
 
        # tests/common
@@ -130,6 +131,7 @@ $wgAutoloadClasses += [
 
        # tests/phpunit/includes/specialpage
        'SpecialPageTestHelper' => "$testDir/phpunit/includes/specialpage/SpecialPageTestHelper.php",
+       'AbstractChangesListSpecialPageTestCase' => "$testDir/phpunit/includes/specialpage/AbstractChangesListSpecialPageTestCase.php",
 
        # tests/phpunit/includes/specials
        'SpecialPageTestBase' => "$testDir/phpunit/includes/specials/SpecialPageTestBase.php",
@@ -167,3 +169,4 @@ $wgAutoloadClasses += [
        'ParserTestFileSuite' => "$testDir/phpunit/suites/ParserTestFileSuite.php",
        'ParserTestTopLevelSuite' => "$testDir/phpunit/suites/ParserTestTopLevelSuite.php",
 ];
+// @codingStandardsIgnoreEnd
\ No newline at end of file
diff --git a/tests/phpunit/includes/changes/ChangesListBooleanFilterGroupTest.php b/tests/phpunit/includes/changes/ChangesListBooleanFilterGroupTest.php
new file mode 100644 (file)
index 0000000..0db3a49
--- /dev/null
@@ -0,0 +1,126 @@
+<?php
+
+/**
+ * @covers ChangesListBooleanFilterGroup
+ */
+class ChangesListBooleanFilterGroupTest extends MediaWikiTestCase {
+       public function testIsFullCoverage() {
+               $hideGroupDefault = TestingAccessWrapper::newFromObject(
+                       new ChangesListBooleanFilterGroup( [
+                               'name' => 'groupName',
+                               'priority' => 1,
+                               'filters' => [],
+                       ] )
+               );
+
+               $this->assertSame(
+                       true,
+                       $hideGroupDefault->isFullCoverage
+               );
+       }
+
+       public function testAutoPriorities() {
+               $group = new ChangesListBooleanFilterGroup( [
+                       'name' => 'groupName',
+                       'priority' => 1,
+                       'filters' => [
+                               [ 'name' => 'hidefoo', 'default' => false, ],
+                               [ 'name' => 'hidebar', 'default' => false, ],
+                               [ 'name' => 'hidebaz', 'default' => false, ],
+                       ],
+               ] );
+
+               $filters = $group->getFilters();
+               $this->assertEquals(
+                       [
+                               -2,
+                               -3,
+                               -4,
+                       ],
+                       array_map(
+                               function ( $f ) {
+                                       return $f->getPriority();
+                               },
+                               array_values( $filters )
+                       )
+               );
+       }
+
+       public function testGetJsData() {
+               $definition = [
+                       'name' => 'some-group',
+                       'title' => 'some-group-title',
+                       'priority' => 1,
+                       'filters' => [
+                               [
+                                       'name' => 'hidefoo',
+                                       'label' => 'foo-label',
+                                       'description' => 'foo-description',
+                                       'default' => true,
+                                       'showHide' => 'showhidefoo',
+                                       'priority' => 2,
+                               ],
+                               [
+                                       'name' => 'hidebar',
+                                       'label' => 'bar-label',
+                                       'description' => 'bar-description',
+                                       'default' => false,
+                                       'priority' => 4,
+                               ]
+                       ],
+               ];
+
+               $group = new ChangesListBooleanFilterGroup( $definition );
+
+               $specialPage = $this->getMockBuilder( 'ChangesListSpecialPage' )
+                       ->setConstructorArgs( [
+                               'ChangesListSpecialPage',
+                               '',
+                       ] )
+                       ->getMockForAbstractClass();
+
+               $this->assertArrayEquals(
+                       [
+                               'name' => 'some-group',
+                               'title' => 'some-group-title',
+                               'type' => ChangesListBooleanFilterGroup::TYPE,
+                               'priority' => 1,
+                               'filters' => [
+                                       [
+                                               'name' => 'hidebar',
+                                               'label' => 'bar-label',
+                                               'description' => 'bar-description',
+                                               'default' => false,
+                                               'priority' => 4,
+                                               'cssClass' => null,
+                                               'conflicts' => [],
+                                               'subset' => [],
+                                       ],
+                                       [
+                                               'name' => 'hidefoo',
+                                               'label' => 'foo-label',
+                                               'description' => 'foo-description',
+                                               'default' => true,
+                                               'priority' => 2,
+                                               'cssClass' => null,
+                                               'conflicts' => [],
+                                               'subset' => [],
+                                       ],
+                               ],
+                               'conflicts' => [],
+                               'fullCoverage' => true,
+                               'messageKeys' => [
+                                       'some-group-title',
+                                       'bar-label',
+                                       'bar-description',
+                                       'foo-label',
+                                       'foo-description',
+                               ],
+                       ],
+
+                       $group->getJsData( $specialPage ),
+                       /** ordered= */ false,
+                       /** named= */ true
+               );
+       }
+}
diff --git a/tests/phpunit/includes/changes/ChangesListBooleanFilterTest.php b/tests/phpunit/includes/changes/ChangesListBooleanFilterTest.php
new file mode 100644 (file)
index 0000000..c715988
--- /dev/null
@@ -0,0 +1,223 @@
+<?php
+
+/**
+ * @covers ChangesListBooleanFilter
+ */
+class ChangesListBooleanFilterTest extends MediaWikiTestCase {
+       public function testGetJsData() {
+               $group = new ChangesListBooleanFilterGroup( [
+                       'name' => 'group',
+                       'priority' => 2,
+                       'filters' => [],
+               ] );
+
+               $definition = [
+                       'group' => $group,
+                       'label' => 'main-label',
+                       'description' => 'main-description',
+                       'default' => 1,
+                       'priority' => 1,
+               ];
+
+               $fooFilter = new ChangesListBooleanFilter(
+                       $definition + [ 'name' => 'hidefoo' ]
+               );
+
+               $barFilter = new ChangesListBooleanFilter(
+                       $definition + [ 'name' => 'hidebar' ]
+               );
+
+               $bazFilter = new ChangesListBooleanFilter(
+                       $definition + [ 'name' => 'hidebaz' ]
+               );
+
+               $fooFilter->conflictsWith(
+                       $barFilter,
+                       'foo-bar-global-conflict',
+                       'foo-conflicts-bar',
+                       'bar-conflicts-foo'
+               );
+
+               $fooFilter->setAsSupersetOf( $bazFilter, 'foo-superset-of-baz' );
+
+               $fooData = $fooFilter->getJsData();
+               $this->assertArrayEquals(
+                       [
+                               'name' => 'hidefoo',
+                               'label' => 'main-label',
+                               'description' => 'main-description',
+                               'default' => 1,
+                               'priority' => 1,
+                               'cssClass' => null,
+                               'conflicts' => [
+                                       [
+                                               'group' => 'group',
+                                               'filter' => 'hidebar',
+                                               'globalDescription' => 'foo-bar-global-conflict',
+                                               'contextDescription' => 'foo-conflicts-bar',
+                                       ]
+                               ],
+                               'subset' => [
+                                       [
+                                               'group' => 'group',
+                                               'filter' => 'hidebaz',
+                                       ],
+
+                               ],
+                               'messageKeys' => [
+                                       'main-label',
+                                       'main-description',
+                                       'foo-bar-global-conflict',
+                                       'foo-conflicts-bar',
+                               ],
+                       ],
+                       $fooData,
+                       /** ordered= */ false,
+                       /** named= */ true
+               );
+
+               $barData = $barFilter->getJsData();
+               $this->assertArrayEquals(
+                       [
+                               'name' => 'hidebar',
+                               'label' => 'main-label',
+                               'description' => 'main-description',
+                               'default' => 1,
+                               'priority' => 1,
+                               'cssClass' => null,
+                               'conflicts' => [
+                                       [
+                                               'group' => 'group',
+                                               'filter' => 'hidefoo',
+                                               'globalDescription' => 'foo-bar-global-conflict',
+                                               'contextDescription' => 'bar-conflicts-foo',
+                                       ]
+                               ],
+                               'subset' => [],
+                               'messageKeys' => [
+                                       'main-label',
+                                       'main-description',
+                                       'foo-bar-global-conflict',
+                                       'bar-conflicts-foo',
+                               ],
+                       ],
+                       $barData,
+                       /** ordered= */ false,
+                       /** named= */ true
+               );
+       }
+
+       /**
+        * @expectedException MWException
+        * @expectedExceptionMessage Supersets can only be defined for filters in the same group
+        */
+       public function testSetAsSupersetOf() {
+               $groupA = new ChangesListBooleanFilterGroup( [
+                       'name' => 'groupA',
+                       'priority' => 2,
+                       'filters' => [
+                               [
+                                       'name' => 'foo',
+                                       'default' => false,
+                               ],
+                               [
+                                       'name' => 'bar',
+                                       'default' => false,
+                               ]
+                       ],
+               ] );
+
+               $groupB = new ChangesListBooleanFilterGroup( [
+                       'name' => 'groupB',
+                       'priority' => 3,
+                       'filters' => [
+                               [
+                                       'name' => 'baz',
+                                       'default' => true,
+                               ],
+                       ],
+               ] );
+
+               $foo = TestingAccessWrapper::newFromObject( $groupA->getFilter( 'foo' ) );
+
+               $bar = $groupA->getFilter( 'bar' );
+
+               $baz = $groupB->getFilter( 'baz' );
+
+               $foo->setAsSupersetOf( $bar );
+               $this->assertArrayEquals( [
+                               [
+                                       'group' => 'groupA',
+                                       'filter' => 'bar',
+                               ],
+                       ],
+                       $foo->subsetFilters,
+                       /** ordered= */ false,
+                       /** named= */ true
+               );
+
+               $foo->setAsSupersetOf( $baz, 'some-message' );
+       }
+
+       public function testIsFeatureAvailableOnStructuredUi() {
+               $specialPage = $this->getMockBuilder( 'ChangesListSpecialPage' )
+                       ->setConstructorArgs( [
+                                       'ChangesListSpecialPage',
+                                       '',
+                               ] )
+                       ->getMockForAbstractClass();
+
+               $groupA = new ChangesListBooleanFilterGroup( [
+                       'name' => 'groupA',
+                       'priority' => 1,
+                       'filters' => [],
+               ] );
+
+               $foo = new ChangesListBooleanFilter( [
+                       'name' => 'hidefoo',
+                       'group' => $groupA,
+                       'label' => 'foo-label',
+                       'description' => 'foo-description',
+                       'default' => true,
+                       'showHide' => 'showhidefoo',
+                       'priority' => 2,
+               ] );
+
+               $this->assertEquals(
+                       true,
+                       $foo->isFeatureAvailableOnStructuredUi( $specialPage ),
+                       'Same filter appears on both'
+               );
+
+               // Should only be legacy ones that haven't been ported yet
+               $bar = new ChangesListBooleanFilter( [
+                       'name' => 'hidebar',
+                       'default' => true,
+                       'group' => $groupA,
+                       'showHide' => 'showhidebar',
+                       'priority' => 2,
+               ] );
+
+               $this->assertEquals(
+                       false,
+                       $bar->isFeatureAvailableOnStructuredUi( $specialPage ),
+                       'Only on unstructured UI'
+               );
+
+               $baz = new ChangesListBooleanFilter( [
+                       'name' => 'hidebaz',
+                       'default' => true,
+                       'group' => $groupA,
+                       'showHide' => 'showhidebaz',
+                       'isReplacedInStructuredUi' => true,
+                       'priority' => 2,
+               ] );
+
+               $this->assertEquals(
+                       true,
+                       $baz->isFeatureAvailableOnStructuredUi( $specialPage ),
+                       'Legacy filter does not appear directly in new UI, but equivalent ' .
+                               'does and is marked with isReplacedInStructuredUi'
+               );
+       }
+}
diff --git a/tests/phpunit/includes/changes/ChangesListStringOptionsFilterGroupTest.php b/tests/phpunit/includes/changes/ChangesListStringOptionsFilterGroupTest.php
new file mode 100644 (file)
index 0000000..019e257
--- /dev/null
@@ -0,0 +1,302 @@
+<?php
+
+/**
+ * @covers ChangesListStringOptionsFilterGroup
+ */
+class ChangesListStringOptionsFilterGroupTest extends MediaWikiTestCase {
+       /**
+        * @expectedException MWException
+        */
+       public function testIsFullCoverage() {
+               $falseGroup = TestingAccessWrapper::newFromObject(
+                       new ChangesListStringOptionsFilterGroup( [
+                               'name' => 'group',
+                               'filters' => [],
+                               'isFullCoverage' => false,
+                               'queryCallable' => function () {
+                               }
+                       ] )
+               );
+
+               $this->assertSame(
+                       false,
+                       $falseGroup->isFullCoverage
+               );
+
+               // Should throw due to missing isFullCoverage
+               $undefinedFullCoverageGroup = new ChangesListStringOptionsFilterGroup( [
+                       'name' => 'othergroup',
+                       'filters' => [],
+               ] );
+       }
+
+       /**
+        * @param array $filterDefinitions Array of filter definitions
+        * @param array $expectedValues Array of values callback should receive
+        * @param string $input Value in URL
+        *
+        * @dataProvider provideModifyQuery
+        */
+       public function testModifyQuery( $filterDefinitions, $expectedValues, $input ) {
+               $self = $this;
+
+               $queryCallable = function (
+                       $className,
+                       $ctx,
+                       $dbr,
+                       &$tables,
+                       &$fields,
+                       &$conds,
+                       &$query_options,
+                       &$join_conds,
+                       $actualSelectedValues
+               ) use ( $self, $expectedValues ) {
+                       $self->assertSame(
+                               $expectedValues,
+                               $actualSelectedValues
+                       );
+               };
+
+               $groupDefinition = [
+                       'name' => 'group',
+                       'default' => '',
+                       'isFullCoverage' => true,
+                       'filters' => $filterDefinitions,
+                       'queryCallable' => $queryCallable,
+               ];
+
+               $this->modifyQueryHelper( $groupDefinition, $input );
+       }
+
+       public function provideModifyQuery() {
+               $mixedFilters = [
+                       [
+                               'name' => 'foo',
+                       ],
+                       [
+                               'name' => 'bar',
+                               'isAllowedCallable' => function () {
+                                       return false;
+                               },
+                       ],
+                       [
+                               'name' => 'baz',
+                       ],
+                       [
+                               'name' => 'goo'
+                       ],
+               ];
+
+               return [
+                       [
+                               $mixedFilters,
+                               [ 'baz', 'foo', ],
+                               'foo;bar;BaZ;invalid',
+                       ],
+
+                       [
+                               $mixedFilters,
+                               [ 'baz', 'foo', 'goo' ],
+                               'all',
+                       ],
+               ];
+       }
+
+       /**
+        * @param array $filterDefinitions Array of filter definitions
+        * @param string $input Value in URL
+        * @param string $message Message thrown by exception
+        *
+        * @dataProvider provideNoOpModifyQuery
+        */
+       public function testNoOpModifyQuery( $filterDefinitions, $input, $message ) {
+               $noFiltersAllowedCallable = function (
+                       $className,
+                       $ctx,
+                       $dbr,
+                       &$tables,
+                       &$fields,
+                       &$conds,
+                       &$query_options,
+                       &$join_conds,
+                       $actualSelectedValues
+               ) use ( $message ) {
+                       throw new MWException( $message );
+               };
+
+               $groupDefinition = [
+                       'name' => 'group',
+                       'default' => '',
+                       'isFullCoverage' => true,
+                       'filters' => $filterDefinitions,
+                       'queryCallable' => $noFiltersAllowedCallable,
+               ];
+
+               $this->modifyQueryHelper( $groupDefinition, $input );
+
+               $this->assertTrue(
+                       true,
+                       'Test successfully completed without calling queryCallable'
+               );
+       }
+
+       public function provideNoOpModifyQuery() {
+               $isAllowedFalse = [
+                       'isAllowedCallable' => function () {
+                               return false;
+                       },
+               ];
+
+               $allDisallowedFilters = [
+                       [
+                               'name' => 'disallowed1',
+                       ] + $isAllowedFalse,
+
+                       [
+                               'name' => 'disallowed2',
+                       ] + $isAllowedFalse,
+
+                       [
+                               'name' => 'disallowed3',
+                       ] + $isAllowedFalse,
+               ];
+
+               $normalFilters = [
+                       [
+                               'name' => 'foo',
+                       ],
+                       [
+                               'name' => 'bar',
+                       ]
+               ];
+
+               return [
+                       [
+                               $allDisallowedFilters,
+                               'disallowed1;disallowed3',
+                               'The queryCallable should not be called if no filters are allowed',
+                       ],
+
+                       [
+                               $normalFilters,
+                               '',
+                               'The queryCallable should not be called if no filters are selected',
+                       ],
+
+                       [
+                               $normalFilters,
+                               'invalid1',
+                               'The queryCallable should not be called if no valid filters are selected',
+                       ],
+               ];
+       }
+
+       protected function getSpecialPage() {
+               return $this->getMockBuilder( 'ChangesListSpecialPage' )
+                       ->setConstructorArgs( [
+                                       'ChangesListSpecialPage',
+                                       '',
+                               ] )
+                       ->getMockForAbstractClass();
+       }
+
+       /**
+        * @param array $groupDefinition Group definition
+        * @param string $input Value in URL
+        *
+        * @dataProvider provideModifyQuery
+        */
+       protected function modifyQueryHelper( $groupDefinition, $input ) {
+               $ctx = $this->getMock( 'IContextSource' );
+               $dbr = $this->getMock( 'IDatabase' );
+               $tables = $fields = $conds = $query_options = $join_conds = [];
+
+               $group = new ChangesListStringOptionsFilterGroup( $groupDefinition );
+
+               $specialPage = $this->getSpecialPage();
+
+               $group->modifyQuery(
+                       $dbr,
+                       $specialPage,
+                       $tables,
+                       $fields,
+                       $conds,
+                       $query_options,
+                       $join_conds,
+                       $input
+               );
+       }
+
+       public function testGetJsData() {
+               $definition = [
+                       'name' => 'some-group',
+                       'title' => 'some-group-title',
+                       'default' => 'foo',
+                       'priority' => 1,
+                       'isFullCoverage' => false,
+                       'queryCallable' => function () {
+                       },
+                       'filters' => [
+                               [
+                                       'name' => 'foo',
+                                       'label' => 'foo-label',
+                                       'description' => 'foo-description',
+                                       'priority' => 2,
+                               ],
+                               [
+                                       'name' => 'bar',
+                                       'label' => 'bar-label',
+                                       'description' => 'bar-description',
+                                       'priority' => 4,
+                               ]
+                       ],
+               ];
+
+               $group = new ChangesListStringOptionsFilterGroup( $definition );
+
+               $specialPage = $this->getSpecialPage();
+
+               $this->assertArrayEquals(
+                       [
+                               'name' => 'some-group',
+                               'title' => 'some-group-title',
+                               'type' => ChangesListStringOptionsFilterGroup::TYPE,
+                               'default' => 'foo',
+                               'priority' => 1,
+                               'fullCoverage' => false,
+                               'filters' => [
+                                       [
+                                               'name' => 'bar',
+                                               'label' => 'bar-label',
+                                               'description' => 'bar-description',
+                                               'priority' => 4,
+                                               'cssClass' => null,
+                                               'conflicts' => [],
+                                               'subset' => [],
+                                       ],
+                                       [
+                                               'name' => 'foo',
+                                               'label' => 'foo-label',
+                                               'description' => 'foo-description',
+                                               'priority' => 2,
+                                               'cssClass' => null,
+                                               'conflicts' => [],
+                                               'subset' => [],
+                                       ],
+                               ],
+                               'conflicts' => [],
+                               'separator' => ';',
+                               'messageKeys' => [
+                                       'some-group-title',
+                                       'bar-label',
+                                       'bar-description',
+                                       'foo-label',
+                                       'foo-description',
+                               ],
+                       ],
+                       $group->getJsData( $specialPage ),
+                       /** ordered= */ false,
+                       /** named= */ true
+               );
+       }
+}
diff --git a/tests/phpunit/includes/specialpage/AbstractChangesListSpecialPageTestCase.php b/tests/phpunit/includes/specialpage/AbstractChangesListSpecialPageTestCase.php
new file mode 100644 (file)
index 0000000..621d6a2
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+/**
+ * Abstract base class for shared logic when testing ChangesListSpecialPage
+ * and subclasses
+ *
+ * @group Database
+ */
+abstract class AbstractChangesListSpecialPageTestCase extends MediaWikiTestCase {
+       // Must be initialized by subclass
+       /**
+        * @var ChangesListSpecialPage
+        */
+       protected $changesListSpecialPage;
+
+       protected function setUp() {
+               parent::setUp();
+               $this->setMwGlobals( 'wgRCWatchCategoryMembership', true );
+       }
+
+       /**
+        * @dataProvider provideParseParameters
+        */
+       public function testParseParameters( $params, $expected ) {
+               $this->changesListSpecialPage->registerFilters();
+
+               $opts = new FormOptions();
+               foreach ( $expected as $key => $value ) {
+                       // Register it as null so sets aren't rejected.
+                       $opts->add(
+                               $key,
+                               null,
+                               FormOptions::guessType( $expected )
+                       );
+               }
+
+               $this->changesListSpecialPage->parseParameters(
+                       $params,
+                       $opts
+               );
+
+               $this->assertArrayEquals(
+                       $expected,
+                       $opts->getAllValues(),
+                       /** ordered= */ false,
+                       /** named= */ true
+               );
+       }
+}
diff --git a/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php b/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php
new file mode 100644 (file)
index 0000000..c292e97
--- /dev/null
@@ -0,0 +1,804 @@
+<?php
+/**
+ * Test class for ChangesListSpecialPage class
+ *
+ * Copyright Â© 2011-, Antoine Musso, Stephane Bisson, Matthew Flaschen
+ *
+ * @author Antoine Musso
+ * @author Stephane Bisson
+ * @author Matthew Flaschen
+ * @group Database
+ *
+ * @covers ChangesListSpecialPage
+ */
+class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase {
+       protected function setUp() {
+               parent::setUp();
+
+               # setup the rc object
+               $this->changesListSpecialPage = $this->getPage();
+       }
+
+       protected function getPage() {
+               return TestingAccessWrapper::newFromObject(
+                       $this->getMockForAbstractClass(
+                               'ChangesListSpecialPage',
+                               [
+                                       'ChangesListSpecialPage',
+                                       ''
+                               ]
+                       )
+               );
+       }
+
+       /** helper to test SpecialRecentchanges::buildMainQueryConds() */
+       private function assertConditions(
+               $expected,
+               $requestOptions = null,
+               $message = '',
+               $user = null
+       ) {
+               $context = new RequestContext;
+               $context->setRequest( new FauxRequest( $requestOptions ) );
+               if ( $user ) {
+                       $context->setUser( $user );
+               }
+
+               $this->changesListSpecialPage->setContext( $context );
+               $formOptions = $this->changesListSpecialPage->setup( null );
+
+               # Â Filter out rc_timestamp conditions which depends on the test runtime
+               # This condition is not needed as of march 2, 2011 -- hashar
+               # @todo FIXME: Find a way to generate the correct rc_timestamp
+
+               $tables = [];
+               $fields = [];
+               $queryConditions = [];
+               $query_options = [];
+               $join_conds = [];
+
+               call_user_func_array(
+                       [ $this->changesListSpecialPage, 'buildQuery' ],
+                       [
+                               &$tables,
+                               &$fields,
+                               &$queryConditions,
+                               &$query_options,
+                               &$join_conds,
+                               $formOptions
+                       ]
+               );
+
+               $queryConditions = array_filter(
+                       $queryConditions,
+                       'ChangesListSpecialPageTest::filterOutRcTimestampCondition'
+               );
+
+               $this->assertEquals(
+                       self::normalizeCondition( $expected ),
+                       self::normalizeCondition( $queryConditions ),
+                       $message
+               );
+       }
+
+       private static function normalizeCondition( $conds ) {
+               $normalized = array_map(
+                       function ( $k, $v ) {
+                               return is_numeric( $k ) ? $v : "$k = $v";
+                       },
+                       array_keys( $conds ),
+                       $conds
+               );
+               sort( $normalized );
+               return $normalized;
+       }
+
+       /** return false if condition begin with 'rc_timestamp ' */
+       private static function filterOutRcTimestampCondition( $var ) {
+               return ( false === strpos( $var, 'rc_timestamp ' ) );
+       }
+
+       public function testRcNsFilter() {
+               $this->assertConditions(
+                       [ # expected
+                               "rc_namespace = '0'",
+                       ],
+                       [
+                               'namespace' => NS_MAIN,
+                       ],
+                       "rc conditions with no options (aka default setting)"
+               );
+       }
+
+       public function testRcNsFilterInversion() {
+               $this->assertConditions(
+                       [ # expected
+                               "rc_namespace != '0'",
+                       ],
+                       [
+                               'namespace' => NS_MAIN,
+                               'invert' => 1,
+                       ],
+                       "rc conditions with namespace inverted"
+               );
+       }
+
+       /**
+        * T4429
+        * @dataProvider provideNamespacesAssociations
+        */
+       public function testRcNsFilterAssociation( $ns1, $ns2 ) {
+               $this->assertConditions(
+                       [ # expected
+                               "(rc_namespace = '$ns1' OR rc_namespace = '$ns2')",
+                       ],
+                       [
+                               'namespace' => $ns1,
+                               'associated' => 1,
+                       ],
+                       "rc conditions with namespace inverted"
+               );
+       }
+
+       /**
+        * T4429
+        * @dataProvider provideNamespacesAssociations
+        */
+       public function testRcNsFilterAssociationWithInversion( $ns1, $ns2 ) {
+               $this->assertConditions(
+                       [ # expected
+                               "(rc_namespace != '$ns1' AND rc_namespace != '$ns2')",
+                       ],
+                       [
+                               'namespace' => $ns1,
+                               'associated' => 1,
+                               'invert' => 1,
+                       ],
+                       "rc conditions with namespace inverted"
+               );
+       }
+
+       /**
+        * Provides associated namespaces to test recent changes
+        * namespaces association filtering.
+        */
+       public static function provideNamespacesAssociations() {
+               return [ # (NS => Associated_NS)
+                       [ NS_MAIN, NS_TALK ],
+                       [ NS_TALK, NS_MAIN ],
+               ];
+       }
+
+       public function testRcHidemyselfFilter() {
+               $user = $this->getTestUser()->getUser();
+               $this->assertConditions(
+                       [ # expected
+                               "rc_user != '{$user->getId()}'",
+                       ],
+                       [
+                               'hidemyself' => 1,
+                       ],
+                       "rc conditions: hidemyself=1 (logged in)",
+                       $user
+               );
+
+               $user = User::newFromName( '10.11.12.13', false );
+               $this->assertConditions(
+                       [ # expected
+                               "rc_user_text != '10.11.12.13'",
+                       ],
+                       [
+                               'hidemyself' => 1,
+                       ],
+                       "rc conditions: hidemyself=1 (anon)",
+                       $user
+               );
+       }
+
+       public function testRcHidebyothersFilter() {
+               $user = $this->getTestUser()->getUser();
+               $this->assertConditions(
+                       [ # expected
+                               "rc_user = '{$user->getId()}'",
+                       ],
+                       [
+                               'hidebyothers' => 1,
+                       ],
+                       "rc conditions: hidebyothers=1 (logged in)",
+                       $user
+               );
+
+               $user = User::newFromName( '10.11.12.13', false );
+               $this->assertConditions(
+                       [ # expected
+                               "rc_user_text = '10.11.12.13'",
+                       ],
+                       [
+                               'hidebyothers' => 1,
+                       ],
+                       "rc conditions: hidebyothers=1 (anon)",
+                       $user
+               );
+       }
+
+       public function testRcHidemyselfHidebyothersFilter() {
+               $user = $this->getTestUser()->getUser();
+               $this->assertConditions(
+                       [ # expected
+                               "rc_user != '{$user->getId()}'",
+                               "rc_user = '{$user->getId()}'",
+                       ],
+                       [
+                               'hidemyself' => 1,
+                               'hidebyothers' => 1,
+                       ],
+                       "rc conditions: hidemyself=1 hidebyothers=1 (logged in)",
+                       $user
+               );
+       }
+
+       public function testRcHidepageedits() {
+               $this->assertConditions(
+                       [ # expected
+                               "rc_type != '0'",
+                       ],
+                       [
+                               'hidepageedits' => 1,
+                       ],
+                       "rc conditions: hidepageedits=1"
+               );
+       }
+
+       public function testRcHidenewpages() {
+               $this->assertConditions(
+                       [ # expected
+                               "rc_type != '1'",
+                       ],
+                       [
+                               'hidenewpages' => 1,
+                       ],
+                       "rc conditions: hidenewpages=1"
+               );
+       }
+
+       public function testRcHidelog() {
+               $this->assertConditions(
+                       [ # expected
+                               "rc_type != '3'",
+                       ],
+                       [
+                               'hidelog' => 1,
+                       ],
+                       "rc conditions: hidelog=1"
+               );
+       }
+
+       public function testRcHidehumans() {
+               $this->assertConditions(
+                       [ # expected
+                               'rc_bot' => 1,
+                       ],
+                       [
+                               'hidebots' => 0,
+                               'hidehumans' => 1,
+                       ],
+                       "rc conditions: hidebots=0 hidehumans=1"
+               );
+       }
+
+       public function testRcHidepatrolledDisabledFilter() {
+               $user = $this->getTestUser()->getUser();
+               $this->assertConditions(
+                       [ # expected
+                       ],
+                       [
+                               'hidepatrolled' => 1,
+                       ],
+                       "rc conditions: hidepatrolled=1 (user not allowed)",
+                       $user
+               );
+       }
+
+       public function testRcHideunpatrolledDisabledFilter() {
+               $user = $this->getTestUser()->getUser();
+               $this->assertConditions(
+                       [ # expected
+                       ],
+                       [
+                               'hideunpatrolled' => 1,
+                       ],
+                       "rc conditions: hideunpatrolled=1 (user not allowed)",
+                       $user
+               );
+       }
+       public function testRcHidepatrolledFilter() {
+               $user = $this->getTestSysop()->getUser();
+               $this->assertConditions(
+                       [ # expected
+                               "rc_patrolled = 0",
+                       ],
+                       [
+                               'hidepatrolled' => 1,
+                       ],
+                       "rc conditions: hidepatrolled=1",
+                       $user
+               );
+       }
+
+       public function testRcHideunpatrolledFilter() {
+               $user = $this->getTestSysop()->getUser();
+               $this->assertConditions(
+                       [ # expected
+                               "rc_patrolled = 1",
+                       ],
+                       [
+                               'hideunpatrolled' => 1,
+                       ],
+                       "rc conditions: hideunpatrolled=1",
+                       $user
+               );
+       }
+
+       public function testRcHideminorFilter() {
+               $this->assertConditions(
+                       [ # expected
+                               "rc_minor = 0",
+                       ],
+                       [
+                               'hideminor' => 1,
+                       ],
+                       "rc conditions: hideminor=1"
+               );
+       }
+
+       public function testRcHidemajorFilter() {
+               $this->assertConditions(
+                       [ # expected
+                               "rc_minor = 1",
+                       ],
+                       [
+                               'hidemajor' => 1,
+                       ],
+                       "rc conditions: hidemajor=1"
+               );
+       }
+
+       public function testRcHidepatrolledHideunpatrolledFilter() {
+               $user = $this->getTestSysop()->getUser();
+               $this->assertConditions(
+                       [ # expected
+                               "rc_patrolled = 0",
+                               "rc_patrolled = 1",
+                       ],
+                       [
+                               'hidepatrolled' => 1,
+                               'hideunpatrolled' => 1,
+                       ],
+                       "rc conditions: hidepatrolled=1 hideunpatrolled=1",
+                       $user
+               );
+       }
+
+       public function testHideCategorization() {
+               $this->assertConditions(
+                       [
+                               # expected
+                               "rc_type != '6'"
+                       ],
+                       [
+                               'hidecategorization' => 1
+                       ],
+                       "rc conditions: hidecategorization=1"
+               );
+       }
+
+       public function testFilterUserExpLevel() {
+               $this->setMwGlobals( [
+                       'wgLearnerEdits' => 10,
+                       'wgLearnerMemberSince' => 4,
+                       'wgExperiencedUserEdits' => 500,
+                       'wgExperiencedUserMemberSince' => 30,
+               ] );
+
+               $this->createUsers( [
+                       'Newcomer1' => [ 'edits' => 2, 'days' => 2 ],
+                       'Newcomer2' => [ 'edits' => 12, 'days' => 3 ],
+                       'Newcomer3' => [ 'edits' => 8, 'days' => 5 ],
+                       'Learner1' => [ 'edits' => 15, 'days' => 10 ],
+                       'Learner2' => [ 'edits' => 450, 'days' => 20 ],
+                       'Learner3' => [ 'edits' => 460, 'days' => 33 ],
+                       'Learner4' => [ 'edits' => 525, 'days' => 28 ],
+                       'Experienced1' => [ 'edits' => 538, 'days' => 33 ],
+               ] );
+
+               // newcomers only
+               $this->assertArrayEquals(
+                       [ 'Newcomer1', 'Newcomer2', 'Newcomer3' ],
+                       $this->fetchUsers( [ 'newcomer' ] )
+               );
+
+               // newcomers and learner
+               $this->assertArrayEquals(
+                       [
+                               'Newcomer1', 'Newcomer2', 'Newcomer3',
+                               'Learner1', 'Learner2', 'Learner3', 'Learner4',
+                       ],
+                       $this->fetchUsers( [ 'newcomer', 'learner' ] )
+               );
+
+               // newcomers and more learner
+               $this->assertArrayEquals(
+                       [
+                               'Newcomer1', 'Newcomer2', 'Newcomer3',
+                               'Experienced1',
+                       ],
+                       $this->fetchUsers( [ 'newcomer', 'experienced' ] )
+               );
+
+               // learner only
+               $this->assertArrayEquals(
+                       [ 'Learner1', 'Learner2', 'Learner3', 'Learner4' ],
+                       $this->fetchUsers( [ 'learner' ] )
+               );
+
+               // more experienced only
+               $this->assertArrayEquals(
+                       [ 'Experienced1' ],
+                       $this->fetchUsers( [ 'experienced' ] )
+               );
+
+               // learner and more experienced
+               $this->assertArrayEquals(
+                       [
+                               'Learner1', 'Learner2', 'Learner3', 'Learner4',
+                               'Experienced1',
+                       ],
+                       $this->fetchUsers( [ 'learner', 'experienced' ] ),
+                       'Learner and more experienced'
+               );
+
+               // newcomers, learner, and more experienced
+               // TOOD: Fix test.  This needs to test that anons are excluded,
+               // and right now the join fails.
+               /* $this->assertArrayEquals( */
+               /*      [ */
+               /*              'Newcomer1', 'Newcomer2', 'Newcomer3', */
+               /*              'Learner1', 'Learner2', 'Learner3', 'Learner4', */
+               /*              'Experienced1', */
+               /*      ], */
+               /*      $this->fetchUsers( [ 'newcomer', 'learner', 'experienced' ] ) */
+               /* ); */
+       }
+
+       private function createUsers( $specs ) {
+               $dbw = wfGetDB( DB_MASTER );
+               foreach ( $specs as $name => $spec ) {
+                       User::createNew(
+                               $name,
+                               [
+                                       'editcount' => $spec['edits'],
+                                       'registration' => $dbw->timestamp( $this->daysAgo( $spec['days'] ) ),
+                                       'email' => 'ut',
+                               ]
+                       );
+               }
+       }
+
+       private function fetchUsers( $filters ) {
+               $tables = [];
+               $conds = [];
+               $fields = [];
+               $query_options = [];
+               $join_conds = [];
+
+               sort( $filters );
+
+               call_user_func_array(
+                       [ $this->changesListSpecialPage, 'filterOnUserExperienceLevel' ],
+                       [
+                               get_class( $this->changesListSpecialPage ),
+                               $this->changesListSpecialPage->getContext(),
+                               $this->changesListSpecialPage->getDB(),
+                               &$tables,
+                               &$fields,
+                               &$conds,
+                               &$query_options,
+                               &$join_conds,
+                               $filters
+                       ]
+               );
+
+               $result = wfGetDB( DB_MASTER )->select(
+                       'user',
+                       'user_name',
+                       array_filter( $conds ) + [ 'user_email' => 'ut' ]
+               );
+
+               $usernames = [];
+               foreach ( $result as $row ) {
+                       $usernames[] = $row->user_name;
+               }
+
+               return $usernames;
+       }
+
+       private function daysAgo( $days ) {
+               $secondsPerDay = 86400;
+               return time() - $days * $secondsPerDay;
+       }
+
+       public function testGetFilterGroupDefinitionFromLegacyCustomFilters() {
+               $customFilters = [
+                       'hidefoo' => [
+                               'msg' => 'showhidefoo',
+                               'default' => true,
+                       ],
+
+                       'hidebar' => [
+                               'msg' => 'showhidebar',
+                               'default' => false,
+                       ],
+               ];
+
+               $this->assertEquals(
+                       [
+                               'name' => 'unstructured',
+                               'class' => ChangesListBooleanFilterGroup::class,
+                               'priority' => -1,
+                               'filters' => [
+                                       [
+                                               'name' => 'hidefoo',
+                                               'showHide' => 'showhidefoo',
+                                               'default' => true,
+                                       ],
+                                       [
+                                               'name' => 'hidebar',
+                                               'showHide' => 'showhidebar',
+                                               'default' => false,
+                                       ]
+                               ],
+                       ],
+                       $this->changesListSpecialPage->getFilterGroupDefinitionFromLegacyCustomFilters(
+                               $customFilters
+                       )
+               );
+       }
+
+       public function testGetStructuredFilterJsData() {
+               $definition = [
+                       [
+                               'name' => 'gub-group',
+                               'title' => 'gub-group-title',
+                               'class' => ChangesListBooleanFilterGroup::class,
+                               'filters' => [
+                                       [
+                                               'name' => 'hidefoo',
+                                               'label' => 'foo-label',
+                                               'description' => 'foo-description',
+                                               'default' => true,
+                                               'showHide' => 'showhidefoo',
+                                               'priority' => 2,
+                                       ],
+                                       [
+                                               'name' => 'hidebar',
+                                               'label' => 'bar-label',
+                                               'description' => 'bar-description',
+                                               'default' => false,
+                                               'priority' => 4,
+                                       ]
+                               ],
+                       ],
+
+                       [
+                               'name' => 'des-group',
+                               'title' => 'des-group-title',
+                               'class' => ChangesListStringOptionsFilterGroup::class,
+                               'isFullCoverage' => true,
+                               'filters' => [
+                                       [
+                                               'name' => 'grault',
+                                               'label' => 'grault-label',
+                                               'description' => 'grault-description',
+                                       ],
+                                       [
+                                               'name' => 'garply',
+                                               'label' => 'garply-label',
+                                               'description' => 'garply-description',
+                                       ],
+                               ],
+                               'queryCallable' => function () {
+                               },
+                               'default' => ChangesListStringOptionsFilterGroup::NONE,
+                       ],
+
+                       [
+                               'name' => 'unstructured',
+                               'class' => ChangesListBooleanFilterGroup::class,
+                               'filters' => [
+                                       [
+                                               'name' => 'hidethud',
+                                               'showHide' => 'showhidethud',
+                                               'default' => true,
+                                       ],
+
+                                       [
+                                               'name' => 'hidemos',
+                                               'showHide' => 'showhidemos',
+                                               'default' => false,
+                                       ],
+                               ],
+                       ],
+
+               ];
+
+               $this->changesListSpecialPage->registerFiltersFromDefinitions( $definition );
+
+               $this->assertArrayEquals(
+                       [
+                               // Filters that only display in the unstructured UI are
+                               // are not included, and neither are groups that would
+                               // be empty due to the above.
+                               'groups' => [
+                                       [
+                                               'name' => 'gub-group',
+                                               'title' => 'gub-group-title',
+                                               'type' => ChangesListBooleanFilterGroup::TYPE,
+                                               'priority' => -1,
+                                               'filters' => [
+                                                       [
+                                                               'name' => 'hidebar',
+                                                               'label' => 'bar-label',
+                                                               'description' => 'bar-description',
+                                                               'default' => false,
+                                                               'priority' => 4,
+                                                               'cssClass' => null,
+                                                               'conflicts' => [],
+                                                               'subset' => [],
+                                                       ],
+                                                       [
+                                                               'name' => 'hidefoo',
+                                                               'label' => 'foo-label',
+                                                               'description' => 'foo-description',
+                                                               'default' => true,
+                                                               'priority' => 2,
+                                                               'cssClass' => null,
+                                                               'conflicts' => [],
+                                                               'subset' => [],
+                                                       ],
+                                               ],
+                                               'fullCoverage' => true,
+                                               'conflicts' => [],
+                                       ],
+
+                                       [
+                                               'name' => 'des-group',
+                                               'title' => 'des-group-title',
+                                               'type' => ChangesListStringOptionsFilterGroup::TYPE,
+                                               'priority' => -2,
+                                               'fullCoverage' => true,
+                                               'filters' => [
+                                                       [
+                                                               'name' => 'grault',
+                                                               'label' => 'grault-label',
+                                                               'description' => 'grault-description',
+                                                               'cssClass' => null,
+                                                               'priority' => -2,
+                                                               'conflicts' => [],
+                                                               'subset' => [],
+                                                       ],
+                                                       [
+                                                               'name' => 'garply',
+                                                               'label' => 'garply-label',
+                                                               'description' => 'garply-description',
+                                                               'cssClass' => null,
+                                                               'priority' => -3,
+                                                               'conflicts' => [],
+                                                               'subset' => [],
+                                                       ],
+                                               ],
+                                               'conflicts' => [],
+                                               'separator' => ';',
+                                               'default' => ChangesListStringOptionsFilterGroup::NONE,
+                                       ],
+                               ],
+                               'messageKeys' => [
+                                       'gub-group-title',
+                                       'bar-label',
+                                       'bar-description',
+                                       'foo-label',
+                                       'foo-description',
+                                       'des-group-title',
+                                       'grault-label',
+                                       'grault-description',
+                                       'garply-label',
+                                       'garply-description',
+                               ],
+                       ],
+                       $this->changesListSpecialPage->getStructuredFilterJsData(),
+                       /** ordered= */ false,
+                       /** named= */ true
+               );
+       }
+
+       public function provideParseParameters() {
+               return [
+                       [ 'hidebots', [ 'hidebots' => true ] ],
+
+                       [ 'bots', [ 'hidebots' => false ] ],
+
+                       [ 'hideminor', [ 'hideminor' => true ] ],
+
+                       [ 'minor', [ 'hideminor' => false ] ],
+
+                       [ 'hidemajor', [ 'hidemajor' => true ] ],
+
+                       [ 'hideliu', [ 'hideliu' => true ] ],
+
+                       [ 'hidepatrolled', [ 'hidepatrolled' => true ] ],
+
+                       [ 'hideunpatrolled', [ 'hideunpatrolled' => true ] ],
+
+                       [ 'hideanons', [ 'hideanons' => true ] ],
+
+                       [ 'hidemyself', [ 'hidemyself' => true ] ],
+
+                       [ 'hidebyothers', [ 'hidebyothers' => true ] ],
+
+                       [ 'hidehumans', [ 'hidehumans' => true ] ],
+
+                       [ 'hidepageedits', [ 'hidepageedits' => true ] ],
+
+                       [ 'pagedits', [ 'hidepageedits' => false ] ],
+
+                       [ 'hidenewpages', [ 'hidenewpages' => true ] ],
+
+                       [ 'hidecategorization', [ 'hidecategorization' => true ] ],
+
+                       [ 'hidelog', [ 'hidelog' => true ] ],
+
+                       [
+                               'userExpLevel=learner;experienced',
+                               [
+                                       'userExpLevel' => 'learner;experienced'
+                               ],
+                       ],
+
+                       // A few random combos
+                       [
+                               'bots,hideliu,hidemyself',
+                               [
+                                       'hidebots' => false,
+                                       'hideliu' => true,
+                                       'hidemyself' => true,
+                               ],
+                       ],
+
+                       [
+                               'minor,hideanons,categorization',
+                               [
+                                       'hideminor' => false,
+                                       'hideanons' => true,
+                                       'hidecategorization' => false,
+                               ]
+                       ],
+
+                       [
+                               'hidehumans,bots,hidecategorization',
+                               [
+                                       'hidehumans' => true,
+                                       'hidebots' => false,
+                                       'hidecategorization' => true,
+                               ],
+                       ],
+
+                       [
+                               'hidemyself,userExpLevel=newcomer;learner,hideminor',
+                               [
+                                       'hidemyself' => true,
+                                       'hideminor' => true,
+                                       'userExpLevel' => 'newcomer;learner',
+                               ],
+                       ],
+               ];
+       }
+}
index 9b0fb6a..011d8a0 100644 (file)
 <?php
+
 /**
  * Test class for SpecialRecentchanges class
  *
- * Copyright Â© 2011, Antoine Musso
- *
- * @author Antoine Musso
  * @group Database
  *
  * @covers SpecialRecentChanges
  */
-class SpecialRecentchangesTest extends MediaWikiTestCase {
-
+class SpecialRecentchangesTest extends AbstractChangesListSpecialPageTestCase {
        protected function setUp() {
                parent::setUp();
-               $this->setMwGlobals( 'wgRCWatchCategoryMembership', true );
-       }
-
-       /**
-        * @var SpecialRecentChanges
-        */
-       protected $rc;
-
-       /** helper to test SpecialRecentchanges::buildMainQueryConds() */
-       private function assertConditions(
-               $expected,
-               $requestOptions = null,
-               $message = '',
-               $user = null
-       ) {
-               $context = new RequestContext;
-               $context->setRequest( new FauxRequest( $requestOptions ) );
-               if ( $user ) {
-                       $context->setUser( $user );
-               }
-
-               # setup the rc object
-               $this->rc = new SpecialRecentChanges();
-               $this->rc->setContext( $context );
-               $formOptions = $this->rc->setup( null );
 
-               # Â Filter out rc_timestamp conditions which depends on the test runtime
-               # This condition is not needed as of march 2, 2011 -- hashar
-               # @todo FIXME: Find a way to generate the correct rc_timestamp
-               $queryConditions = array_filter(
-                       $this->rc->buildMainQueryConds( $formOptions ),
-                       'SpecialRecentchangesTest::filterOutRcTimestampCondition'
-               );
-
-               $this->assertEquals(
-                       self::normalizeCondition( $expected ),
-                       self::normalizeCondition( $queryConditions ),
-                       $message
+               # setup the CLSP object
+               $this->changesListSpecialPage = TestingAccessWrapper::newFromObject(
+                       new SpecialRecentchanges
                );
        }
 
-       private static function normalizeCondition( $conds ) {
-               $normalized = array_map(
-                       function ( $k, $v ) {
-                               return is_numeric( $k ) ? $v : "$k = $v";
-                       },
-                       array_keys( $conds ),
-                       $conds
-               );
-               sort( $normalized );
-               return $normalized;
-       }
+       public function provideParseParameters() {
+               return [
+                       [ 'limit=123', [ 'limit' => '123' ] ],
 
-       /** return false if condition begin with 'rc_timestamp ' */
-       private static function filterOutRcTimestampCondition( $var ) {
-               return ( false === strpos( $var, 'rc_timestamp ' ) );
-       }
+                       [ '234', [ 'limit' => '234' ] ],
 
-       public function testRcNsFilter() {
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_type != '6'",
-                               "rc_namespace = '0'",
-                       ],
-                       [
-                               'namespace' => NS_MAIN,
-                       ],
-                       "rc conditions with no options (aka default setting)"
-               );
-       }
-
-       public function testRcNsFilterInversion() {
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_type != '6'",
-                               "rc_namespace != '0'",
-                       ],
-                       [
-                               'namespace' => NS_MAIN,
-                               'invert' => 1,
-                       ],
-                       "rc conditions with namespace inverted"
-               );
-       }
-
-       /**
-        * T4429
-        * @dataProvider provideNamespacesAssociations
-        */
-       public function testRcNsFilterAssociation( $ns1, $ns2 ) {
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_type != '6'",
-                               "(rc_namespace = '$ns1' OR rc_namespace = '$ns2')",
-                       ],
-                       [
-                               'namespace' => $ns1,
-                               'associated' => 1,
-                       ],
-                       "rc conditions with namespace inverted"
-               );
-       }
+                       [ 'days=3', [ 'days' => '3' ] ],
 
-       /**
-        * T4429
-        * @dataProvider provideNamespacesAssociations
-        */
-       public function testRcNsFilterAssociationWithInversion( $ns1, $ns2 ) {
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_type != '6'",
-                               "(rc_namespace != '$ns1' AND rc_namespace != '$ns2')",
-                       ],
-                       [
-                               'namespace' => $ns1,
-                               'associated' => 1,
-                               'invert' => 1,
-                       ],
-                       "rc conditions with namespace inverted"
-               );
-       }
+                       [ 'namespace=5', [ 'namespace' => 5 ] ],
 
-       /**
-        * Provides associated namespaces to test recent changes
-        * namespaces association filtering.
-        */
-       public static function provideNamespacesAssociations() {
-               return [ # (NS => Associated_NS)
-                       [ NS_MAIN, NS_TALK ],
-                       [ NS_TALK, NS_MAIN ],
+                       [ 'tagfilter=foo', [ 'tagfilter' => 'foo' ] ],
                ];
        }
-
-       public function testRcHidemyselfFilter() {
-               $user = $this->getTestUser()->getUser();
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_user != '{$user->getId()}'",
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hidemyself' => 1,
-                       ],
-                       "rc conditions: hidemyself=1 (logged in)",
-                       $user
-               );
-
-               $user = User::newFromName( '10.11.12.13', false );
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_user_text != '10.11.12.13'",
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hidemyself' => 1,
-                       ],
-                       "rc conditions: hidemyself=1 (anon)",
-                       $user
-               );
-       }
-
-       public function testRcHidebyothersFilter() {
-               $user = $this->getTestUser()->getUser();
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_user = '{$user->getId()}'",
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hidebyothers' => 1,
-                       ],
-                       "rc conditions: hidebyothers=1 (logged in)",
-                       $user
-               );
-
-               $user = User::newFromName( '10.11.12.13', false );
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_user_text = '10.11.12.13'",
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hidebyothers' => 1,
-                       ],
-                       "rc conditions: hidebyothers=1 (anon)",
-                       $user
-               );
-       }
-
-       public function testRcHidemyselfHidebyothersFilter() {
-               $user = $this->getTestUser()->getUser();
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_user != '{$user->getId()}'",
-                               "rc_user = '{$user->getId()}'",
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hidemyself' => 1,
-                               'hidebyothers' => 1,
-                       ],
-                       "rc conditions: hidemyself=1 hidebyothers=1 (logged in)",
-                       $user
-               );
-       }
-
-       public function testRcHidepageedits() {
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_type != '6'",
-                               "rc_type != '0'",
-                       ],
-                       [
-                               'hidepageedits' => 1,
-                       ],
-                       "rc conditions: hidepageedits=1"
-               );
-       }
-
-       public function testRcHidenewpages() {
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_type != '6'",
-                               "rc_type != '1'",
-                       ],
-                       [
-                               'hidenewpages' => 1,
-                       ],
-                       "rc conditions: hidenewpages=1"
-               );
-       }
-
-       public function testRcHidelog() {
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_type != '6'",
-                               "rc_type != '3'",
-                       ],
-                       [
-                               'hidelog' => 1,
-                       ],
-                       "rc conditions: hidelog=1"
-               );
-       }
-
-       public function testRcHidehumans() {
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 1,
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hidebots' => 0,
-                               'hidehumans' => 1,
-                       ],
-                       "rc conditions: hidebots=0 hidehumans=1"
-               );
-       }
-
-       public function testRcHidepatrolledDisabledFilter() {
-               $user = $this->getTestUser()->getUser();
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hidepatrolled' => 1,
-                       ],
-                       "rc conditions: hidepatrolled=1 (user not allowed)",
-                       $user
-               );
-       }
-
-       public function testRcHideunpatrolledDisabledFilter() {
-               $user = $this->getTestUser()->getUser();
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hideunpatrolled' => 1,
-                       ],
-                       "rc conditions: hideunpatrolled=1 (user not allowed)",
-                       $user
-               );
-       }
-       public function testRcHidepatrolledFilter() {
-               $user = $this->getTestSysop()->getUser();
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_patrolled = 0",
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hidepatrolled' => 1,
-                       ],
-                       "rc conditions: hidepatrolled=1",
-                       $user
-               );
-       }
-
-       public function testRcHideunpatrolledFilter() {
-               $user = $this->getTestSysop()->getUser();
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_patrolled = 1",
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hideunpatrolled' => 1,
-                       ],
-                       "rc conditions: hideunpatrolled=1",
-                       $user
-               );
-       }
-
-       public function testRcHideminorFilter() {
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_minor = 0",
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hideminor' => 1,
-                       ],
-                       "rc conditions: hideminor=1"
-               );
-       }
-
-       public function testRcHidemajorFilter() {
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_minor = 1",
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hidemajor' => 1,
-                       ],
-                       "rc conditions: hidemajor=1"
-               );
-       }
-
-       // This is probably going to change when we do auto-fix of
-       // filters combinations that don't make sense but for now
-       // it's the behavior therefore it's the test.
-       public function testRcHidepatrolledHideunpatrolledFilter() {
-               $user = $this->getTestSysop()->getUser();
-               $this->assertConditions(
-                       [ # expected
-                               'rc_bot' => 0,
-                               "rc_patrolled = 0",
-                               "rc_patrolled = 1",
-                               "rc_type != '6'",
-                       ],
-                       [
-                               'hidepatrolled' => 1,
-                               'hideunpatrolled' => 1,
-                       ],
-                       "rc conditions: hidepatrolled=1 hideunpatrolled=1",
-                       $user
-               );
-       }
-
-       public function testFilterUserExpLevel() {
-               $this->setMwGlobals( [
-                       'wgLearnerEdits' => 10,
-                       'wgLearnerMemberSince' => 4,
-                       'wgExperiencedUserEdits' => 500,
-                       'wgExperiencedUserMemberSince' => 30,
-               ] );
-
-               $this->createUsers( [
-                       'Newcomer1' => [ 'edits' => 2, 'days' => 2 ],
-                       'Newcomer2' => [ 'edits' => 12, 'days' => 3 ],
-                       'Newcomer3' => [ 'edits' => 8, 'days' => 5 ],
-                       'Learner1' => [ 'edits' => 15, 'days' => 10 ],
-                       'Learner2' => [ 'edits' => 450, 'days' => 20 ],
-                       'Learner3' => [ 'edits' => 460, 'days' => 33 ],
-                       'Learner4' => [ 'edits' => 525, 'days' => 28 ],
-                       'Experienced1' => [ 'edits' => 538, 'days' => 33 ],
-               ] );
-
-               // newcomers only
-               $this->assertArrayEquals(
-                       [ 'Newcomer1', 'Newcomer2', 'Newcomer3' ],
-                       $this->fetchUsers( [ 'userExpLevel' => 'newcomer' ] )
-               );
-
-               // newcomers and learner
-               $this->assertArrayEquals(
-                       [
-                               'Newcomer1', 'Newcomer2', 'Newcomer3',
-                               'Learner1', 'Learner2', 'Learner3', 'Learner4',
-                       ],
-                       $this->fetchUsers( [ 'userExpLevel' => 'newcomer,learner' ] )
-               );
-
-               // newcomers and more learner
-               $this->assertArrayEquals(
-                       [
-                               'Newcomer1', 'Newcomer2', 'Newcomer3',
-                               'Experienced1',
-                       ],
-                       $this->fetchUsers( [ 'userExpLevel' => 'newcomer,experienced' ] )
-               );
-
-               // learner only
-               $this->assertArrayEquals(
-                       [ 'Learner1', 'Learner2', 'Learner3', 'Learner4' ],
-                       $this->fetchUsers( [ 'userExpLevel' => 'learner' ] )
-               );
-
-               // more experienced only
-               $this->assertArrayEquals(
-                       [ 'Experienced1' ],
-                       $this->fetchUsers( [ 'userExpLevel' => 'experienced' ] )
-               );
-
-               // learner and more experienced
-               $this->assertArrayEquals(
-                       [
-                               'Learner1', 'Learner2', 'Learner3', 'Learner4',
-                               'Experienced1',
-                       ],
-                       $this->fetchUsers( [ 'userExpLevel' => 'learner,experienced' ] )
-               );
-
-               // newcomers, learner, and more experienced
-               $this->assertArrayEquals(
-                       [
-                               'Newcomer1', 'Newcomer2', 'Newcomer3',
-                               'Learner1', 'Learner2', 'Learner3', 'Learner4',
-                               'Experienced1',
-                       ],
-                       $this->fetchUsers( [ 'userExpLevel' => 'newcomer,learner,experienced' ] )
-               );
-
-               // 'all'
-               $this->assertArrayEquals(
-                       [
-                               'Newcomer1', 'Newcomer2', 'Newcomer3',
-                               'Learner1', 'Learner2', 'Learner3', 'Learner4',
-                               'Experienced1',
-                       ],
-                       $this->fetchUsers( [ 'userExpLevel' => 'all' ] )
-               );
-       }
-
-       private function createUsers( $specs ) {
-               $dbw = wfGetDB( DB_MASTER );
-               foreach ( $specs as $name => $spec ) {
-                       User::createNew(
-                               $name,
-                               [
-                                       'editcount' => $spec['edits'],
-                                       'registration' => $dbw->timestamp( $this->daysAgo( $spec['days'] ) ),
-                                       'email' => 'ut',
-                               ]
-                       );
-               }
-       }
-
-       private function fetchUsers( $filters ) {
-               $specialRC = new SpecialRecentChanges();
-
-               $tables = [];
-               $conds = [];
-               $join_conds = [];
-
-               $specialRC->filterOnUserExperienceLevel(
-                       $tables,
-                       $conds,
-                       $join_conds,
-                       $filters
-               );
-
-               $result = wfGetDB( DB_MASTER )->select(
-                       'user',
-                       'user_name',
-                       array_filter( $conds ) + [ 'user_email' => 'ut' ]
-               );
-
-               $usernames = [];
-               foreach ( $result as $row ) {
-                       $usernames[] = $row->user_name;
-               }
-
-               return $usernames;
-       }
-
-       private function daysAgo( $days ) {
-               $secondsPerDay = 86400;
-               return time() - $days * $secondsPerDay;
-       }
 }
index ad0ed54..52ba360 100644 (file)
@@ -1,57 +1,67 @@
 ( function ( mw, $ ) {
-       QUnit.module( 'mediawiki.rcfilters - FiltersViewModel' );
+       QUnit.module( 'mediawiki.rcfilters - FiltersViewModel', QUnit.newMwEnvironment( {
+               messages: {
+                       'group1filter1-label': 'Group 1: Filter 1',
+                       'group1filter1-desc': 'Description of Filter 1 in Group 1',
+                       'group1filter2-label': 'Group 1: Filter 2',
+                       'group1filter2-desc': 'Description of Filter 2 in Group 1',
+                       'group2filter1-label': 'Group 2: Filter 1',
+                       'group2filter1-desc': 'Description of Filter 1 in Group 2',
+                       'group2filter2-label': 'xGroup 2: Filter 2',
+                       'group2filter2-desc': 'Description of Filter 2 in Group 2'
+               }
+       } ) );
 
        QUnit.test( 'Setting up filters', function ( assert ) {
-               var definition = {
-                               group1: {
-                                       title: 'Group 1',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'group1filter1',
-                                                       label: 'Group 1: Filter 1',
-                                                       description: 'Description of Filter 1 in Group 1'
-                                               },
-                                               {
-                                                       name: 'group1filter2',
-                                                       label: 'Group 1: Filter 2',
-                                                       description: 'Description of Filter 2 in Group 1'
-                                               }
-                                       ]
-                               },
-                               group2: {
-                                       title: 'Group 2',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'group2filter1',
-                                                       label: 'Group 2: Filter 1',
-                                                       description: 'Description of Filter 1 in Group 2'
-                                               },
-                                               {
-                                                       name: 'group2filter2',
-                                                       label: 'Group 2: Filter 2',
-                                                       description: 'Description of Filter 2 in Group 2'
-                                               }
-                                       ]
-                               },
-                               group3: {
-                                       title: 'Group 3',
-                                       type: 'string_options',
-                                       filters: [
-                                               {
-                                                       name: 'group3filter1',
-                                                       label: 'Group 3: Filter 1',
-                                                       description: 'Description of Filter 1 in Group 3'
-                                               },
-                                               {
-                                                       name: 'group3filter2',
-                                                       label: 'Group 3: Filter 2',
-                                                       description: 'Description of Filter 2 in Group 3'
-                                               }
-                                       ]
-                               }
-                       },
+               var definition = [ {
+                               name: 'group1',
+                               title: 'Group 1',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'group1filter1',
+                                               label: 'Group 1: Filter 1',
+                                               description: 'Description of Filter 1 in Group 1'
+                                       },
+                                       {
+                                               name: 'group1filter2',
+                                               label: 'Group 1: Filter 2',
+                                               description: 'Description of Filter 2 in Group 1'
+                                       }
+                               ]
+                       }, {
+                               name: 'group2',
+                               title: 'Group 2',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'group2filter1',
+                                               label: 'Group 2: Filter 1',
+                                               description: 'Description of Filter 1 in Group 2'
+                                       },
+                                       {
+                                               name: 'group2filter2',
+                                               label: 'Group 2: Filter 2',
+                                               description: 'Description of Filter 2 in Group 2'
+                                       }
+                               ]
+                       }, {
+                               name: 'group3',
+                               title: 'Group 3',
+                               type: 'string_options',
+                               filters: [
+                                       {
+                                               name: 'group3filter1',
+                                               label: 'Group 3: Filter 1',
+                                               description: 'Description of Filter 1 in Group 3'
+                                       },
+                                       {
+                                               name: 'group3filter2',
+                                               label: 'Group 3: Filter 2',
+                                               description: 'Description of Filter 2 in Group 3'
+                                       }
+                               ]
+                       } ],
                        model = new mw.rcfilters.dm.FiltersViewModel();
 
                model.initializeFilters( definition );
 
        QUnit.test( 'Finding matching filters', function ( assert ) {
                var matches,
-                       definition = {
-                               group1: {
-                                       title: 'Group 1 title',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'group1filter1',
-                                                       label: 'Group 1: Filter 1',
-                                                       description: 'Description of Filter 1 in Group 1'
-                                               },
-                                               {
-                                                       name: 'group1filter2',
-                                                       label: 'Group 1: Filter 2',
-                                                       description: 'Description of Filter 2 in Group 1'
-                                               }
-                                       ]
-                               },
-                               group2: {
-                                       title: 'Group 2 title',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'group2filter1',
-                                                       label: 'Group 2: Filter 1',
-                                                       description: 'Description of Filter 1 in Group 2'
-                                               },
-                                               {
-                                                       name: 'group2filter2',
-                                                       label: 'xGroup 2: Filter 2',
-                                                       description: 'Description of Filter 2 in Group 2'
-                                               }
-                                       ]
-                               }
-                       },
+                       definition = [ {
+                               name: 'group1',
+                               title: 'Group 1 title',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'group1filter1',
+                                               label: 'group1filter1-label',
+                                               description: 'group1filter1-desc'
+                                       },
+                                       {
+                                               name: 'group1filter2',
+                                               label: 'group1filter2-label',
+                                               description: 'group1filter2-desc'
+                                       }
+                               ]
+                       }, {
+                               name: 'group2',
+                               title: 'Group 2 title',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'group2filter1',
+                                               label: 'group2filter1-label',
+                                               description: 'group2filter1-desc'
+                                       },
+                                       {
+                                               name: 'group2filter2',
+                                               label: 'group2filter2-label',
+                                               description: 'group2filter2-desc'
+                                       }
+                               ]
+                       } ],
                        testCases = [
                                {
                                        query: 'group',
        } );
 
        QUnit.test( 'getParametersFromFilters', function ( assert ) {
-               var definition = {
-                               group1: {
-                                       title: 'Group 1',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'hidefilter1',
-                                                       label: 'Group 1: Filter 1',
-                                                       description: 'Description of Filter 1 in Group 1'
-                                               },
-                                               {
-                                                       name: 'hidefilter2',
-                                                       label: 'Group 1: Filter 2',
-                                                       description: 'Description of Filter 2 in Group 1'
-                                               },
-                                               {
-                                                       name: 'hidefilter3',
-                                                       label: 'Group 1: Filter 3',
-                                                       description: 'Description of Filter 3 in Group 1'
-                                               }
-                                       ]
-                               },
-                               group2: {
-                                       title: 'Group 2',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'hidefilter4',
-                                                       label: 'Group 2: Filter 1',
-                                                       description: 'Description of Filter 1 in Group 2'
-                                               },
-                                               {
-                                                       name: 'hidefilter5',
-                                                       label: 'Group 2: Filter 2',
-                                                       description: 'Description of Filter 2 in Group 2'
-                                               },
-                                               {
-                                                       name: 'hidefilter6',
-                                                       label: 'Group 2: Filter 3',
-                                                       description: 'Description of Filter 3 in Group 2'
-                                               }
-                                       ]
-                               },
-                               group3: {
-                                       title: 'Group 3',
-                                       type: 'string_options',
-                                       separator: ',',
-                                       filters: [
-                                               {
-                                                       name: 'filter7',
-                                                       label: 'Group 3: Filter 1',
-                                                       description: 'Description of Filter 1 in Group 3'
-                                               },
-                                               {
-                                                       name: 'filter8',
-                                                       label: 'Group 3: Filter 2',
-                                                       description: 'Description of Filter 2 in Group 3'
-                                               },
-                                               {
-                                                       name: 'filter9',
-                                                       label: 'Group 3: Filter 3',
-                                                       description: 'Description of Filter 3 in Group 3'
-                                               }
-                                       ]
-                               }
-                       },
+               var definition = [ {
+                               name: 'group1',
+                               title: 'Group 1',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'hidefilter1',
+                                               label: 'Group 1: Filter 1',
+                                               description: 'Description of Filter 1 in Group 1'
+                                       },
+                                       {
+                                               name: 'hidefilter2',
+                                               label: 'Group 1: Filter 2',
+                                               description: 'Description of Filter 2 in Group 1'
+                                       },
+                                       {
+                                               name: 'hidefilter3',
+                                               label: 'Group 1: Filter 3',
+                                               description: 'Description of Filter 3 in Group 1'
+                                       }
+                               ]
+                       }, {
+                               name: 'group2',
+                               title: 'Group 2',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'hidefilter4',
+                                               label: 'Group 2: Filter 1',
+                                               description: 'Description of Filter 1 in Group 2'
+                                       },
+                                       {
+                                               name: 'hidefilter5',
+                                               label: 'Group 2: Filter 2',
+                                               description: 'Description of Filter 2 in Group 2'
+                                       },
+                                       {
+                                               name: 'hidefilter6',
+                                               label: 'Group 2: Filter 3',
+                                               description: 'Description of Filter 3 in Group 2'
+                                       }
+                               ]
+                       }, {
+                               name: 'group3',
+                               title: 'Group 3',
+                               type: 'string_options',
+                               separator: ',',
+                               filters: [
+                                       {
+                                               name: 'filter7',
+                                               label: 'Group 3: Filter 1',
+                                               description: 'Description of Filter 1 in Group 3'
+                                       },
+                                       {
+                                               name: 'filter8',
+                                               label: 'Group 3: Filter 2',
+                                               description: 'Description of Filter 2 in Group 3'
+                                       },
+                                       {
+                                               name: 'filter9',
+                                               label: 'Group 3: Filter 3',
+                                               description: 'Description of Filter 3 in Group 3'
+                                       }
+                               ]
+                       } ],
                        model = new mw.rcfilters.dm.FiltersViewModel();
 
                model.initializeFilters( definition );
                                hidefilter4: 0,
                                hidefilter5: 0,
                                hidefilter6: 0,
-                               group3: 'all'
+                               group3: ''
                        },
-                       'Unselected filters return all parameters falsey or \'all\'.'
+                       'Unselected filters return all parameters falsey or \'\'.'
                );
 
                // Select 1 filter
                                hidefilter4: 0,
                                hidefilter5: 0,
                                hidefilter6: 0,
-                               group3: 'all'
+                               group3: ''
                        },
                        'One filters in one "send_unselected_if_any" group returns the other parameters truthy.'
                );
                                hidefilter4: 0,
                                hidefilter5: 0,
                                hidefilter6: 0,
-                               group3: 'all'
+                               group3: ''
                        },
                        'One filters in one "send_unselected_if_any" group returns the other parameters truthy.'
                );
                                hidefilter4: 0,
                                hidefilter5: 0,
                                hidefilter6: 0,
-                               group3: 'all'
+                               group3: ''
                        },
                        'All filters selected in one "send_unselected_if_any" group returns all parameters falsy.'
                );
        } );
 
        QUnit.test( 'getFiltersFromParameters', function ( assert ) {
-               var definition = {
-                               group1: {
-                                       title: 'Group 1',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'hidefilter1',
-                                                       label: 'Show filter 1',
-                                                       description: 'Description of Filter 1 in Group 1',
-                                                       default: true
-                                               },
-                                               {
-                                                       name: 'hidefilter2',
-                                                       label: 'Show filter 2',
-                                                       description: 'Description of Filter 2 in Group 1'
-                                               },
-                                               {
-                                                       name: 'hidefilter3',
-                                                       label: 'Show filter 3',
-                                                       description: 'Description of Filter 3 in Group 1',
-                                                       default: true
-                                               }
-                                       ]
-                               },
-                               group2: {
-                                       title: 'Group 2',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'hidefilter4',
-                                                       label: 'Show filter 4',
-                                                       description: 'Description of Filter 1 in Group 2'
-                                               },
-                                               {
-                                                       name: 'hidefilter5',
-                                                       label: 'Show filter 5',
-                                                       description: 'Description of Filter 2 in Group 2',
-                                                       default: true
-                                               },
-                                               {
-                                                       name: 'hidefilter6',
-                                                       label: 'Show filter 6',
-                                                       description: 'Description of Filter 3 in Group 2'
-                                               }
-                                       ]
-                               },
-                               group3: {
-                                       title: 'Group 3',
-                                       type: 'string_options',
-                                       separator: ',',
-                                       filters: [
-                                               {
-                                                       name: 'filter7',
-                                                       label: 'Group 3: Filter 1',
-                                                       description: 'Description of Filter 1 in Group 3'
-                                               },
-                                               {
-                                                       name: 'filter8',
-                                                       label: 'Group 3: Filter 2',
-                                                       description: 'Description of Filter 2 in Group 3',
-                                                       default: true
-                                               },
-                                               {
-                                                       name: 'filter9',
-                                                       label: 'Group 3: Filter 3',
-                                                       description: 'Description of Filter 3 in Group 3'
-                                               }
-                                       ]
-                               }
-                       },
+               var definition = {
+                               name: 'group1',
+                               title: 'Group 1',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'hidefilter1',
+                                               label: 'Show filter 1',
+                                               description: 'Description of Filter 1 in Group 1',
+                                               default: true
+                                       },
+                                       {
+                                               name: 'hidefilter2',
+                                               label: 'Show filter 2',
+                                               description: 'Description of Filter 2 in Group 1'
+                                       },
+                                       {
+                                               name: 'hidefilter3',
+                                               label: 'Show filter 3',
+                                               description: 'Description of Filter 3 in Group 1',
+                                               default: true
+                                       }
+                               ]
+                       }, {
+                               name: 'group2',
+                               title: 'Group 2',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'hidefilter4',
+                                               label: 'Show filter 4',
+                                               description: 'Description of Filter 1 in Group 2'
+                                       },
+                                       {
+                                               name: 'hidefilter5',
+                                               label: 'Show filter 5',
+                                               description: 'Description of Filter 2 in Group 2',
+                                               default: true
+                                       },
+                                       {
+                                               name: 'hidefilter6',
+                                               label: 'Show filter 6',
+                                               description: 'Description of Filter 3 in Group 2'
+                                       }
+                               ]
+                       }, {
+
+                               name: 'group3',
+                               title: 'Group 3',
+                               type: 'string_options',
+                               separator: ',',
+                               filters: [
+                                       {
+                                               name: 'filter7',
+                                               label: 'Group 3: Filter 1',
+                                               description: 'Description of Filter 1 in Group 3'
+                                       },
+                                       {
+                                               name: 'filter8',
+                                               label: 'Group 3: Filter 2',
+                                               description: 'Description of Filter 2 in Group 3',
+                                               default: true
+                                       },
+                                       {
+                                               name: 'filter9',
+                                               label: 'Group 3: Filter 3',
+                                               description: 'Description of Filter 3 in Group 3'
+                                       }
+                               ]
+                       } ],
                        defaultFilterRepresentation = {
                                // Group 1 and 2, "send_unselected_if_any", the values of the filters are "flipped" from the values of the parameters
                                hidefilter1: false,
        } );
 
        QUnit.test( 'sanitizeStringOptionGroup', function ( assert ) {
-               var definition = {
-                               group1: {
-                                       title: 'Group 1',
-                                       type: 'string_options',
-                                       filters: [
-                                               {
-                                                       name: 'filter1',
-                                                       label: 'Show filter 1',
-                                                       description: 'Description of Filter 1 in Group 1'
-                                               },
-                                               {
-                                                       name: 'filter2',
-                                                       label: 'Show filter 2',
-                                                       description: 'Description of Filter 2 in Group 1'
-                                               },
-                                               {
-                                                       name: 'filter3',
-                                                       label: 'Show filter 3',
-                                                       description: 'Description of Filter 3 in Group 1'
-                                               }
-                                       ]
-                               }
-                       },
+               var definition = [ {
+                               name: 'group1',
+                               title: 'Group 1',
+                               type: 'string_options',
+                               filters: [
+                                       {
+                                               name: 'filter1',
+                                               label: 'Show filter 1',
+                                               description: 'Description of Filter 1 in Group 1'
+                                       },
+                                       {
+                                               name: 'filter2',
+                                               label: 'Show filter 2',
+                                               description: 'Description of Filter 2 in Group 1'
+                                       },
+                                       {
+                                               name: 'filter3',
+                                               label: 'Show filter 3',
+                                               description: 'Description of Filter 3 in Group 1'
+                                       }
+                               ]
+                       } ],
                        model = new mw.rcfilters.dm.FiltersViewModel();
 
                model.initializeFilters( definition );
        } );
 
        QUnit.test( 'setFiltersToDefaults', function ( assert ) {
-               var definition = {
-                               group1: {
-                                       title: 'Group 1',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'hidefilter1',
-                                                       label: 'Show filter 1',
-                                                       description: 'Description of Filter 1 in Group 1',
-                                                       default: true
-                                               },
-                                               {
-                                                       name: 'hidefilter2',
-                                                       label: 'Show filter 2',
-                                                       description: 'Description of Filter 2 in Group 1'
-                                               },
-                                               {
-                                                       name: 'hidefilter3',
-                                                       label: 'Show filter 3',
-                                                       description: 'Description of Filter 3 in Group 1',
-                                                       default: true
-                                               }
-                                       ]
-                               },
-                               group2: {
-                                       title: 'Group 2',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'hidefilter4',
-                                                       label: 'Show filter 4',
-                                                       description: 'Description of Filter 1 in Group 2'
-                                               },
-                                               {
-                                                       name: 'hidefilter5',
-                                                       label: 'Show filter 5',
-                                                       description: 'Description of Filter 2 in Group 2',
-                                                       default: true
-                                               },
-                                               {
-                                                       name: 'hidefilter6',
-                                                       label: 'Show filter 6',
-                                                       description: 'Description of Filter 3 in Group 2'
-                                               }
-                                       ]
-                               }
-                       },
+               var definition = [ {
+                               name: 'group1',
+                               title: 'Group 1',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'hidefilter1',
+                                               label: 'Show filter 1',
+                                               description: 'Description of Filter 1 in Group 1',
+                                               default: true
+                                       },
+                                       {
+                                               name: 'hidefilter2',
+                                               label: 'Show filter 2',
+                                               description: 'Description of Filter 2 in Group 1'
+                                       },
+                                       {
+                                               name: 'hidefilter3',
+                                               label: 'Show filter 3',
+                                               description: 'Description of Filter 3 in Group 1',
+                                               default: true
+                                       }
+                               ]
+                       }, {
+                               name: 'group2',
+                               title: 'Group 2',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'hidefilter4',
+                                               label: 'Show filter 4',
+                                               description: 'Description of Filter 1 in Group 2'
+                                       },
+                                       {
+                                               name: 'hidefilter5',
+                                               label: 'Show filter 5',
+                                               description: 'Description of Filter 2 in Group 2',
+                                               default: true
+                                       },
+                                       {
+                                               name: 'hidefilter6',
+                                               label: 'Show filter 6',
+                                               description: 'Description of Filter 3 in Group 2'
+                                       }
+                               ]
+                       } ],
                        defaultFilterRepresentation = {
                                // Group 1 and 2, "send_unselected_if_any", the values of the filters are "flipped" from the values of the parameters
                                hidefilter1: false,
        } );
 
        QUnit.test( 'Filter interaction: subsets', function ( assert ) {
-               var definition = {
-                               group1: {
-                                       title: 'Group 1',
-                                       type: 'string_options',
-                                       filters: [
-                                               {
-                                                       name: 'filter1',
-                                                       label: 'Show filter 1',
-                                                       description: 'Description of Filter 1 in Group 1',
-                                                       subset: [ 'filter2', 'filter5' ]
-                                               },
-                                               {
-                                                       name: 'filter2',
-                                                       label: 'Show filter 2',
-                                                       description: 'Description of Filter 2 in Group 1'
-                                               },
-                                               {
-                                                       name: 'filter3',
-                                                       label: 'Show filter 3',
-                                                       description: 'Description of Filter 3 in Group 1'
-                                               }
-                                       ]
-                               },
-                               group2: {
-                                       title: 'Group 2',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'filter4',
-                                                       label: 'Show filter 4',
-                                                       description: 'Description of Filter 1 in Group 2',
-                                                       subset: [ 'filter3', 'filter5' ]
-                                               },
-                                               {
-                                                       name: 'filter5',
-                                                       label: 'Show filter 5',
-                                                       description: 'Description of Filter 2 in Group 2'
-                                               },
-                                               {
-                                                       name: 'filter6',
-                                                       label: 'Show filter 6',
-                                                       description: 'Description of Filter 3 in Group 2'
-                                               }
-                                       ]
-                               }
-                       },
+               var definition = [ {
+                               name: 'group1',
+                               title: 'Group 1',
+                               type: 'string_options',
+                               filters: [
+                                       {
+                                               name: 'filter1',
+                                               label: 'Show filter 1',
+                                               description: 'Description of Filter 1 in Group 1',
+                                               subset: [
+                                                       {
+                                                               group: 'group1',
+                                                               filter: 'filter2'
+                                                       },
+                                                       {
+                                                               group: 'group1',
+                                                               filter: 'filter3'
+                                                       }
+                                               ]
+                                       },
+                                       {
+                                               name: 'filter2',
+                                               label: 'Show filter 2',
+                                               description: 'Description of Filter 2 in Group 1',
+                                               subset: [
+                                                       {
+                                                               group: 'group1',
+                                                               filter: 'filter3'
+                                                       }
+                                               ]
+                                       },
+                                       {
+                                               name: 'filter3',
+                                               label: 'Show filter 3',
+                                               description: 'Description of Filter 3 in Group 1'
+                                       }
+                               ]
+                       } ],
                        baseFullState = {
                                filter1: { selected: false, conflicted: false, included: false },
                                filter2: { selected: false, conflicted: false, included: false },
-                               filter3: { selected: false, conflicted: false, included: false },
-                               filter4: { selected: false, conflicted: false, included: false },
-                               filter5: { selected: false, conflicted: false, included: false },
-                               filter6: { selected: false, conflicted: false, included: false }
+                               filter3: { selected: false, conflicted: false, included: false }
                        },
                        model = new mw.rcfilters.dm.FiltersViewModel();
 
                        $.extend( true, {}, baseFullState, {
                                filter1: { selected: true },
                                filter2: { included: true },
-                               filter5: { included: true }
+                               filter3: { included: true }
                        } ),
                        'Filters with subsets are represented in the model.'
                );
 
                // Select another filter that has a subset with the same previous filter
                model.toggleFiltersSelected( {
-                       filter4: true
+                       filter2: true
                } );
-               model.reassessFilterInteractions( model.getItemByName( 'filter4' ) );
+               model.reassessFilterInteractions( model.getItemByName( 'filter2' ) );
                assert.deepEqual(
                        model.getFullState(),
                        $.extend( true, {}, baseFullState, {
                                filter1: { selected: true },
-                               filter2: { included: true },
-                               filter3: { included: true },
-                               filter4: { selected: true },
-                               filter5: { included: true }
+                               filter2: { selected: true, included: true },
+                               filter3: { included: true }
                        } ),
                        'Filters that have multiple subsets are represented.'
                );
                assert.deepEqual(
                        model.getFullState(),
                        $.extend( true, {}, baseFullState, {
-                               filter2: { included: false },
-                               filter3: { included: true },
-                               filter4: { selected: true },
-                               filter5: { included: true }
+                               filter2: { selected: true, included: false },
+                               filter3: { included: true }
                        } ),
                        'Removing a filter only un-includes its subset if there is no other filter affecting.'
                );
 
                model.toggleFiltersSelected( {
-                       filter4: false
+                       filter2: false
                } );
-               model.reassessFilterInteractions( model.getItemByName( 'filter4' ) );
+               model.reassessFilterInteractions( model.getItemByName( 'filter2' ) );
                assert.deepEqual(
                        model.getFullState(),
                        baseFullState,
        } );
 
        QUnit.test( 'Filter interaction: full coverage', function ( assert ) {
-               var definition = {
-                               group1: {
-                                       title: 'Group 1',
-                                       type: 'string_options',
-                                       fullCoverage: false,
-                                       filters: [
-                                               { name: 'filter1' },
-                                               { name: 'filter2' },
-                                               { name: 'filter3' }
-                                       ]
-                               },
-                               group2: {
-                                       title: 'Group 2',
-                                       type: 'send_unselected_if_any',
-                                       fullCoverage: true,
-                                       filters: [
-                                               { name: 'filter4' },
-                                               { name: 'filter5' },
-                                               { name: 'filter6' }
-                                       ]
-                               }
-                       },
+               var definition = [ {
+                               name: 'group1',
+                               title: 'Group 1',
+                               type: 'string_options',
+                               fullCoverage: false,
+                               filters: [
+                                       { name: 'filter1', label: '1', description: '1' },
+                                       { name: 'filter2', label: '2', description: '2' },
+                                       { name: 'filter3', label: '3', description: '3' }
+                               ]
+                       }, {
+                               name: 'group2',
+                               title: 'Group 2',
+                               type: 'send_unselected_if_any',
+                               fullCoverage: true,
+                               filters: [
+                                       { name: 'filter4', label: '4', description: '4' },
+                                       { name: 'filter5', label: '5', description: '5' },
+                                       { name: 'filter6', label: '6', description: '6' }
+                               ]
+                       } ],
                        model = new mw.rcfilters.dm.FiltersViewModel(),
                        isCapsuleItemMuted = function ( filterName ) {
                                var itemModel = model.getItemByName( filterName ),
        } );
 
        QUnit.test( 'Filter interaction: conflicts', function ( assert ) {
-               var definition = {
-                               group1: {
-                                       title: 'Group 1',
-                                       type: 'string_options',
-                                       filters: [
-                                               {
-                                                       name: 'filter1',
-                                                       conflicts: [ 'filter2', 'filter4' ]
-                                               },
-                                               {
-                                                       name: 'filter2',
-                                                       conflicts: [ 'filter6' ]
-                                               },
-                                               {
-                                                       name: 'filter3'
-                                               }
-                                       ]
-                               },
-                               group2: {
-                                       title: 'Group 2',
-                                       type: 'send_unselected_if_any',
-                                       filters: [
-                                               {
-                                                       name: 'filter4'
-                                               },
-                                               {
-                                                       name: 'filter5',
-                                                       conflicts: [ 'filter3' ]
-                                               },
-                                               {
-                                                       name: 'filter6'
-                                               }
-                                       ]
-                               }
-                       },
+               var definition = [ {
+                               name: 'group1',
+                               title: 'Group 1',
+                               type: 'string_options',
+                               filters: [
+                                       {
+                                               name: 'filter1',
+                                               label: '1',
+                                               description: '1',
+                                               conflicts: [ 'filter2', 'filter4' ]
+                                       },
+                                       {
+                                               name: 'filter2',
+                                               label: '2',
+                                               description: '2',
+                                               conflicts: [ 'filter6' ]
+                                       },
+                                       {
+                                               name: 'filter3',
+                                               label: '3',
+                                               description: '3'
+                                       }
+                               ]
+                       }, {
+                               name: 'group2',
+                               title: 'Group 2',
+                               type: 'send_unselected_if_any',
+                               filters: [
+                                       {
+                                               name: 'filter4',
+                                               label: '1',
+                                               description: '1'
+                                       },
+                                       {
+                                               name: 'filter5',
+                                               label: '5',
+                                               description: '5',
+                                               conflicts: [ 'filter3' ]
+                                       },
+                                       {
+                                               name: 'filter6',
+                                               label: '6',
+                                               description: '6'
+                                       }
+                               ]
+                       } ],
                        baseFullState = {
                                filter1: { selected: false, conflicted: false, included: false },
                                filter2: { selected: false, conflicted: false, included: false },
        } );
 
        QUnit.test( 'Filter highlights', function ( assert ) {
-               var definition = {
-                               group1: {
-                                       title: 'Group 1',
-                                       type: 'string_options',
-                                       filters: [
-                                               { name: 'filter1', class: 'class1' },
-                                               { name: 'filter2', class: 'class2' },
-                                               { name: 'filter3', class: 'class3' },
-                                               { name: 'filter4', class: 'class4' },
-                                               { name: 'filter5', class: 'class5' },
-                                               { name: 'filter6' }
-                                       ]
-                               }
-                       },
+               var definition = [ {
+                               name: 'group1',
+                               title: 'Group 1',
+                               type: 'string_options',
+                               filters: [
+                                       { name: 'filter1', cssClass: 'class1', label: '1', description: '1' },
+                                       { name: 'filter2', cssClass: 'class2', label: '2', description: '2' },
+                                       { name: 'filter3', cssClass: 'class3', label: '3', description: '3' },
+                                       { name: 'filter4', cssClass: 'class4', label: '4', description: '4' },
+                                       { name: 'filter5', cssClass: 'class5', label: '5', description: '5' },
+                                       { name: 'filter6', label: '6', description: '6' }
+                               ]
+                       } ],
                        model = new mw.rcfilters.dm.FiltersViewModel();
 
                model.initializeFilters( definition );