RCFilters: Convert RL modules to packageFiles
authorRoan Kattouw <roan.kattouw@gmail.com>
Fri, 14 Sep 2018 19:29:51 +0000 (12:29 -0700)
committerKrinkle <krinklemail@gmail.com>
Sun, 10 Feb 2019 00:44:49 +0000 (00:44 +0000)
* Use per-file require() to get classes' dependencies
* Export all DM classes to the global mw.rcfilters.dm object because the
  unit tests need that
* Don't make any of the UI classes globally available except
  MainWrapperWidget (and we can probably drop that one too)
* Rename mw.rcfilters.{dm,ui}.XYZ.js to XYZ.js

Change-Id: I8e541abf15c6987827a29eecc6a6f19f27378abb

87 files changed:
resources/Resources.php
resources/src/mediawiki.rcfilters/Controller.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/HighlightColors.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/UriProcessor.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/FilterGroup.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/FilterItem.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/ItemModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js [deleted file]
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js [deleted file]
resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js [deleted file]
resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js [deleted file]
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
resources/src/mediawiki.rcfilters/mw.rcfilters.js
resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/GroupWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/TagItemWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuOptionWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuSectionOptionWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagItemWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.GroupWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightPopupWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MainWrapperWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ViewSwitchWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js [deleted file]

index a34634f..f774993 100644 (file)
@@ -1800,17 +1800,19 @@ return [
                ],
        ],
        'mediawiki.rcfilters.filters.dm' => [
-               'scripts' => [
-                       'resources/src/mediawiki.rcfilters/mw.rcfilters.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js',
-                       'resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js',
-                       'resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js',
+               'localBasePath' => "$IP/resources/src/mediawiki.rcfilters",
+               'remoteBasePath' => "$wgResourceBasePath/resources/src/mediawiki.rcfilters",
+               'packageFiles' => [
+                       'mw.rcfilters.js',
+                       'Controller.js',
+                       'UriProcessor.js',
+                       'dm/ChangesListViewModel.js',
+                       'dm/FilterGroup.js',
+                       'dm/FilterItem.js',
+                       'dm/FiltersViewModel.js',
+                       'dm/ItemModel.js',
+                       'dm/SavedQueriesModel.js',
+                       'dm/SavedQueryItemModel.js',
                ],
                'dependencies' => [
                        'mediawiki.String',
@@ -1827,79 +1829,81 @@ return [
                ],
        ],
        'mediawiki.rcfilters.filters.ui' => [
-               'scripts' => [
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.GroupWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuOptionWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuSectionOptionWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagItemWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MainWrapperWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ViewSwitchWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightPopupWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js',
-                       'resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js',
-                       'resources/src/mediawiki.rcfilters/mw.rcfilters.init.js',
+               'localBasePath' => "$IP/resources/src/mediawiki.rcfilters",
+               'remoteBasePath' => "$wgResourceBasePath/resources/src/mediawiki.rcfilters",
+               'packageFiles' => [
+                       'mw.rcfilters.init.js',
+                       'HighlightColors.js',
+                       'ui/GroupWidget.js',
+                       'ui/CheckboxInputWidget.js',
+                       'ui/FilterTagMultiselectWidget.js',
+                       'ui/ItemMenuOptionWidget.js',
+                       'ui/FilterMenuOptionWidget.js',
+                       'ui/FilterMenuSectionOptionWidget.js',
+                       'ui/TagItemWidget.js',
+                       'ui/FilterTagItemWidget.js',
+                       'ui/FilterMenuHeaderWidget.js',
+                       'ui/MenuSelectWidget.js',
+                       'ui/MainWrapperWidget.js',
+                       'ui/ViewSwitchWidget.js',
+                       'ui/ValuePickerWidget.js',
+                       'ui/ChangesLimitPopupWidget.js',
+                       'ui/ChangesLimitAndDateButtonWidget.js',
+                       'ui/DatePopupWidget.js',
+                       'ui/FilterWrapperWidget.js',
+                       'ui/ChangesListWrapperWidget.js',
+                       'ui/SavedLinksListWidget.js',
+                       'ui/SavedLinksListItemWidget.js',
+                       'ui/SaveFiltersPopupButtonWidget.js',
+                       'ui/FormWrapperWidget.js',
+                       'ui/FilterItemHighlightButton.js',
+                       'ui/HighlightPopupWidget.js',
+                       'ui/HighlightColorPickerWidget.js',
+                       'ui/LiveUpdateButtonWidget.js',
+                       'ui/MarkSeenButtonWidget.js',
+                       'ui/RcTopSectionWidget.js',
+                       'ui/RclTopSectionWidget.js',
+                       'ui/RclTargetPageWidget.js',
+                       'ui/RclToOrFromWidget.js',
+                       'ui/WatchlistTopSectionWidget.js',
                ],
                'styles' => [
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.mixins.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.Overlay.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.TagItemWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.MenuSelectWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ViewSwitchWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ValuePickerWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesLimitPopupWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.DatePopupWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.HighlightColorPickerWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemHighlightButton.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListItemWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RcTopSectionWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclTargetPageWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.WatchlistTopSectionWidget.less',
+                       'styles/mw.rcfilters.mixins.less',
+                       'styles/mw.rcfilters.variables.less',
+                       'styles/mw.rcfilters.ui.less',
+                       'styles/mw.rcfilters.ui.Overlay.less',
+                       'styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less',
+                       'styles/mw.rcfilters.ui.ItemMenuOptionWidget.less',
+                       'styles/mw.rcfilters.ui.FilterMenuOptionWidget.less',
+                       'styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less',
+                       'styles/mw.rcfilters.ui.TagItemWidget.less',
+                       'styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less',
+                       'styles/mw.rcfilters.ui.MenuSelectWidget.less',
+                       'styles/mw.rcfilters.ui.ViewSwitchWidget.less',
+                       'styles/mw.rcfilters.ui.ValuePickerWidget.less',
+                       'styles/mw.rcfilters.ui.ChangesLimitPopupWidget.less',
+                       'styles/mw.rcfilters.ui.DatePopupWidget.less',
+                       'styles/mw.rcfilters.ui.FilterWrapperWidget.less',
+                       'styles/mw.rcfilters.ui.ChangesListWrapperWidget.less',
+                       'styles/mw.rcfilters.ui.HighlightColorPickerWidget.less',
+                       'styles/mw.rcfilters.ui.FilterItemHighlightButton.less',
+                       'styles/mw.rcfilters.ui.SavedLinksListWidget.less',
+                       'styles/mw.rcfilters.ui.SavedLinksListItemWidget.less',
+                       'styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less',
+                       'styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less',
+                       'styles/mw.rcfilters.ui.RcTopSectionWidget.less',
+                       'styles/mw.rcfilters.ui.RclToOrFromWidget.less',
+                       'styles/mw.rcfilters.ui.RclTargetPageWidget.less',
+                       'styles/mw.rcfilters.ui.WatchlistTopSectionWidget.less',
                ],
                'skinStyles' => [
                        'vector' => [
-                               'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.Overlay.vector.less',
+                               'styles/mw.rcfilters.ui.Overlay.vector.less',
                        ],
                        'monobook' => [
-                               'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.Overlay.monobook.less',
-                               'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.monobook.less',
-                               'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.monobook.less',
+                               'styles/mw.rcfilters.ui.Overlay.monobook.less',
+                               'styles/mw.rcfilters.ui.CapsuleItemWidget.monobook.less',
+                               'styles/mw.rcfilters.ui.FilterMenuOptionWidget.monobook.less',
                        ],
                ],
                'messages' => [
diff --git a/resources/src/mediawiki.rcfilters/Controller.js b/resources/src/mediawiki.rcfilters/Controller.js
new file mode 100644 (file)
index 0000000..30d4a90
--- /dev/null
@@ -0,0 +1,1229 @@
+( function () {
+
+       var byteLength = require( 'mediawiki.String' ).byteLength,
+               UriProcessor = require( './UriProcessor.js' ),
+               Controller;
+
+       /* eslint no-underscore-dangle: "off" */
+       /**
+        * Controller for the filters in Recent Changes
+        * @class mw.rcfilters.Controller
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
+        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+        * @param {Object} config Additional configuration
+        * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
+        * @cfg {string} daysPreferenceName Preference name for the days filter
+        * @cfg {string} limitPreferenceName Preference name for the limit filter
+        * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
+        *  the active filters area
+        * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
+        *  title normalization to separate title subpage/parts into the target= url
+        *  parameter
+        */
+       Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
+               this.filtersModel = filtersModel;
+               this.changesListModel = changesListModel;
+               this.savedQueriesModel = savedQueriesModel;
+               this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
+               this.daysPreferenceName = config.daysPreferenceName;
+               this.limitPreferenceName = config.limitPreferenceName;
+               this.collapsedPreferenceName = config.collapsedPreferenceName;
+               this.normalizeTarget = !!config.normalizeTarget;
+
+               this.requestCounter = {};
+               this.baseFilterState = {};
+               this.uriProcessor = null;
+               this.initialized = false;
+               this.wereSavedQueriesSaved = false;
+
+               this.prevLoggedItems = [];
+
+               this.FILTER_CHANGE = 'filterChange';
+               this.SHOW_NEW_CHANGES = 'showNewChanges';
+               this.LIVE_UPDATE = 'liveUpdate';
+       };
+
+       /* Initialization */
+       OO.initClass( Controller );
+
+       /**
+        * Initialize the filter and parameter states
+        *
+        * @param {Array} filterStructure Filter definition and structure for the model
+        * @param {Object} [namespaceStructure] Namespace definition
+        * @param {Object} [tagList] Tag definition
+        * @param {Object} [conditionalViews] Conditional view definition
+        */
+       Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
+               var parsedSavedQueries, pieces,
+                       displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
+                       defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
+                       controller = this,
+                       views = $.extend( true, {}, conditionalViews ),
+                       items = [],
+                       uri = new mw.Uri();
+
+               // Prepare views
+               if ( namespaceStructure ) {
+                       items = [];
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( namespaceStructure, function ( namespaceID, label ) {
+                               // Build and clean up the individual namespace items definition
+                               items.push( {
+                                       name: namespaceID,
+                                       label: label || mw.msg( 'blanknamespace' ),
+                                       description: '',
+                                       identifiers: [
+                                               mw.Title.isTalkNamespace( namespaceID ) ?
+                                                       'talk' : 'subject'
+                                       ],
+                                       cssClass: 'mw-changeslist-ns-' + namespaceID
+                               } );
+                       } );
+
+                       views.namespaces = {
+                               title: mw.msg( 'namespaces' ),
+                               trigger: ':',
+                               groups: [ {
+                                       // Group definition (single group)
+                                       name: 'namespace', // parameter name is singular
+                                       type: 'string_options',
+                                       title: mw.msg( 'namespaces' ),
+                                       labelPrefixKey: { default: 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+                                       separator: ';',
+                                       fullCoverage: true,
+                                       filters: items
+                               } ]
+                       };
+                       views.invert = {
+                               groups: [
+                                       {
+                                               name: 'invertGroup',
+                                               type: 'boolean',
+                                               hidden: true,
+                                               filters: [ {
+                                                       name: 'invert',
+                                                       default: '0'
+                                               } ]
+                                       } ]
+                       };
+               }
+               if ( tagList ) {
+                       views.tags = {
+                               title: mw.msg( 'rcfilters-view-tags' ),
+                               trigger: '#',
+                               groups: [ {
+                                       // Group definition (single group)
+                                       name: 'tagfilter', // Parameter name
+                                       type: 'string_options',
+                                       title: 'rcfilters-view-tags', // Message key
+                                       labelPrefixKey: 'rcfilters-tag-prefix-tags',
+                                       separator: '|',
+                                       fullCoverage: false,
+                                       filters: tagList
+                               } ]
+                       };
+               }
+
+               // Add parameter range operations
+               views.range = {
+                       groups: [
+                               {
+                                       name: 'limit',
+                                       type: 'single_option',
+                                       title: '', // Because it's a hidden group, this title actually appears nowhere
+                                       hidden: true,
+                                       allowArbitrary: true,
+                                       // FIXME: $.isNumeric is deprecated
+                                       validate: $.isNumeric,
+                                       range: {
+                                               min: 0, // The server normalizes negative numbers to 0 results
+                                               max: 1000
+                                       },
+                                       sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
+                                       default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
+                                       sticky: true,
+                                       filters: displayConfig.limitArray.map( function ( num ) {
+                                               return controller._createFilterDataFromNumber( num, num );
+                                       } )
+                               },
+                               {
+                                       name: 'days',
+                                       type: 'single_option',
+                                       title: '', // Because it's a hidden group, this title actually appears nowhere
+                                       hidden: true,
+                                       allowArbitrary: true,
+                                       // FIXME: $.isNumeric is deprecated
+                                       validate: $.isNumeric,
+                                       range: {
+                                               min: 0,
+                                               max: displayConfig.maxDays
+                                       },
+                                       sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
+                                       numToLabelFunc: function ( i ) {
+                                               return Number( i ) < 1 ?
+                                                       ( Number( i ) * 24 ).toFixed( 2 ) :
+                                                       Number( i );
+                                       },
+                                       default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
+                                       sticky: true,
+                                       filters: [
+                                               // Hours (1, 2, 6, 12)
+                                               0.04166, 0.0833, 0.25, 0.5
+                                       // Days
+                                       ].concat( displayConfig.daysArray )
+                                               .map( function ( num ) {
+                                                       return controller._createFilterDataFromNumber(
+                                                               num,
+                                                               // Convert fractions of days to number of hours for the labels
+                                                               num < 1 ? Math.round( num * 24 ) : num
+                                                       );
+                                               } )
+                               }
+                       ]
+               };
+
+               views.display = {
+                       groups: [
+                               {
+                                       name: 'display',
+                                       type: 'boolean',
+                                       title: '', // Because it's a hidden group, this title actually appears nowhere
+                                       hidden: true,
+                                       sticky: true,
+                                       filters: [
+                                               {
+                                                       name: 'enhanced',
+                                                       default: String( mw.user.options.get( 'usenewrc', 0 ) )
+                                               }
+                                       ]
+                               }
+                       ]
+               };
+
+               // Before we do anything, we need to see if we require additional items in the
+               // groups that have 'AllowArbitrary'. For the moment, those are only single_option
+               // groups; if we ever expand it, this might need further generalization:
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( views, function ( viewName, viewData ) {
+                       viewData.groups.forEach( function ( groupData ) {
+                               var extraValues = [];
+                               if ( groupData.allowArbitrary ) {
+                                       // If the value in the URI isn't in the group, add it
+                                       if ( uri.query[ groupData.name ] !== undefined ) {
+                                               extraValues.push( uri.query[ groupData.name ] );
+                                       }
+                                       // If the default value isn't in the group, add it
+                                       if ( groupData.default !== undefined ) {
+                                               extraValues.push( String( groupData.default ) );
+                                       }
+                                       controller.addNumberValuesToGroup( groupData, extraValues );
+                               }
+                       } );
+               } );
+
+               // Initialize the model
+               this.filtersModel.initializeFilters( filterStructure, views );
+
+               this.uriProcessor = new UriProcessor(
+                       this.filtersModel,
+                       { normalizeTarget: this.normalizeTarget }
+               );
+
+               if ( !mw.user.isAnon() ) {
+                       try {
+                               parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
+                       } catch ( err ) {
+                               parsedSavedQueries = {};
+                       }
+
+                       // Initialize saved queries
+                       this.savedQueriesModel.initialize( parsedSavedQueries );
+                       if ( this.savedQueriesModel.isConverted() ) {
+                               // Since we know we converted, we're going to re-save
+                               // the queries so they are now migrated to the new format
+                               this._saveSavedQueries();
+                       }
+               }
+
+               if ( defaultSavedQueryExists ) {
+                       // This came from the server, meaning that we have a default
+                       // saved query, but the server could not load it, probably because
+                       // it was pre-conversion to the new format.
+                       // We need to load this query again
+                       this.applySavedQuery( this.savedQueriesModel.getDefault() );
+               } else {
+                       // There are either recognized parameters in the URL
+                       // or there are none, but there is also no default
+                       // saved query (so defaults are from the backend)
+                       // We want to update the state but not fetch results
+                       // again
+                       this.updateStateFromUrl( false );
+
+                       pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
+
+                       // Update the changes list with the existing data
+                       // so it gets processed
+                       this.changesListModel.update(
+                               pieces.changes,
+                               pieces.fieldset,
+                               pieces.noResultsDetails,
+                               true // We're using existing DOM elements
+                       );
+               }
+
+               this.initialized = true;
+               this.switchView( 'default' );
+
+               this.pollingRate = mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' );
+               if ( this.pollingRate ) {
+                       this._scheduleLiveUpdate();
+               }
+       };
+
+       /**
+        * Check if the controller has finished initializing.
+        * @return {boolean} Controller is initialized
+        */
+       Controller.prototype.isInitialized = function () {
+               return this.initialized;
+       };
+
+       /**
+        * Extracts information from the changes list DOM
+        *
+        * @param {jQuery} $root Root DOM to find children from
+        * @param {boolean} [statusCode] Server response status code
+        * @return {Object} Information about changes list
+        * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
+        *   (either normally or as an error)
+        * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
+        *   'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
+        * @return {jQuery} return.fieldset Fieldset
+        */
+       Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
+               var info,
+                       $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
+                       areResults = !!$changesListContents.length,
+                       checkForLogout = !areResults && statusCode === 200;
+
+               // We check if user logged out on different tab/browser or the session has expired.
+               // 205 status code returned from the server, which indicates that we need to reload the page
+               // is not usable on WL page, because we get redirected to login page, which gives 200 OK
+               // status code (if everything else goes well).
+               // Bug: T177717
+               if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
+                       location.reload( false );
+                       return;
+               }
+
+               info = {
+                       changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
+                       fieldset: $root.find( 'fieldset.cloptions' ).first()
+               };
+
+               if ( !areResults ) {
+                       if ( $root.find( '.mw-changeslist-timeout' ).length ) {
+                               info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
+                       } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
+                               info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
+                       } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
+                               info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
+                       } else {
+                               info.noResultsDetails = 'NO_RESULTS_NORMAL';
+                       }
+               }
+
+               return info;
+       };
+
+       /**
+        * Create filter data from a number, for the filters that are numerical value
+        *
+        * @param {number} num Number
+        * @param {number} numForDisplay Number for the label
+        * @return {Object} Filter data
+        */
+       Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
+               return {
+                       name: String( num ),
+                       label: mw.language.convertNumber( numForDisplay )
+               };
+       };
+
+       /**
+        * Add an arbitrary values to groups that allow arbitrary values
+        *
+        * @param {Object} groupData Group data
+        * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
+        */
+       Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
+               var controller = this,
+                       normalizeWithinRange = function ( range, val ) {
+                               if ( val < range.min ) {
+                                       return range.min; // Min
+                               } else if ( val >= range.max ) {
+                                       return range.max; // Max
+                               }
+                               return val;
+                       };
+
+               arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
+
+               // Normalize the arbitrary values and the default value for a range
+               if ( groupData.range ) {
+                       arbitraryValues = arbitraryValues.map( function ( val ) {
+                               return normalizeWithinRange( groupData.range, val );
+                       } );
+
+                       // Normalize the default, since that's user defined
+                       if ( groupData.default !== undefined ) {
+                               groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
+                       }
+               }
+
+               // This is only true for single_option group
+               // We assume these are the only groups that will allow for
+               // arbitrary, since it doesn't make any sense for the other
+               // groups.
+               arbitraryValues.forEach( function ( val ) {
+                       if (
+                               // If the group allows for arbitrary data
+                               groupData.allowArbitrary &&
+                               // and it is single_option (or string_options, but we
+                               // don't have cases of those yet, nor do we plan to)
+                               groupData.type === 'single_option' &&
+                               // and, if there is a validate method and it passes on
+                               // the data
+                               ( !groupData.validate || groupData.validate( val ) ) &&
+                               // but if that value isn't already in the definition
+                               groupData.filters
+                                       .map( function ( filterData ) {
+                                               return String( filterData.name );
+                                       } )
+                                       .indexOf( String( val ) ) === -1
+                       ) {
+                               // Add the filter information
+                               groupData.filters.push( controller._createFilterDataFromNumber(
+                                       val,
+                                       groupData.numToLabelFunc ?
+                                               groupData.numToLabelFunc( val ) :
+                                               val
+                               ) );
+
+                               // If there's a sort function set up, re-sort the values
+                               if ( groupData.sortFunc ) {
+                                       groupData.filters.sort( groupData.sortFunc );
+                               }
+                       }
+               } );
+       };
+
+       /**
+        * Reset to default filters
+        */
+       Controller.prototype.resetToDefaults = function () {
+               var params = this._getDefaultParams();
+               if ( this.applyParamChange( params ) ) {
+                       // Only update the changes list if there was a change to actual filters
+                       this.updateChangesList();
+               } else {
+                       this.uriProcessor.updateURL( params );
+               }
+       };
+
+       /**
+        * Check whether the default values of the filters are all false.
+        *
+        * @return {boolean} Defaults are all false
+        */
+       Controller.prototype.areDefaultsEmpty = function () {
+               return $.isEmptyObject( this._getDefaultParams() );
+       };
+
+       /**
+        * Empty all selected filters
+        */
+       Controller.prototype.emptyFilters = function () {
+               var highlightedFilterNames = this.filtersModel.getHighlightedItems()
+                       .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
+
+               if ( this.applyParamChange( {} ) ) {
+                       // Only update the changes list if there was a change to actual filters
+                       this.updateChangesList();
+               } else {
+                       this.uriProcessor.updateURL();
+               }
+
+               if ( highlightedFilterNames ) {
+                       this._trackHighlight( 'clearAll', highlightedFilterNames );
+               }
+       };
+
+       /**
+        * Update the selected state of a filter
+        *
+        * @param {string} filterName Filter name
+        * @param {boolean} [isSelected] Filter selected state
+        */
+       Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
+               var filterItem = this.filtersModel.getItemByName( filterName );
+
+               if ( !filterItem ) {
+                       // If no filter was found, break
+                       return;
+               }
+
+               isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
+
+               if ( filterItem.isSelected() !== isSelected ) {
+                       this.filtersModel.toggleFilterSelected( filterName, isSelected );
+
+                       this.updateChangesList();
+
+                       // Check filter interactions
+                       this.filtersModel.reassessFilterInteractions( filterItem );
+               }
+       };
+
+       /**
+        * Clear both highlight and selection of a filter
+        *
+        * @param {string} filterName Name of the filter item
+        */
+       Controller.prototype.clearFilter = function ( filterName ) {
+               var filterItem = this.filtersModel.getItemByName( filterName ),
+                       isHighlighted = filterItem.isHighlighted(),
+                       isSelected = filterItem.isSelected();
+
+               if ( isSelected || isHighlighted ) {
+                       this.filtersModel.clearHighlightColor( filterName );
+                       this.filtersModel.toggleFilterSelected( filterName, false );
+
+                       if ( isSelected ) {
+                               // Only update the changes list if the filter changed
+                               // its selection state. If it only changed its highlight
+                               // then don't reload
+                               this.updateChangesList();
+                       }
+
+                       this.filtersModel.reassessFilterInteractions( filterItem );
+
+                       // Log filter grouping
+                       this.trackFilterGroupings( 'removefilter' );
+               }
+
+               if ( isHighlighted ) {
+                       this._trackHighlight( 'clear', filterName );
+               }
+       };
+
+       /**
+        * Toggle the highlight feature on and off
+        */
+       Controller.prototype.toggleHighlight = function () {
+               this.filtersModel.toggleHighlight();
+               this.uriProcessor.updateURL();
+
+               if ( this.filtersModel.isHighlightEnabled() ) {
+                       mw.hook( 'RcFilters.highlight.enable' ).fire();
+               }
+       };
+
+       /**
+        * Toggle the namespaces inverted feature on and off
+        */
+       Controller.prototype.toggleInvertedNamespaces = function () {
+               this.filtersModel.toggleInvertedNamespaces();
+               if (
+                       this.filtersModel.getFiltersByView( 'namespaces' ).filter(
+                               function ( filterItem ) { return filterItem.isSelected(); }
+                       ).length
+               ) {
+                       // Only re-fetch results if there are namespace items that are actually selected
+                       this.updateChangesList();
+               } else {
+                       this.uriProcessor.updateURL();
+               }
+       };
+
+       /**
+        * Set the value of the 'showlinkedto' parameter
+        * @param {boolean} value
+        */
+       Controller.prototype.setShowLinkedTo = function ( value ) {
+               var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
+                       showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
+
+               this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
+               this.uriProcessor.updateURL();
+               // reload the results only when target is set
+               if ( targetItem.getValue() ) {
+                       this.updateChangesList();
+               }
+       };
+
+       /**
+        * Set the target page
+        * @param {string} page
+        */
+       Controller.prototype.setTargetPage = function ( page ) {
+               var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
+               targetItem.setValue( page );
+               this.uriProcessor.updateURL();
+               this.updateChangesList();
+       };
+
+       /**
+        * Set the highlight color for a filter item
+        *
+        * @param {string} filterName Name of the filter item
+        * @param {string} color Selected color
+        */
+       Controller.prototype.setHighlightColor = function ( filterName, color ) {
+               this.filtersModel.setHighlightColor( filterName, color );
+               this.uriProcessor.updateURL();
+               this._trackHighlight( 'set', { name: filterName, color: color } );
+       };
+
+       /**
+        * Clear highlight for a filter item
+        *
+        * @param {string} filterName Name of the filter item
+        */
+       Controller.prototype.clearHighlightColor = function ( filterName ) {
+               this.filtersModel.clearHighlightColor( filterName );
+               this.uriProcessor.updateURL();
+               this._trackHighlight( 'clear', filterName );
+       };
+
+       /**
+        * Enable or disable live updates.
+        * @param {boolean} enable True to enable, false to disable
+        */
+       Controller.prototype.toggleLiveUpdate = function ( enable ) {
+               this.changesListModel.toggleLiveUpdate( enable );
+               if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
+                       this.updateChangesList( null, this.LIVE_UPDATE );
+               }
+       };
+
+       /**
+        * Set a timeout for the next live update.
+        * @private
+        */
+       Controller.prototype._scheduleLiveUpdate = function () {
+               setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
+       };
+
+       /**
+        * Perform a live update.
+        * @private
+        */
+       Controller.prototype._doLiveUpdate = function () {
+               if ( !this._shouldCheckForNewChanges() ) {
+                       // skip this turn and check back later
+                       this._scheduleLiveUpdate();
+                       return;
+               }
+
+               this._checkForNewChanges()
+                       .then( function ( statusCode ) {
+                               // no result is 204 with the 'peek' param
+                               // logged out is 205
+                               var newChanges = statusCode === 200;
+
+                               if ( !this._shouldCheckForNewChanges() ) {
+                                       // by the time the response is received,
+                                       // it may not be appropriate anymore
+                                       return;
+                               }
+
+                               // 205 is the status code returned from server when user's logged in/out
+                               // status is not matching while fetching live update changes.
+                               // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
+                               // Bug: T177717
+                               if ( statusCode === 205 ) {
+                                       location.reload( false );
+                                       return;
+                               }
+
+                               if ( newChanges ) {
+                                       if ( this.changesListModel.getLiveUpdate() ) {
+                                               return this.updateChangesList( null, this.LIVE_UPDATE );
+                                       } else {
+                                               this.changesListModel.setNewChangesExist( true );
+                                       }
+                               }
+                       }.bind( this ) )
+                       .always( this._scheduleLiveUpdate.bind( this ) );
+       };
+
+       /**
+        * @return {boolean} It's appropriate to check for new changes now
+        * @private
+        */
+       Controller.prototype._shouldCheckForNewChanges = function () {
+               return !document.hidden &&
+                       !this.filtersModel.hasConflict() &&
+                       !this.changesListModel.getNewChangesExist() &&
+                       !this.updatingChangesList &&
+                       this.changesListModel.getNextFrom();
+       };
+
+       /**
+        * Check if new changes, newer than those currently shown, are available
+        *
+        * @return {jQuery.Promise} Promise object that resolves with a bool
+        *   specifying if there are new changes or not
+        *
+        * @private
+        */
+       Controller.prototype._checkForNewChanges = function () {
+               var params = {
+                       limit: 1,
+                       peek: 1, // bypasses ChangesList specific UI
+                       from: this.changesListModel.getNextFrom(),
+                       isAnon: mw.user.isAnon()
+               };
+               return this._queryChangesList( 'liveUpdate', params ).then(
+                       function ( data ) {
+                               return data.status;
+                       }
+               );
+       };
+
+       /**
+        * Show the new changes
+        *
+        * @return {jQuery.Promise} Promise object that resolves after
+        * fetching and showing the new changes
+        */
+       Controller.prototype.showNewChanges = function () {
+               return this.updateChangesList( null, this.SHOW_NEW_CHANGES );
+       };
+
+       /**
+        * Save the current model state as a saved query
+        *
+        * @param {string} [label] Label of the saved query
+        * @param {boolean} [setAsDefault=false] This query should be set as the default
+        */
+       Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
+               // Add item
+               this.savedQueriesModel.addNewQuery(
+                       label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
+                       this.filtersModel.getCurrentParameterState( true ),
+                       setAsDefault
+               );
+
+               // Save item
+               this._saveSavedQueries();
+       };
+
+       /**
+        * Remove a saved query
+        *
+        * @param {string} queryID Query id
+        */
+       Controller.prototype.removeSavedQuery = function ( queryID ) {
+               this.savedQueriesModel.removeQuery( queryID );
+
+               this._saveSavedQueries();
+       };
+
+       /**
+        * Rename a saved query
+        *
+        * @param {string} queryID Query id
+        * @param {string} newLabel New label for the query
+        */
+       Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
+               var queryItem = this.savedQueriesModel.getItemByID( queryID );
+
+               if ( queryItem ) {
+                       queryItem.updateLabel( newLabel );
+               }
+               this._saveSavedQueries();
+       };
+
+       /**
+        * Set a saved query as default
+        *
+        * @param {string} queryID Query Id. If null is given, default
+        *  query is reset.
+        */
+       Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
+               this.savedQueriesModel.setDefault( queryID );
+               this._saveSavedQueries();
+       };
+
+       /**
+        * Load a saved query
+        *
+        * @param {string} queryID Query id
+        */
+       Controller.prototype.applySavedQuery = function ( queryID ) {
+               var currentMatchingQuery,
+                       params = this.savedQueriesModel.getItemParams( queryID );
+
+               currentMatchingQuery = this.findQueryMatchingCurrentState();
+
+               if (
+                       currentMatchingQuery &&
+                       currentMatchingQuery.getID() === queryID
+               ) {
+                       // If the query we want to load is the one that is already
+                       // loaded, don't reload it
+                       return;
+               }
+
+               if ( this.applyParamChange( params ) ) {
+                       // Update changes list only if there was a difference in filter selection
+                       this.updateChangesList();
+               } else {
+                       this.uriProcessor.updateURL( params );
+               }
+
+               // Log filter grouping
+               this.trackFilterGroupings( 'savedfilters' );
+       };
+
+       /**
+        * Check whether the current filter and highlight state exists
+        * in the saved queries model.
+        *
+        * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
+        */
+       Controller.prototype.findQueryMatchingCurrentState = function () {
+               return this.savedQueriesModel.findMatchingQuery(
+                       this.filtersModel.getCurrentParameterState( true )
+               );
+       };
+
+       /**
+        * Save the current state of the saved queries model with all
+        * query item representation in the user settings.
+        */
+       Controller.prototype._saveSavedQueries = function () {
+               var stringified, oldPrefValue,
+                       backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
+                       state = this.savedQueriesModel.getState();
+
+               // Stringify state
+               stringified = JSON.stringify( state );
+
+               if ( byteLength( stringified ) > 65535 ) {
+                       // Sanity check, since the preference can only hold that.
+                       return;
+               }
+
+               if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
+                       // The queries were converted from the previous version
+                       // Keep the old string in the [prefname]-versionbackup
+                       oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
+
+                       // Save the old preference in the backup preference
+                       new mw.Api().saveOption( backupPrefName, oldPrefValue );
+                       // Update the preference for this session
+                       mw.user.options.set( backupPrefName, oldPrefValue );
+               }
+
+               // Save the preference
+               new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
+               // Update the preference for this session
+               mw.user.options.set( this.savedQueriesPreferenceName, stringified );
+
+               // Tag as already saved so we don't do this again
+               this.wereSavedQueriesSaved = true;
+       };
+
+       /**
+        * Update sticky preferences with current model state
+        */
+       Controller.prototype.updateStickyPreferences = function () {
+               // Update default sticky values with selected, whether they came from
+               // the initial defaults or from the URL value that is being normalized
+               this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).findSelectedItems()[ 0 ].getParamName() );
+               this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );
+
+               // TODO: Make these automatic by having the model go over sticky
+               // items and update their default values automatically
+       };
+
+       /**
+        * Update the limit default value
+        *
+        * @param {number} newValue New value
+        */
+       Controller.prototype.updateLimitDefault = function ( newValue ) {
+               this.updateNumericPreference( this.limitPreferenceName, newValue );
+       };
+
+       /**
+        * Update the days default value
+        *
+        * @param {number} newValue New value
+        */
+       Controller.prototype.updateDaysDefault = function ( newValue ) {
+               this.updateNumericPreference( this.daysPreferenceName, newValue );
+       };
+
+       /**
+        * Update the group by page default value
+        *
+        * @param {boolean} newValue New value
+        */
+       Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
+               this.updateNumericPreference( 'usenewrc', Number( newValue ) );
+       };
+
+       /**
+        * Update the collapsed state value
+        *
+        * @param {boolean} isCollapsed Filter area is collapsed
+        */
+       Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
+               this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
+       };
+
+       /**
+        * Update a numeric preference with a new value
+        *
+        * @param {string} prefName Preference name
+        * @param {number|string} newValue New value
+        */
+       Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
+               // FIXME: $.isNumeric is deprecated
+               // eslint-disable-next-line jquery/no-is-numeric
+               if ( !$.isNumeric( newValue ) ) {
+                       return;
+               }
+
+               newValue = Number( newValue );
+
+               if ( mw.user.options.get( prefName ) !== newValue ) {
+                       // Save the preference
+                       new mw.Api().saveOption( prefName, newValue );
+                       // Update the preference for this session
+                       mw.user.options.set( prefName, newValue );
+               }
+       };
+
+       /**
+        * Synchronize the URL with the current state of the filters
+        * without adding an history entry.
+        */
+       Controller.prototype.replaceUrl = function () {
+               this.uriProcessor.updateURL();
+       };
+
+       /**
+        * Update filter state (selection and highlighting) based
+        * on current URL values.
+        *
+        * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
+        *  list based on the updated model.
+        */
+       Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
+               fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
+
+               this.uriProcessor.updateModelBasedOnQuery();
+
+               // Update the sticky preferences, in case we received a value
+               // from the URL
+               this.updateStickyPreferences();
+
+               // Only update and fetch new results if it is requested
+               if ( fetchChangesList ) {
+                       this.updateChangesList();
+               }
+       };
+
+       /**
+        * Update the list of changes and notify the model
+        *
+        * @param {Object} [params] Extra parameters to add to the API call
+        * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
+        * @return {jQuery.Promise} Promise that is resolved when the update is complete
+        */
+       Controller.prototype.updateChangesList = function ( params, updateMode ) {
+               updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
+
+               if ( updateMode === this.FILTER_CHANGE ) {
+                       this.uriProcessor.updateURL( params );
+               }
+               if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
+                       this.changesListModel.invalidate();
+               }
+               this.changesListModel.setNewChangesExist( false );
+               this.updatingChangesList = true;
+               return this._fetchChangesList()
+                       .then(
+                               // Success
+                               function ( pieces ) {
+                                       var $changesListContent = pieces.changes,
+                                               $fieldset = pieces.fieldset;
+                                       this.changesListModel.update(
+                                               $changesListContent,
+                                               $fieldset,
+                                               pieces.noResultsDetails,
+                                               false,
+                                               // separator between old and new changes
+                                               updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
+                                       );
+                               }.bind( this )
+                               // Do nothing for failure
+                       )
+                       .always( function () {
+                               this.updatingChangesList = false;
+                       }.bind( this ) );
+       };
+
+       /**
+        * Get an object representing the default parameter state, whether
+        * it is from the model defaults or from the saved queries.
+        *
+        * @return {Object} Default parameters
+        */
+       Controller.prototype._getDefaultParams = function () {
+               if ( this.savedQueriesModel.getDefault() ) {
+                       return this.savedQueriesModel.getDefaultParams();
+               } else {
+                       return this.filtersModel.getDefaultParams();
+               }
+       };
+
+       /**
+        * Query the list of changes from the server for the current filters
+        *
+        * @param {string} counterId Id for this request. To allow concurrent requests
+        *  not to invalidate each other.
+        * @param {Object} [params={}] Parameters to add to the query
+        *
+        * @return {jQuery.Promise} Promise object resolved with { content, status }
+        */
+       Controller.prototype._queryChangesList = function ( counterId, params ) {
+               var uri = this.uriProcessor.getUpdatedUri(),
+                       stickyParams = this.filtersModel.getStickyParamsValues(),
+                       requestId,
+                       latestRequest;
+
+               params = params || {};
+               params.action = 'render'; // bypasses MW chrome
+
+               uri.extend( params );
+
+               this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
+               requestId = ++this.requestCounter[ counterId ];
+               latestRequest = function () {
+                       return requestId === this.requestCounter[ counterId ];
+               }.bind( this );
+
+               // Sticky parameters override the URL params
+               // this is to make sure that whether we represent
+               // the sticky params in the URL or not (they may
+               // be normalized out) the sticky parameters are
+               // always being sent to the server with their
+               // current/default values
+               uri.extend( stickyParams );
+
+               return $.ajax( uri.toString(), { contentType: 'html' } )
+                       .then(
+                               function ( content, message, jqXHR ) {
+                                       if ( !latestRequest() ) {
+                                               return $.Deferred().reject();
+                                       }
+                                       return {
+                                               content: content,
+                                               status: jqXHR.status
+                                       };
+                               },
+                               // RC returns 404 when there is no results
+                               function ( jqXHR ) {
+                                       if ( latestRequest() ) {
+                                               return $.Deferred().resolve(
+                                                       {
+                                                               content: jqXHR.responseText,
+                                                               status: jqXHR.status
+                                                       }
+                                               ).promise();
+                                       }
+                               }
+                       );
+       };
+
+       /**
+        * Fetch the list of changes from the server for the current filters
+        *
+        * @return {jQuery.Promise} Promise object that will resolve with the changes list
+        *  and the fieldset.
+        */
+       Controller.prototype._fetchChangesList = function () {
+               return this._queryChangesList( 'updateChangesList' )
+                       .then(
+                               function ( data ) {
+                                       var $parsed;
+
+                                       // Status code 0 is not HTTP status code,
+                                       // but is valid value of XMLHttpRequest status.
+                                       // It is used for variety of network errors, for example
+                                       // when an AJAX call was cancelled before getting the response
+                                       if ( data && data.status === 0 ) {
+                                               return {
+                                                       changes: 'NO_RESULTS',
+                                                       // We need empty result set, to avoid exceptions because of undefined value
+                                                       fieldset: $( [] ),
+                                                       noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
+                                               };
+                                       }
+
+                                       $parsed = $( '<div>' ).append( $( $.parseHTML(
+                                               data ? data.content : ''
+                                       ) ) );
+
+                                       return this._extractChangesListInfo( $parsed, data.status );
+                               }.bind( this )
+                       );
+       };
+
+       /**
+        * Track usage of highlight feature
+        *
+        * @param {string} action
+        * @param {Array|Object|string} filters
+        */
+       Controller.prototype._trackHighlight = function ( action, filters ) {
+               filters = typeof filters === 'string' ? { name: filters } : filters;
+               filters = !Array.isArray( filters ) ? [ filters ] : filters;
+               mw.track(
+                       'event.ChangesListHighlights',
+                       {
+                               action: action,
+                               filters: filters,
+                               userId: mw.user.getId()
+                       }
+               );
+       };
+
+       /**
+        * Track filter grouping usage
+        *
+        * @param {string} action Action taken
+        */
+       Controller.prototype.trackFilterGroupings = function ( action ) {
+               var controller = this,
+                       rightNow = new Date().getTime(),
+                       randomIdentifier = String( mw.user.sessionId() ) + String( rightNow ) + String( Math.random() ),
+                       // Get all current filters
+                       filters = this.filtersModel.findSelectedItems().map( function ( item ) {
+                               return item.getName();
+                       } );
+
+               action = action || 'filtermenu';
+
+               // Check if these filters were the ones we just logged previously
+               // (Don't log the same grouping twice, in case the user opens/closes)
+               // the menu without action, or with the same result
+               if (
+                       // Only log if the two arrays are different in size
+                       filters.length !== this.prevLoggedItems.length ||
+                       // Or if any filters are not the same as the cached filters
+                       filters.some( function ( filterName ) {
+                               return controller.prevLoggedItems.indexOf( filterName ) === -1;
+                       } ) ||
+                       // Or if any cached filters are not the same as given filters
+                       this.prevLoggedItems.some( function ( filterName ) {
+                               return filters.indexOf( filterName ) === -1;
+                       } )
+               ) {
+                       filters.forEach( function ( filterName ) {
+                               mw.track(
+                                       'event.ChangesListFilterGrouping',
+                                       {
+                                               action: action,
+                                               groupIdentifier: randomIdentifier,
+                                               filter: filterName,
+                                               userId: mw.user.getId()
+                                       }
+                               );
+                       } );
+
+                       // Cache the filter names
+                       this.prevLoggedItems = filters;
+               }
+       };
+
+       /**
+        * Apply a change of parameters to the model state, and check whether
+        * the new state is different than the old state.
+        *
+        * @param  {Object} newParamState New parameter state to apply
+        * @return {boolean} New applied model state is different than the previous state
+        */
+       Controller.prototype.applyParamChange = function ( newParamState ) {
+               var after,
+                       before = this.filtersModel.getSelectedState();
+
+               this.filtersModel.updateStateFromParams( newParamState );
+
+               after = this.filtersModel.getSelectedState();
+
+               return !OO.compare( before, after );
+       };
+
+       /**
+        * Mark all changes as seen on Watchlist
+        */
+       Controller.prototype.markAllChangesAsSeen = function () {
+               var api = new mw.Api();
+               api.postWithToken( 'csrf', {
+                       formatversion: 2,
+                       action: 'setnotificationtimestamp',
+                       entirewatchlist: true
+               } ).then( function () {
+                       this.updateChangesList( null, 'markSeen' );
+               }.bind( this ) );
+       };
+
+       /**
+        * Set the current search for the system.
+        *
+        * @param {string} searchQuery Search query, including triggers
+        */
+       Controller.prototype.setSearch = function ( searchQuery ) {
+               this.filtersModel.setSearch( searchQuery );
+       };
+
+       /**
+        * Switch the view by changing the search query trigger
+        * without changing the search term
+        *
+        * @param  {string} view View to change to
+        */
+       Controller.prototype.switchView = function ( view ) {
+               this.setSearch(
+                       this.filtersModel.getViewTrigger( view ) +
+                       this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
+               );
+       };
+
+       /**
+        * Reset the search for a specific view. This means we null the search query
+        * and replace it with the relevant trigger for the requested view
+        *
+        * @param  {string} [view='default'] View to change to
+        */
+       Controller.prototype.resetSearchForView = function ( view ) {
+               view = view || 'default';
+
+               this.setSearch(
+                       this.filtersModel.getViewTrigger( view )
+               );
+       };
+
+       module.exports = Controller;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/HighlightColors.js b/resources/src/mediawiki.rcfilters/HighlightColors.js
new file mode 100644 (file)
index 0000000..a4ef73b
--- /dev/null
@@ -0,0 +1,12 @@
+( function () {
+       /**
+        * Supported highlight colors.
+        * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
+        *
+        * @member mw.rcfilters
+        * @property {string[]}
+        */
+       var HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];
+
+       module.exports = HighlightColors;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/UriProcessor.js b/resources/src/mediawiki.rcfilters/UriProcessor.js
new file mode 100644 (file)
index 0000000..37874d5
--- /dev/null
@@ -0,0 +1,296 @@
+( function () {
+       /* eslint no-underscore-dangle: "off" */
+       /**
+        * URI Processor for RCFilters
+        *
+        * @class mw.rcfilters.UriProcessor
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
+        * @param {Object} [config] Configuration object
+        * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
+        *  title normalization to separate title subpage/parts into the target= url
+        *  parameter
+        */
+       var UriProcessor = function MwRcfiltersController( filtersModel, config ) {
+               config = config || {};
+               this.filtersModel = filtersModel;
+
+               this.normalizeTarget = !!config.normalizeTarget;
+       };
+
+       /* Initialization */
+       OO.initClass( UriProcessor );
+
+       /* Static methods */
+
+       /**
+        * Replace the url history through replaceState
+        *
+        * @param {mw.Uri} newUri New URI to replace
+        */
+       UriProcessor.static.replaceState = function ( newUri ) {
+               window.history.replaceState(
+                       { tag: 'rcfilters' },
+                       document.title,
+                       newUri.toString()
+               );
+       };
+
+       /**
+        * Push the url to history through pushState
+        *
+        * @param {mw.Uri} newUri New URI to push
+        */
+       UriProcessor.static.pushState = function ( newUri ) {
+               window.history.pushState(
+                       { tag: 'rcfilters' },
+                       document.title,
+                       newUri.toString()
+               );
+       };
+
+       /* Methods */
+
+       /**
+        * Get the version that this URL query is tagged with.
+        *
+        * @param {Object} [uriQuery] URI query
+        * @return {number} URL version
+        */
+       UriProcessor.prototype.getVersion = function ( uriQuery ) {
+               uriQuery = uriQuery || new mw.Uri().query;
+
+               return Number( uriQuery.urlversion || 1 );
+       };
+
+       /**
+        * Get an updated mw.Uri object based on the model state
+        *
+        * @param {mw.Uri} [uri] An external URI to build the new uri
+        *  with. This is mainly for tests, to be able to supply external query
+        *  parameters and make sure they are retained.
+        * @return {mw.Uri} Updated Uri
+        */
+       UriProcessor.prototype.getUpdatedUri = function ( uri ) {
+               var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
+                       unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );
+
+               normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
+                       $.extend(
+                               true,
+                               {},
+                               normalizedUri.query,
+                               // The representation must be expanded so it can
+                               // override the uri query params but we then output
+                               // a minimized version for the entire URI representation
+                               // for the method
+                               this.filtersModel.getExpandedParamRepresentation()
+                       )
+               );
+
+               // Reapply unrecognized params and url version
+               normalizedUri.query = $.extend(
+                       true,
+                       {},
+                       normalizedUri.query,
+                       unrecognizedParams,
+                       { urlversion: '2' }
+               );
+
+               return normalizedUri;
+       };
+
+       /**
+        * Move the subpage to the target parameter
+        *
+        * @param {mw.Uri} uri
+        * @return {mw.Uri}
+        * @private
+        */
+       UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
+               var parts,
+                       // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
+                       re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
+
+               if ( !this.normalizeTarget ) {
+                       return uri;
+               }
+
+               // target in title param
+               if ( uri.query.title ) {
+                       parts = uri.query.title.match( re );
+                       if ( parts ) {
+                               uri.query.title = parts[ 1 ];
+                               uri.query.target = parts[ 2 ];
+                       }
+               }
+
+               // target in path
+               parts = mw.Uri.decode( uri.path ).match( re );
+               if ( parts ) {
+                       uri.path = parts[ 1 ];
+                       uri.query.target = parts[ 2 ];
+               }
+
+               return uri;
+       };
+
+       /**
+        * Get an object representing given parameters that are unrecognized by the model
+        *
+        * @param  {Object} params Full params object
+        * @return {Object} Unrecognized params
+        */
+       UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
+               // Start with full representation
+               var givenParamNames = Object.keys( params ),
+                       unrecognizedParams = $.extend( true, {}, params );
+
+               // Extract unrecognized parameters
+               Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
+                       // Remove recognized params
+                       if ( givenParamNames.indexOf( paramName ) > -1 ) {
+                               delete unrecognizedParams[ paramName ];
+                       }
+               } );
+
+               return unrecognizedParams;
+       };
+
+       /**
+        * Update the URL of the page to reflect current filters
+        *
+        * This should not be called directly from outside the controller.
+        * If an action requires changing the URL, it should either use the
+        * highlighting actions below, or call #updateChangesList which does
+        * the uri corrections already.
+        *
+        * @param {Object} [params] Extra parameters to add to the API call
+        */
+       UriProcessor.prototype.updateURL = function ( params ) {
+               var currentUri = new mw.Uri(),
+                       updatedUri = this.getUpdatedUri();
+
+               updatedUri.extend( params || {} );
+
+               if (
+                       this.getVersion( currentUri.query ) !== 2 ||
+                       this.isNewState( currentUri.query, updatedUri.query )
+               ) {
+                       this.constructor.static.replaceState( updatedUri );
+               }
+       };
+
+       /**
+        * Update the filters model based on the URI query
+        * This happens on initialization, and from this moment on,
+        * we consider the system synchronized, and the model serves
+        * as the source of truth for the URL.
+        *
+        * This methods should only be called once on initialization.
+        * After initialization, the model updates the URL, not the
+        * other way around.
+        *
+        * @param {Object} [uriQuery] URI query
+        */
+       UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
+               uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
+               this.filtersModel.updateStateFromParams(
+                       this._getNormalizedQueryParams( uriQuery )
+               );
+       };
+
+       /**
+        * Compare two URI queries to decide whether they are different
+        * enough to represent a new state.
+        *
+        * @param {Object} currentUriQuery Current Uri query
+        * @param {Object} updatedUriQuery Updated Uri query
+        * @return {boolean} This is a new state
+        */
+       UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
+               var currentParamState, updatedParamState,
+                       notEquivalent = function ( obj1, obj2 ) {
+                               var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
+                               return keys.some( function ( key ) {
+                                       return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
+                               } );
+                       };
+
+               // Compare states instead of parameters
+               // This will allow us to always have a proper check of whether
+               // the requested new url is one to change or not, regardless of
+               // actual parameter visibility/representation in the URL
+               currentParamState = $.extend(
+                       true,
+                       {},
+                       this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
+                       this.getUnrecognizedParams( currentUriQuery )
+               );
+               updatedParamState = $.extend(
+                       true,
+                       {},
+                       this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
+                       this.getUnrecognizedParams( updatedUriQuery )
+               );
+
+               return notEquivalent( currentParamState, updatedParamState );
+       };
+
+       /**
+        * Check whether the given query has parameters that are
+        * recognized as parameters we should load the system with
+        *
+        * @param {mw.Uri} [uriQuery] Given URI query
+        * @return {boolean} Query contains valid recognized parameters
+        */
+       UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
+               var anyValidInUrl,
+                       validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );
+
+               uriQuery = uriQuery || new mw.Uri().query;
+
+               anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
+                       return validParameterNames.indexOf( parameter ) > -1;
+               } );
+
+               // URL version 2 is allowed to be empty or within nonrecognized params
+               return anyValidInUrl || this.getVersion( uriQuery ) === 2;
+       };
+
+       /**
+        * Get the adjusted URI params based on the url version
+        * If the urlversion is not 2, the parameters are merged with
+        * the model's defaults.
+        * Always merge in the hidden parameter defaults.
+        *
+        * @private
+        * @param {Object} uriQuery Current URI query
+        * @return {Object} Normalized parameters
+        */
+       UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
+               // Check whether we are dealing with urlversion=2
+               // If we are, we do not merge the initial request with
+               // defaults. Not having urlversion=2 means we need to
+               // reproduce the server-side request and merge the
+               // requested parameters (or starting state) with the
+               // wiki default.
+               // Any subsequent change of the URL through the RCFilters
+               // system will receive 'urlversion=2'
+               var base = this.getVersion( uriQuery ) === 2 ?
+                       {} :
+                       this.filtersModel.getDefaultParams();
+
+               return $.extend(
+                       true,
+                       {},
+                       this.filtersModel.getMinimizedParamRepresentation(
+                               $.extend( true, {}, base, uriQuery )
+                       ),
+                       { urlversion: '2' }
+               );
+       };
+
+       module.exports = UriProcessor;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js b/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js
new file mode 100644 (file)
index 0000000..64d2e79
--- /dev/null
@@ -0,0 +1,169 @@
+( function () {
+       /**
+        * View model for the changes list
+        *
+        * @class mw.rcfilters.dm.ChangesListViewModel
+        * @mixins OO.EventEmitter
+        *
+        * @param {jQuery} $initialFieldset The initial server-generated legacy form content
+        * @constructor
+        */
+       var ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               this.valid = true;
+               this.newChangesExist = false;
+               this.liveUpdate = false;
+               this.unseenWatchedChanges = false;
+
+               this.extractNextFrom( $initialFieldset );
+       };
+
+       /* Initialization */
+       OO.initClass( ChangesListViewModel );
+       OO.mixinClass( ChangesListViewModel, OO.EventEmitter );
+
+       /* Events */
+
+       /**
+        * @event invalidate
+        *
+        * The list of changes is now invalid (out of date)
+        */
+
+       /**
+        * @event update
+        * @param {jQuery|string} $changesListContent List of changes
+        * @param {jQuery} $fieldset Server-generated form
+        * @param {string} noResultsDetails Type of no result error
+        * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
+        * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
+        *
+        * The list of changes has been updated
+        */
+
+       /**
+        * @event newChangesExist
+        * @param {boolean} newChangesExist
+        *
+        * The existence of changes newer than those currently displayed has changed.
+        */
+
+       /**
+        * @event liveUpdateChange
+        * @param {boolean} enable
+        *
+        * The state of the 'live update' feature has changed.
+        */
+
+       /* Methods */
+
+       /**
+        * Invalidate the list of changes
+        *
+        * @fires invalidate
+        */
+       ChangesListViewModel.prototype.invalidate = function () {
+               if ( this.valid ) {
+                       this.valid = false;
+                       this.emit( 'invalidate' );
+               }
+       };
+
+       /**
+        * Update the model with an updated list of changes
+        *
+        * @param {jQuery|string} changesListContent
+        * @param {jQuery} $fieldset
+        * @param {string} noResultsDetails Type of no result error
+        * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
+        * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
+        * @fires update
+        */
+       ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
+               var from = this.nextFrom;
+               this.valid = true;
+               this.extractNextFrom( $fieldset );
+               this.checkForUnseenWatchedChanges( changesListContent );
+               this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
+       };
+
+       /**
+        * Specify whether new changes exist
+        *
+        * @param {boolean} newChangesExist
+        * @fires newChangesExist
+        */
+       ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
+               if ( newChangesExist !== this.newChangesExist ) {
+                       this.newChangesExist = newChangesExist;
+                       this.emit( 'newChangesExist', newChangesExist );
+               }
+       };
+
+       /**
+        * @return {boolean} Whether new changes exist
+        */
+       ChangesListViewModel.prototype.getNewChangesExist = function () {
+               return this.newChangesExist;
+       };
+
+       /**
+        * Extract the value of the 'from' parameter from a link in the field set
+        *
+        * @param {jQuery} $fieldset
+        */
+       ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
+               var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
+               if ( data && data.from ) {
+                       this.nextFrom = data.from;
+               }
+       };
+
+       /**
+        * @return {string} The 'from' parameter that can be used to query new changes
+        */
+       ChangesListViewModel.prototype.getNextFrom = function () {
+               return this.nextFrom;
+       };
+
+       /**
+        * Toggle the 'live update' feature on/off
+        *
+        * @param {boolean} enable
+        */
+       ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
+               enable = enable === undefined ? !this.liveUpdate : enable;
+               if ( enable !== this.liveUpdate ) {
+                       this.liveUpdate = enable;
+                       this.emit( 'liveUpdateChange', this.liveUpdate );
+               }
+       };
+
+       /**
+        * @return {boolean} The 'live update' feature is enabled
+        */
+       ChangesListViewModel.prototype.getLiveUpdate = function () {
+               return this.liveUpdate;
+       };
+
+       /**
+        * Check if some of the given changes watched and unseen
+        *
+        * @param {jQuery|string} changeslistContent
+        */
+       ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
+               this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
+                       changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
+       };
+
+       /**
+        * @return {boolean} Whether some of the current changes are watched and unseen
+        */
+       ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
+               return this.unseenWatchedChanges;
+       };
+
+       module.exports = ChangesListViewModel;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/FilterGroup.js b/resources/src/mediawiki.rcfilters/dm/FilterGroup.js
new file mode 100644 (file)
index 0000000..831e6eb
--- /dev/null
@@ -0,0 +1,994 @@
+( function () {
+       var FilterItem = require( './FilterItem.js' ),
+               FilterGroup;
+
+       /**
+        * View model for a filter group
+        *
+        * @class mw.rcfilters.dm.FilterGroup
+        * @mixins OO.EventEmitter
+        * @mixins OO.EmitterList
+        *
+        * @constructor
+        * @param {string} name Group name
+        * @param {Object} [config] Configuration options
+        * @cfg {string} [type='send_unselected_if_any'] Group type
+        * @cfg {string} [view='default'] Name of the display group this group
+        *  is a part of.
+        * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
+        *  with a preference, does not participate in Saved Queries, and is
+        *  not shown in the active filters area.
+        * @cfg {string} [title] Group title
+        * @cfg {boolean} [hidden] This group is hidden from the regular menu views
+        *  and the active filters area.
+        * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
+        *  group from the URL, even if it wasn't initially set up.
+        * @cfg {number} [range] An object defining minimum and maximum values for numeric
+        *  groups. { min: x, max: y }
+        * @cfg {number} [minValue] Minimum value for numeric groups
+        * @cfg {string} [separator='|'] Value separator for 'string_options' groups
+        * @cfg {boolean} [active] Group is active
+        * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
+        * @cfg {Object} [conflicts] Defines the conflicts for this filter group
+        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
+        *  with 'default' and 'inverted' as keys.
+        * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
+        * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
+        * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
+        * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
+        * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
+        * @cfg {boolean} [visible=true] The visibility of the group
+        */
+       FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
+               config = config || {};
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+               OO.EmitterList.call( this );
+
+               this.name = name;
+               this.type = config.type || 'send_unselected_if_any';
+               this.view = config.view || 'default';
+               this.sticky = !!config.sticky;
+               this.title = config.title || name;
+               this.hidden = !!config.hidden;
+               this.allowArbitrary = !!config.allowArbitrary;
+               this.numericRange = config.range;
+               this.separator = config.separator || '|';
+               this.labelPrefixKey = config.labelPrefixKey;
+               this.visible = config.visible === undefined ? true : !!config.visible;
+
+               this.currSelected = null;
+               this.active = !!config.active;
+               this.fullCoverage = !!config.fullCoverage;
+
+               this.whatsThis = config.whatsThis || {};
+
+               this.conflicts = config.conflicts || {};
+               this.defaultParams = {};
+               this.defaultFilters = {};
+
+               this.aggregate( { update: 'filterItemUpdate' } );
+               this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
+       };
+
+       /* Initialization */
+       OO.initClass( FilterGroup );
+       OO.mixinClass( FilterGroup, OO.EventEmitter );
+       OO.mixinClass( FilterGroup, OO.EmitterList );
+
+       /* Events */
+
+       /**
+        * @event update
+        *
+        * Group state has been updated
+        */
+
+       /* Methods */
+
+       /**
+        * Initialize the group and create its filter items
+        *
+        * @param {Object} filterDefinition Filter definition for this group
+        * @param {string|Object} [groupDefault] Definition of the group default
+        */
+       FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
+               var defaultParam,
+                       supersetMap = {},
+                       model = this,
+                       items = [];
+
+               filterDefinition.forEach( function ( filter ) {
+                       // Instantiate an item
+                       var subsetNames = [],
+                               filterItem = new FilterItem( filter.name, model, {
+                                       group: model.getName(),
+                                       label: filter.label || filter.name,
+                                       description: filter.description || '',
+                                       labelPrefixKey: model.labelPrefixKey,
+                                       cssClass: filter.cssClass,
+                                       identifiers: filter.identifiers,
+                                       defaultHighlightColor: filter.defaultHighlightColor
+                               } );
+
+                       if ( filter.subset ) {
+                               filter.subset = filter.subset.map( function ( el ) {
+                                       return el.filter;
+                               } );
+
+                               subsetNames = [];
+
+                               filter.subset.forEach( function ( subsetFilterName ) {
+                                       // Subsets (unlike conflicts) are always inside the same group
+                                       // We can re-map the names of the filters we are getting from
+                                       // the subsets with the group prefix
+                                       var subsetName = model.getPrefixedName( subsetFilterName );
+                                       // For convenience, we should store each filter's "supersets" -- these are
+                                       // the filters that have that item in their subset list. This will just
+                                       // make it easier to go through whether the item has any other items
+                                       // that affect it (and are selected) at any given time
+                                       supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
+                                       mw.rcfilters.utils.addArrayElementsUnique(
+                                               supersetMap[ subsetName ],
+                                               filterItem.getName()
+                                       );
+
+                                       // Translate subset param name to add the group name, so we
+                                       // get consistent naming. We know that subsets are only within
+                                       // the same group
+                                       subsetNames.push( subsetName );
+                               } );
+
+                               // Set translated subset
+                               filterItem.setSubset( subsetNames );
+                       }
+
+                       items.push( filterItem );
+
+                       // Store default parameter state; in this case, default is defined per filter
+                       if (
+                               model.getType() === 'send_unselected_if_any' ||
+                               model.getType() === 'boolean'
+                       ) {
+                               // Store the default parameter state
+                               // For this group type, parameter values are direct
+                               // We need to convert from a boolean to a string ('1' and '0')
+                               model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
+                       } else if ( model.getType() === 'any_value' ) {
+                               model.defaultParams[ filter.name ] = filter.default;
+                       }
+               } );
+
+               // Add items
+               this.addItems( items );
+
+               // Now that we have all items, we can apply the superset map
+               this.getItems().forEach( function ( filterItem ) {
+                       filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
+               } );
+
+               // Store default parameter state; in this case, default is defined per the
+               // entire group, given by groupDefault method parameter
+               if ( this.getType() === 'string_options' ) {
+                       // Store the default parameter group state
+                       // For this group, the parameter is group name and value is the names
+                       // of selected items
+                       this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
+                               // Current values
+                               groupDefault ?
+                                       groupDefault.split( this.getSeparator() ) :
+                                       [],
+                               // Legal values
+                               this.getItems().map( function ( item ) {
+                                       return item.getParamName();
+                               } )
+                       ).join( this.getSeparator() );
+               } else if ( this.getType() === 'single_option' ) {
+                       defaultParam = groupDefault !== undefined ?
+                               groupDefault : this.getItems()[ 0 ].getParamName();
+
+                       // For this group, the parameter is the group name,
+                       // and a single item can be selected: default or first item
+                       this.defaultParams[ this.getName() ] = defaultParam;
+               }
+
+               // add highlights to defaultParams
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( filterItem.isHighlighted() ) {
+                               this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
+                       }
+               }.bind( this ) );
+
+               // Store default filter state based on default params
+               this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
+
+               // Check for filters that should be initially selected by their default value
+               if ( this.isSticky() ) {
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( this.defaultFilters, function ( filterName, filterValue ) {
+                               model.getItemByName( filterName ).toggleSelected( filterValue );
+                       } );
+               }
+
+               // Verify that single_option group has at least one item selected
+               if (
+                       this.getType() === 'single_option' &&
+                       this.findSelectedItems().length === 0
+               ) {
+                       defaultParam = groupDefault !== undefined ?
+                               groupDefault : this.getItems()[ 0 ].getParamName();
+
+                       // Single option means there must be a single option
+                       // selected, so we have to either select the default
+                       // or select the first option
+                       this.selectItemByParamName( defaultParam );
+               }
+       };
+
+       /**
+        * Respond to filterItem update event
+        *
+        * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
+        * @fires update
+        */
+       FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
+               // Update state
+               var changed = false,
+                       active = this.areAnySelected(),
+                       model = this;
+
+               if ( this.getType() === 'single_option' ) {
+                       // This group must have one item selected always
+                       // and must never have more than one item selected at a time
+                       if ( this.findSelectedItems().length === 0 ) {
+                               // Nothing is selected anymore
+                               // Select the default or the first item
+                               this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
+                                       this.getItems()[ 0 ];
+                               this.currSelected.toggleSelected( true );
+                               changed = true;
+                       } else if ( this.findSelectedItems().length > 1 ) {
+                               // There is more than one item selected
+                               // This should only happen if the item given
+                               // is the one that is selected, so unselect
+                               // all items that is not it
+                               this.findSelectedItems().forEach( function ( itemModel ) {
+                                       // Note that in case the given item is actually
+                                       // not selected, this loop will end up unselecting
+                                       // all items, which would trigger the case above
+                                       // when the last item is unselected anyways
+                                       var selected = itemModel.getName() === item.getName() &&
+                                               item.isSelected();
+
+                                       itemModel.toggleSelected( selected );
+                                       if ( selected ) {
+                                               model.currSelected = itemModel;
+                                       }
+                               } );
+                               changed = true;
+                       }
+               }
+
+               if ( this.isSticky() ) {
+                       // If this group is sticky, then change the default according to the
+                       // current selection.
+                       this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
+               }
+
+               if (
+                       changed ||
+                       this.active !== active ||
+                       this.currSelected !== item
+               ) {
+                       this.active = active;
+                       this.currSelected = item;
+
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Get group active state
+        *
+        * @return {boolean} Active state
+        */
+       FilterGroup.prototype.isActive = function () {
+               return this.active;
+       };
+
+       /**
+        * Get group hidden state
+        *
+        * @return {boolean} Hidden state
+        */
+       FilterGroup.prototype.isHidden = function () {
+               return this.hidden;
+       };
+
+       /**
+        * Get group allow arbitrary state
+        *
+        * @return {boolean} Group allows an arbitrary value from the URL
+        */
+       FilterGroup.prototype.isAllowArbitrary = function () {
+               return this.allowArbitrary;
+       };
+
+       /**
+        * Get group maximum value for numeric groups
+        *
+        * @return {number|null} Group max value
+        */
+       FilterGroup.prototype.getMaxValue = function () {
+               return this.numericRange && this.numericRange.max !== undefined ?
+                       this.numericRange.max : null;
+       };
+
+       /**
+        * Get group minimum value for numeric groups
+        *
+        * @return {number|null} Group max value
+        */
+       FilterGroup.prototype.getMinValue = function () {
+               return this.numericRange && this.numericRange.min !== undefined ?
+                       this.numericRange.min : null;
+       };
+
+       /**
+        * Get group name
+        *
+        * @return {string} Group name
+        */
+       FilterGroup.prototype.getName = function () {
+               return this.name;
+       };
+
+       /**
+        * Get the default param state of this group
+        *
+        * @return {Object} Default param state
+        */
+       FilterGroup.prototype.getDefaultParams = function () {
+               return this.defaultParams;
+       };
+
+       /**
+        * Get the default filter state of this group
+        *
+        * @return {Object} Default filter state
+        */
+       FilterGroup.prototype.getDefaultFilters = function () {
+               return this.defaultFilters;
+       };
+
+       /**
+        * This is for a single_option and string_options group types
+        * it returns the value of the default
+        *
+        * @return {string} Value of the default
+        */
+       FilterGroup.prototype.getDefaulParamValue = function () {
+               return this.defaultParams[ this.getName() ];
+       };
+       /**
+        * Get the messags defining the 'whats this' popup for this group
+        *
+        * @return {Object} What's this messages
+        */
+       FilterGroup.prototype.getWhatsThis = function () {
+               return this.whatsThis;
+       };
+
+       /**
+        * Check whether this group has a 'what's this' message
+        *
+        * @return {boolean} This group has a what's this message
+        */
+       FilterGroup.prototype.hasWhatsThis = function () {
+               return !!this.whatsThis.body;
+       };
+
+       /**
+        * Get the conflicts associated with the entire group.
+        * Conflict object is set up by filter name keys and conflict
+        * definition. For example:
+        * [
+        *     {
+        *         filterName: {
+        *             filter: filterName,
+        *             group: group1
+        *         }
+        *     },
+        *     {
+        *         filterName2: {
+        *             filter: filterName2,
+        *             group: group2
+        *         }
+        *     }
+        * ]
+        * @return {Object} Conflict definition
+        */
+       FilterGroup.prototype.getConflicts = function () {
+               return this.conflicts;
+       };
+
+       /**
+        * Set conflicts for this group. See #getConflicts for the expected
+        * structure of the definition.
+        *
+        * @param {Object} conflicts Conflicts for this group
+        */
+       FilterGroup.prototype.setConflicts = function ( conflicts ) {
+               this.conflicts = conflicts;
+       };
+
+       /**
+        * Set conflicts for each filter item in the group based on the
+        * given conflict map
+        *
+        * @param {Object} conflicts Object representing the conflict map,
+        *  keyed by the item name, where its value is an object for all its conflicts
+        */
+       FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( conflicts[ filterItem.getName() ] ) {
+                               filterItem.setConflicts( conflicts[ filterItem.getName() ] );
+                       }
+               } );
+       };
+
+       /**
+        * Check whether this item has a potential conflict with the given item
+        *
+        * This checks whether the given item is in the list of conflicts of
+        * the current item, but makes no judgment about whether the conflict
+        * is currently at play (either one of the items may not be selected)
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+        * @return {boolean} This item has a conflict with the given item
+        */
+       FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
+               return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+       };
+
+       /**
+        * Check whether there are any items selected
+        *
+        * @return {boolean} Any items in the group are selected
+        */
+       FilterGroup.prototype.areAnySelected = function () {
+               return this.getItems().some( function ( filterItem ) {
+                       return filterItem.isSelected();
+               } );
+       };
+
+       /**
+        * Check whether all items selected
+        *
+        * @return {boolean} All items are selected
+        */
+       FilterGroup.prototype.areAllSelected = function () {
+               var selected = [],
+                       unselected = [];
+
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( filterItem.isSelected() ) {
+                               selected.push( filterItem );
+                       } else {
+                               unselected.push( filterItem );
+                       }
+               } );
+
+               if ( unselected.length === 0 ) {
+                       return true;
+               }
+
+               // check if every unselected is a subset of a selected
+               return unselected.every( function ( unselectedFilterItem ) {
+                       return selected.some( function ( selectedFilterItem ) {
+                               return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
+                       } );
+               } );
+       };
+
+       /**
+        * Get all selected items in this group
+        *
+        * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
+        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+        */
+       FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
+               var excludeName = ( excludeItem && excludeItem.getName() ) || '';
+
+               return this.getItems().filter( function ( item ) {
+                       return item.getName() !== excludeName && item.isSelected();
+               } );
+       };
+
+       /**
+        * Check whether all selected items are in conflict with the given item
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+        * @return {boolean} All selected items are in conflict with this item
+        */
+       FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
+               var selectedItems = this.findSelectedItems( filterItem );
+
+               return selectedItems.length > 0 &&
+                       (
+                               // The group as a whole is in conflict with this item
+                               this.existsInConflicts( filterItem ) ||
+                               // All selected items are in conflict individually
+                               selectedItems.every( function ( selectedFilter ) {
+                                       return selectedFilter.existsInConflicts( filterItem );
+                               } )
+                       );
+       };
+
+       /**
+        * Check whether any of the selected items are in conflict with the given item
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+        * @return {boolean} Any of the selected items are in conflict with this item
+        */
+       FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
+               var selectedItems = this.findSelectedItems( filterItem );
+
+               return selectedItems.length > 0 && (
+                       // The group as a whole is in conflict with this item
+                       this.existsInConflicts( filterItem ) ||
+                       // Any selected items are in conflict individually
+                       selectedItems.some( function ( selectedFilter ) {
+                               return selectedFilter.existsInConflicts( filterItem );
+                       } )
+               );
+       };
+
+       /**
+        * Get the parameter representation from this group
+        *
+        * @param {Object} [filterRepresentation] An object defining the state
+        *  of the filters in this group, keyed by their name and current selected
+        *  state value.
+        * @return {Object} Parameter representation
+        */
+       FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
+               var values,
+                       areAnySelected = false,
+                       buildFromCurrentState = !filterRepresentation,
+                       defaultFilters = this.getDefaultFilters(),
+                       result = {},
+                       model = this,
+                       filterParamNames = {},
+                       getSelectedParameter = function ( filters ) {
+                               var item,
+                                       selected = [];
+
+                               // Find if any are selected
+                               // eslint-disable-next-line jquery/no-each-util
+                               $.each( filters, function ( name, value ) {
+                                       if ( value ) {
+                                               selected.push( name );
+                                       }
+                               } );
+
+                               item = model.getItemByName( selected[ 0 ] );
+                               return ( item && item.getParamName() ) || '';
+                       };
+
+               filterRepresentation = filterRepresentation || {};
+
+               // Create or complete the filterRepresentation definition
+               this.getItems().forEach( function ( item ) {
+                       // Map filter names to their parameter names
+                       filterParamNames[ item.getName() ] = item.getParamName();
+
+                       if ( buildFromCurrentState ) {
+                               // This means we have not been given a filter representation
+                               // so we are building one based on current state
+                               filterRepresentation[ item.getName() ] = item.getValue();
+                       } else if ( filterRepresentation[ item.getName() ] === undefined ) {
+                               // We are given a filter representation, but we have to make
+                               // sure that we fill in the missing filters if there are any
+                               // we will assume they are all falsey
+                               if ( model.isSticky() ) {
+                                       filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
+                               } else {
+                                       filterRepresentation[ item.getName() ] = false;
+                               }
+                       }
+
+                       if ( filterRepresentation[ item.getName() ] ) {
+                               areAnySelected = true;
+                       }
+               } );
+
+               // Build result
+               if (
+                       this.getType() === 'send_unselected_if_any' ||
+                       this.getType() === 'boolean' ||
+                       this.getType() === 'any_value'
+               ) {
+                       // First, check if any of the items are selected at all.
+                       // If none is selected, we're treating it as if they are
+                       // all false
+
+                       // Go over the items and define the correct values
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( filterRepresentation, function ( name, value ) {
+                               // We must store all parameter values as strings '0' or '1'
+                               if ( model.getType() === 'send_unselected_if_any' ) {
+                                       result[ filterParamNames[ name ] ] = areAnySelected ?
+                                               String( Number( !value ) ) :
+                                               '0';
+                               } else if ( model.getType() === 'boolean' ) {
+                                       // Representation is straight-forward and direct from
+                                       // the parameter value to the filter state
+                                       result[ filterParamNames[ name ] ] = String( Number( !!value ) );
+                               } else if ( model.getType() === 'any_value' ) {
+                                       result[ filterParamNames[ name ] ] = value;
+                               }
+                       } );
+               } else if ( this.getType() === 'string_options' ) {
+                       values = [];
+
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( filterRepresentation, function ( name, value ) {
+                               // Collect values
+                               if ( value ) {
+                                       values.push( filterParamNames[ name ] );
+                               }
+                       } );
+
+                       result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
+                               'all' : values.join( this.getSeparator() );
+               } else if ( this.getType() === 'single_option' ) {
+                       result[ this.getName() ] = getSelectedParameter( filterRepresentation );
+               }
+
+               return result;
+       };
+
+       /**
+        * Get the filter representation this group would provide
+        * based on given parameter states.
+        *
+        * @param {Object} [paramRepresentation] An object defining a parameter
+        *  state to translate the filter state from. If not given, an object
+        *  representing all filters as falsey is returned; same as if the parameter
+        *  given were an empty object, or had some of the filters missing.
+        * @return {Object} Filter representation
+        */
+       FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
+               var areAnySelected, paramValues, item, currentValue,
+                       oneWasSelected = false,
+                       defaultParams = this.getDefaultParams(),
+                       expandedParams = $.extend( true, {}, paramRepresentation ),
+                       model = this,
+                       paramToFilterMap = {},
+                       result = {};
+
+               if ( this.isSticky() ) {
+                       // If the group is sticky, check if all parameters are represented
+                       // and for those that aren't represented, add them with their default
+                       // values
+                       paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
+               }
+
+               paramRepresentation = paramRepresentation || {};
+               if (
+                       this.getType() === 'send_unselected_if_any' ||
+                       this.getType() === 'boolean' ||
+                       this.getType() === 'any_value'
+               ) {
+                       // Go over param representation; map and check for selections
+                       this.getItems().forEach( function ( filterItem ) {
+                               var paramName = filterItem.getParamName();
+
+                               expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
+                               paramToFilterMap[ paramName ] = filterItem;
+
+                               if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
+                                       areAnySelected = true;
+                               }
+                       } );
+
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( expandedParams, function ( paramName, paramValue ) {
+                               var filterItem = paramToFilterMap[ paramName ];
+
+                               if ( model.getType() === 'send_unselected_if_any' ) {
+                                       // Flip the definition between the parameter
+                                       // state and the filter state
+                                       // This is what the 'toggleSelected' value of the filter is
+                                       result[ filterItem.getName() ] = areAnySelected ?
+                                               !Number( paramValue ) :
+                                               // Otherwise, there are no selected items in the
+                                               // group, which means the state is false
+                                               false;
+                               } else if ( model.getType() === 'boolean' ) {
+                                       // Straight-forward definition of state
+                                       result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
+                               } else if ( model.getType() === 'any_value' ) {
+                                       result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
+                               }
+                       } );
+               } else if ( this.getType() === 'string_options' ) {
+                       currentValue = paramRepresentation[ this.getName() ] || '';
+
+                       // Normalize the given parameter values
+                       paramValues = mw.rcfilters.utils.normalizeParamOptions(
+                               // Given
+                               currentValue.split(
+                                       this.getSeparator()
+                               ),
+                               // Allowed values
+                               this.getItems().map( function ( filterItem ) {
+                                       return filterItem.getParamName();
+                               } )
+                       );
+                       // Translate the parameter values into a filter selection state
+                       this.getItems().forEach( function ( filterItem ) {
+                               // All true (either because all values are written or the term 'all' is written)
+                               // is the same as all filters set to true
+                               result[ filterItem.getName() ] = (
+                                       // If it is the word 'all'
+                                       paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
+                                       // All values are written
+                                       paramValues.length === model.getItemCount()
+                               ) ?
+                                       true :
+                                       // Otherwise, the filter is selected only if it appears in the parameter values
+                                       paramValues.indexOf( filterItem.getParamName() ) > -1;
+                       } );
+               } else if ( this.getType() === 'single_option' ) {
+                       // There is parameter that fits a single filter and if not, get the default
+                       this.getItems().forEach( function ( filterItem ) {
+                               var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];
+
+                               result[ filterItem.getName() ] = selected;
+                               oneWasSelected = oneWasSelected || selected;
+                       } );
+               }
+
+               // Go over result and make sure all filters are represented.
+               // If any filters are missing, they will get a falsey value
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( result[ filterItem.getName() ] === undefined ) {
+                               result[ filterItem.getName() ] = this.getFalsyValue();
+                       }
+               }.bind( this ) );
+
+               // Make sure that at least one option is selected in
+               // single_option groups, no matter what path was taken
+               // If none was selected by the given definition, then
+               // we need to select the one in the base state -- either
+               // the default given, or the first item
+               if (
+                       this.getType() === 'single_option' &&
+                       !oneWasSelected
+               ) {
+                       item = this.getItems()[ 0 ];
+                       if ( defaultParams[ this.getName() ] ) {
+                               item = this.getItemByParamName( defaultParams[ this.getName() ] );
+                       }
+
+                       result[ item.getName() ] = true;
+               }
+
+               return result;
+       };
+
+       /**
+        * @return {*} The appropriate falsy value for this group type
+        */
+       FilterGroup.prototype.getFalsyValue = function () {
+               return this.getType() === 'any_value' ? '' : false;
+       };
+
+       /**
+        * Get current selected state of all filter items in this group
+        *
+        * @return {Object} Selected state
+        */
+       FilterGroup.prototype.getSelectedState = function () {
+               var state = {};
+
+               this.getItems().forEach( function ( filterItem ) {
+                       state[ filterItem.getName() ] = filterItem.getValue();
+               } );
+
+               return state;
+       };
+
+       /**
+        * Get item by its filter name
+        *
+        * @param {string} filterName Filter name
+        * @return {mw.rcfilters.dm.FilterItem} Filter item
+        */
+       FilterGroup.prototype.getItemByName = function ( filterName ) {
+               return this.getItems().filter( function ( item ) {
+                       return item.getName() === filterName;
+               } )[ 0 ];
+       };
+
+       /**
+        * Select an item by its parameter name
+        *
+        * @param {string} paramName Filter parameter name
+        */
+       FilterGroup.prototype.selectItemByParamName = function ( paramName ) {
+               this.getItems().forEach( function ( item ) {
+                       item.toggleSelected( item.getParamName() === String( paramName ) );
+               } );
+       };
+
+       /**
+        * Get item by its parameter name
+        *
+        * @param {string} paramName Parameter name
+        * @return {mw.rcfilters.dm.FilterItem} Filter item
+        */
+       FilterGroup.prototype.getItemByParamName = function ( paramName ) {
+               return this.getItems().filter( function ( item ) {
+                       return item.getParamName() === String( paramName );
+               } )[ 0 ];
+       };
+
+       /**
+        * Get group type
+        *
+        * @return {string} Group type
+        */
+       FilterGroup.prototype.getType = function () {
+               return this.type;
+       };
+
+       /**
+        * Check whether this group is represented by a single parameter
+        * or whether each item is its own parameter
+        *
+        * @return {boolean} This group is a single parameter
+        */
+       FilterGroup.prototype.isPerGroupRequestParameter = function () {
+               return (
+                       this.getType() === 'string_options' ||
+                       this.getType() === 'single_option'
+               );
+       };
+
+       /**
+        * Get display group
+        *
+        * @return {string} Display group
+        */
+       FilterGroup.prototype.getView = function () {
+               return this.view;
+       };
+
+       /**
+        * Get the prefix used for the filter names inside this group.
+        *
+        * @param {string} [name] Filter name to prefix
+        * @return {string} Group prefix
+        */
+       FilterGroup.prototype.getNamePrefix = function () {
+               return this.getName() + '__';
+       };
+
+       /**
+        * Get a filter name with the prefix used for the filter names inside this group.
+        *
+        * @param {string} name Filter name to prefix
+        * @return {string} Group prefix
+        */
+       FilterGroup.prototype.getPrefixedName = function ( name ) {
+               return this.getNamePrefix() + name;
+       };
+
+       /**
+        * Get group's title
+        *
+        * @return {string} Title
+        */
+       FilterGroup.prototype.getTitle = function () {
+               return this.title;
+       };
+
+       /**
+        * Get group's values separator
+        *
+        * @return {string} Values separator
+        */
+       FilterGroup.prototype.getSeparator = function () {
+               return this.separator;
+       };
+
+       /**
+        * Check whether the group is defined as full coverage
+        *
+        * @return {boolean} Group is full coverage
+        */
+       FilterGroup.prototype.isFullCoverage = function () {
+               return this.fullCoverage;
+       };
+
+       /**
+        * Check whether the group is defined as sticky default
+        *
+        * @return {boolean} Group is sticky default
+        */
+       FilterGroup.prototype.isSticky = function () {
+               return this.sticky;
+       };
+
+       /**
+        * Normalize a value given to this group. This is mostly for correcting
+        * arbitrary values for 'single option' groups, given by the user settings
+        * or the URL that can go outside the limits that are allowed.
+        *
+        * @param  {string} value Given value
+        * @return {string} Corrected value
+        */
+       FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
+               if (
+                       this.getType() === 'single_option' &&
+                       this.isAllowArbitrary()
+               ) {
+                       if (
+                               this.getMaxValue() !== null &&
+                               value > this.getMaxValue()
+                       ) {
+                               // Change the value to the actual max value
+                               return String( this.getMaxValue() );
+                       } else if (
+                               this.getMinValue() !== null &&
+                               value < this.getMinValue()
+                       ) {
+                               // Change the value to the actual min value
+                               return String( this.getMinValue() );
+                       }
+               }
+
+               return value;
+       };
+
+       /**
+        * Toggle the visibility of this group
+        *
+        * @param {boolean} [isVisible] Item is visible
+        */
+       FilterGroup.prototype.toggleVisible = function ( isVisible ) {
+               isVisible = isVisible === undefined ? !this.visible : isVisible;
+
+               if ( this.visible !== isVisible ) {
+                       this.visible = isVisible;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Check whether the group is visible
+        *
+        * @return {boolean} Group is visible
+        */
+       FilterGroup.prototype.isVisible = function () {
+               return this.visible;
+       };
+
+       /**
+        * Set the visibility of the items under this group by the given items array
+        *
+        * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
+        */
+       FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
+               this.getItems().forEach( function ( itemModel ) {
+                       itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
+               } );
+       };
+
+       module.exports = FilterGroup;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/FilterItem.js b/resources/src/mediawiki.rcfilters/dm/FilterItem.js
new file mode 100644 (file)
index 0000000..3e11d1e
--- /dev/null
@@ -0,0 +1,406 @@
+( function () {
+       var ItemModel = require( './ItemModel.js' ),
+               FilterItem;
+
+       /**
+        * Filter item model
+        *
+        * @class mw.rcfilters.dm.FilterItem
+        * @extends mw.rcfilters.dm.ItemModel
+        *
+        * @constructor
+        * @param {string} param Filter param name
+        * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
+        * @param {Object} config Configuration object
+        * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
+        *  selected, makes inactive.
+        * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
+        * @cfg {Object} [conflicts] Defines the conflicts for this filter
+        * @cfg {boolean} [visible=true] The visibility of the group
+        */
+       FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
+               config = config || {};
+
+               this.groupModel = groupModel;
+
+               // Parent
+               FilterItem.parent.call( this, param, $.extend( {
+                       namePrefix: this.groupModel.getNamePrefix()
+               }, config ) );
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               // Interaction definitions
+               this.subset = config.subset || [];
+               this.conflicts = config.conflicts || {};
+               this.superset = [];
+               this.visible = config.visible === undefined ? true : !!config.visible;
+
+               // Interaction states
+               this.included = false;
+               this.conflicted = false;
+               this.fullyCovered = false;
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterItem, ItemModel );
+
+       /* Methods */
+
+       /**
+        * Return the representation of the state of this item.
+        *
+        * @return {Object} State of the object
+        */
+       FilterItem.prototype.getState = function () {
+               return {
+                       selected: this.isSelected(),
+                       included: this.isIncluded(),
+                       conflicted: this.isConflicted(),
+                       fullyCovered: this.isFullyCovered()
+               };
+       };
+
+       /**
+        * Get the message for the display area for the currently active conflict
+        *
+        * @private
+        * @return {string} Conflict result message key
+        */
+       FilterItem.prototype.getCurrentConflictResultMessage = function () {
+               var details = {};
+
+               // First look in filter's own conflicts
+               details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
+               if ( !details.message ) {
+                       // Fall back onto conflicts in the group
+                       details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
+               }
+
+               return details.message;
+       };
+
+       /**
+        * Get the details of the active conflict on this filter
+        *
+        * @private
+        * @param {Object} conflicts Conflicts to examine
+        * @param {string} [key='contextDescription'] Message key
+        * @return {Object} Object with conflict message and conflict items
+        * @return {string} return.message Conflict message
+        * @return {string[]} return.names Conflicting item labels
+        */
+       FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
+               var group,
+                       conflictMessage = '',
+                       itemLabels = [];
+
+               key = key || 'contextDescription';
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( conflicts, function ( filterName, conflict ) {
+                       if ( !conflict.item.isSelected() ) {
+                               return;
+                       }
+
+                       if ( !conflictMessage ) {
+                               conflictMessage = conflict[ key ];
+                               group = conflict.group;
+                       }
+
+                       if ( group === conflict.group ) {
+                               itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
+                       }
+               } );
+
+               return {
+                       message: conflictMessage,
+                       names: itemLabels
+               };
+
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterItem.prototype.getStateMessage = function () {
+               var messageKey, details, superset,
+                       affectingItems = [];
+
+               if ( this.isSelected() ) {
+                       if ( this.isConflicted() ) {
+                               // First look in filter's own conflicts
+                               details = this.getConflictDetails( this.getOwnConflicts() );
+                               if ( !details.message ) {
+                                       // Fall back onto conflicts in the group
+                                       details = this.getConflictDetails( this.getGroupModel().getConflicts() );
+                               }
+
+                               messageKey = details.message;
+                               affectingItems = details.names;
+                       } else if ( this.isIncluded() && !this.isHighlighted() ) {
+                               // We only show the 'no effect' full-coverage message
+                               // if the item is also not highlighted. See T161273
+                               superset = this.getSuperset();
+                               // For this message we need to collect the affecting superset
+                               affectingItems = this.getGroupModel().findSelectedItems( this )
+                                       .filter( function ( item ) {
+                                               return superset.indexOf( item.getName() ) !== -1;
+                                       } )
+                                       .map( function ( item ) {
+                                               return mw.msg( 'quotation-marks', item.getLabel() );
+                                       } );
+
+                               messageKey = 'rcfilters-state-message-subset';
+                       } else if ( this.isFullyCovered() && !this.isHighlighted() ) {
+                               affectingItems = this.getGroupModel().findSelectedItems( this )
+                                       .map( function ( item ) {
+                                               return mw.msg( 'quotation-marks', item.getLabel() );
+                                       } );
+
+                               messageKey = 'rcfilters-state-message-fullcoverage';
+                       }
+               }
+
+               if ( messageKey ) {
+                       // Build message
+                       return mw.msg(
+                               messageKey,
+                               mw.language.listToText( affectingItems ),
+                               affectingItems.length
+                       );
+               }
+
+               // Display description
+               return this.getDescription();
+       };
+
+       /**
+        * Get the model of the group this filter belongs to
+        *
+        * @return {mw.rcfilters.dm.FilterGroup} Filter group model
+        */
+       FilterItem.prototype.getGroupModel = function () {
+               return this.groupModel;
+       };
+
+       /**
+        * Get the group name this filter belongs to
+        *
+        * @return {string} Filter group name
+        */
+       FilterItem.prototype.getGroupName = function () {
+               return this.groupModel.getName();
+       };
+
+       /**
+        * Get filter subset
+        * This is a list of filter names that are defined to be included
+        * when this filter is selected.
+        *
+        * @return {string[]} Filter subset
+        */
+       FilterItem.prototype.getSubset = function () {
+               return this.subset;
+       };
+
+       /**
+        * Get filter superset
+        * This is a generated list of filters that define this filter
+        * to be included when either of them is selected.
+        *
+        * @return {string[]} Filter superset
+        */
+       FilterItem.prototype.getSuperset = function () {
+               return this.superset;
+       };
+
+       /**
+        * Check whether the filter is currently in a conflict state
+        *
+        * @return {boolean} Filter is in conflict state
+        */
+       FilterItem.prototype.isConflicted = function () {
+               return this.conflicted;
+       };
+
+       /**
+        * Check whether the filter is currently in an already included subset
+        *
+        * @return {boolean} Filter is in an already-included subset
+        */
+       FilterItem.prototype.isIncluded = function () {
+               return this.included;
+       };
+
+       /**
+        * Check whether the filter is currently fully covered
+        *
+        * @return {boolean} Filter is in fully-covered state
+        */
+       FilterItem.prototype.isFullyCovered = function () {
+               return this.fullyCovered;
+       };
+
+       /**
+        * Get all conflicts associated with this filter or its group
+        *
+        * Conflict object is set up by filter name keys and conflict
+        * definition. For example:
+        *
+        *  {
+        *      filterName: {
+        *          filter: filterName,
+        *          group: group1,
+        *          label: itemLabel,
+        *          item: itemModel
+        *      }
+        *      filterName2: {
+        *          filter: filterName2,
+        *          group: group2
+        *          label: itemLabel2,
+        *          item: itemModel2
+        *      }
+        *  }
+        *
+        * @return {Object} Filter conflicts
+        */
+       FilterItem.prototype.getConflicts = function () {
+               return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
+       };
+
+       /**
+        * Get the conflicts associated with this filter
+        *
+        * @return {Object} Filter conflicts
+        */
+       FilterItem.prototype.getOwnConflicts = function () {
+               return this.conflicts;
+       };
+
+       /**
+        * Set conflicts for this filter. See #getConflicts for the expected
+        * structure of the definition.
+        *
+        * @param {Object} conflicts Conflicts for this filter
+        */
+       FilterItem.prototype.setConflicts = function ( conflicts ) {
+               this.conflicts = conflicts || {};
+       };
+
+       /**
+        * Set filter superset
+        *
+        * @param {string[]} superset Filter superset
+        */
+       FilterItem.prototype.setSuperset = function ( superset ) {
+               this.superset = superset || [];
+       };
+
+       /**
+        * Set filter subset
+        *
+        * @param {string[]} subset Filter subset
+        */
+       FilterItem.prototype.setSubset = function ( subset ) {
+               this.subset = subset || [];
+       };
+
+       /**
+        * Check whether a filter exists in the subset list for this filter
+        *
+        * @param {string} filterName Filter name
+        * @return {boolean} Filter name is in the subset list
+        */
+       FilterItem.prototype.existsInSubset = function ( filterName ) {
+               return this.subset.indexOf( filterName ) > -1;
+       };
+
+       /**
+        * Check whether this item has a potential conflict with the given item
+        *
+        * This checks whether the given item is in the list of conflicts of
+        * the current item, but makes no judgment about whether the conflict
+        * is currently at play (either one of the items may not be selected)
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+        * @return {boolean} This item has a conflict with the given item
+        */
+       FilterItem.prototype.existsInConflicts = function ( filterItem ) {
+               return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+       };
+
+       /**
+        * Set the state of this filter as being conflicted
+        * (This means any filters in its conflicts are selected)
+        *
+        * @param {boolean} [conflicted] Filter is in conflict state
+        * @fires update
+        */
+       FilterItem.prototype.toggleConflicted = function ( conflicted ) {
+               conflicted = conflicted === undefined ? !this.conflicted : conflicted;
+
+               if ( this.conflicted !== conflicted ) {
+                       this.conflicted = conflicted;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Set the state of this filter as being already included
+        * (This means any filters in its superset are selected)
+        *
+        * @param {boolean} [included] Filter is included as part of a subset
+        * @fires update
+        */
+       FilterItem.prototype.toggleIncluded = function ( included ) {
+               included = included === undefined ? !this.included : included;
+
+               if ( this.included !== included ) {
+                       this.included = included;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Toggle the fully covered state of the item
+        *
+        * @param {boolean} [isFullyCovered] Filter is fully covered
+        * @fires update
+        */
+       FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
+               isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
+
+               if ( this.fullyCovered !== isFullyCovered ) {
+                       this.fullyCovered = isFullyCovered;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Toggle the visibility of this item
+        *
+        * @param {boolean} [isVisible] Item is visible
+        */
+       FilterItem.prototype.toggleVisible = function ( isVisible ) {
+               isVisible = isVisible === undefined ? !this.visible : !!isVisible;
+
+               if ( this.visible !== isVisible ) {
+                       this.visible = isVisible;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Check whether the item is visible
+        *
+        * @return {boolean} Item is visible
+        */
+       FilterItem.prototype.isVisible = function () {
+               return this.visible;
+       };
+
+       module.exports = FilterItem;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js
new file mode 100644 (file)
index 0000000..d89bb28
--- /dev/null
@@ -0,0 +1,1302 @@
+( function () {
+       var FilterGroup = require( './FilterGroup.js' ),
+               FilterItem = require( './FilterItem.js' ),
+               FiltersViewModel;
+
+       /**
+        * View model for the filters selection and display
+        *
+        * @class mw.rcfilters.dm.FiltersViewModel
+        * @mixins OO.EventEmitter
+        * @mixins OO.EmitterList
+        *
+        * @constructor
+        */
+       FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+               OO.EmitterList.call( this );
+
+               this.groups = {};
+               this.defaultParams = {};
+               this.highlightEnabled = false;
+               this.parameterMap = {};
+               this.emptyParameterState = null;
+
+               this.views = {};
+               this.currentView = 'default';
+               this.searchQuery = null;
+
+               // Events
+               this.aggregate( { update: 'filterItemUpdate' } );
+               this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
+       };
+
+       /* Initialization */
+       OO.initClass( FiltersViewModel );
+       OO.mixinClass( FiltersViewModel, OO.EventEmitter );
+       OO.mixinClass( FiltersViewModel, OO.EmitterList );
+
+       /* Events */
+
+       /**
+        * @event initialize
+        *
+        * Filter list is initialized
+        */
+
+       /**
+        * @event update
+        *
+        * Model has been updated
+        */
+
+       /**
+        * @event itemUpdate
+        * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
+        *
+        * Filter item has changed
+        */
+
+       /**
+        * @event highlightChange
+        * @param {boolean} Highlight feature is enabled
+        *
+        * Highlight feature has been toggled enabled or disabled
+        */
+
+       /* Methods */
+
+       /**
+        * Re-assess the states of filter items based on the interactions between them
+        *
+        * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
+        *  method will go over the state of all items
+        */
+       FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
+               var allSelected,
+                       model = this,
+                       iterationItems = item !== undefined ? [ item ] : this.getItems();
+
+               iterationItems.forEach( function ( checkedItem ) {
+                       var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
+                               groupModel = checkedItem.getGroupModel();
+
+                       // Check for subsets (included filters) plus the item itself:
+                       allCheckedItems.forEach( function ( filterItemName ) {
+                               var itemInSubset = model.getItemByName( filterItemName );
+
+                               itemInSubset.toggleIncluded(
+                                       // If any of itemInSubset's supersets are selected, this item
+                                       // is included
+                                       itemInSubset.getSuperset().some( function ( supersetName ) {
+                                               return ( model.getItemByName( supersetName ).isSelected() );
+                                       } )
+                               );
+                       } );
+
+                       // Update coverage for the changed group
+                       if ( groupModel.isFullCoverage() ) {
+                               allSelected = groupModel.areAllSelected();
+                               groupModel.getItems().forEach( function ( filterItem ) {
+                                       filterItem.toggleFullyCovered( allSelected );
+                               } );
+                       }
+               } );
+
+               // Check for conflicts
+               // In this case, we must go over all items, since
+               // conflicts are bidirectional and depend not only on
+               // individual items, but also on the selected states of
+               // the groups they're in.
+               this.getItems().forEach( function ( filterItem ) {
+                       var inConflict = false,
+                               filterItemGroup = filterItem.getGroupModel();
+
+                       // For each item, see if that item is still conflicting
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( model.groups, function ( groupName, groupModel ) {
+                               if ( filterItem.getGroupName() === groupName ) {
+                                       // Check inside the group
+                                       inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
+                               } else {
+                                       // According to the spec, if two items conflict from two different
+                                       // groups, the conflict only lasts if the groups **only have selected
+                                       // items that are conflicting**. If a group has selected items that
+                                       // are conflicting and non-conflicting, the scope of the result has
+                                       // expanded enough to completely remove the conflict.
+
+                                       // For example, see two groups with conflicts:
+                                       // userExpLevel: [
+                                       //   {
+                                       //     name: 'experienced',
+                                       //     conflicts: [ 'unregistered' ]
+                                       //   }
+                                       // ],
+                                       // registration: [
+                                       //   {
+                                       //     name: 'registered',
+                                       //   },
+                                       //   {
+                                       //     name: 'unregistered',
+                                       //   }
+                                       // ]
+                                       // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
+                                       // because, inherently, 'experienced' filter only includes registered users, and so
+                                       // both filters are in conflict with one another.
+                                       // However, the minute we select 'registered', the scope of our results
+                                       // has expanded to no longer have a conflict with 'experienced' filter, and
+                                       // so the conflict is removed.
+
+                                       // In our case, we need to check if the entire group conflicts with
+                                       // the entire item's group, so we follow the above spec
+                                       inConflict = (
+                                               // The foreign group is in conflict with this item
+                                               groupModel.areAllSelectedInConflictWith( filterItem ) &&
+                                               // Every selected member of the item's own group is also
+                                               // in conflict with the other group
+                                               filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
+                                                       return groupModel.areAllSelectedInConflictWith( otherGroupItem );
+                                               } )
+                                       );
+                               }
+
+                               // If we're in conflict, this will return 'false' which
+                               // will break the loop. Otherwise, we're not in conflict
+                               // and the loop continues
+                               return !inConflict;
+                       } );
+
+                       // Toggle the item state
+                       filterItem.toggleConflicted( inConflict );
+               } );
+       };
+
+       /**
+        * Get whether the model has any conflict in its items
+        *
+        * @return {boolean} There is a conflict
+        */
+       FiltersViewModel.prototype.hasConflict = function () {
+               return this.getItems().some( function ( filterItem ) {
+                       return filterItem.isSelected() && filterItem.isConflicted();
+               } );
+       };
+
+       /**
+        * Get the first item with a current conflict
+        *
+        * @return {mw.rcfilters.dm.FilterItem} Conflicted item
+        */
+       FiltersViewModel.prototype.getFirstConflictedItem = function () {
+               var conflictedItem;
+
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( filterItem.isSelected() && filterItem.isConflicted() ) {
+                               conflictedItem = filterItem;
+                               return false;
+                       }
+               } );
+
+               return conflictedItem;
+       };
+
+       /**
+        * Set filters and preserve a group relationship based on
+        * the definition given by an object
+        *
+        * @param {Array} filterGroups Filters definition
+        * @param {Object} [views] Extra views definition
+        *  Expected in the following format:
+        *  {
+        *     namespaces: {
+        *       label: 'namespaces', // Message key
+        *       trigger: ':',
+        *       groups: [
+        *         {
+        *            // Group info
+        *            name: 'namespaces' // Parameter name
+        *            title: 'namespaces' // Message key
+        *            type: 'string_options',
+        *            separator: ';',
+        *            labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+        *            fullCoverage: true
+        *            items: []
+        *         }
+        *       ]
+        *     }
+        *  }
+        */
+       FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
+               var filterConflictResult, groupConflictResult,
+                       allViews = {},
+                       model = this,
+                       items = [],
+                       groupConflictMap = {},
+                       filterConflictMap = {},
+                       /*!
+                        * Expand a conflict definition from group name to
+                        * the list of all included filters in that group.
+                        * We do this so that the direct relationship in the
+                        * models are consistently item->items rather than
+                        * mixing item->group with item->item.
+                        *
+                        * @param {Object} obj Conflict definition
+                        * @return {Object} Expanded conflict definition
+                        */
+                       expandConflictDefinitions = function ( obj ) {
+                               var result = {};
+
+                               // eslint-disable-next-line jquery/no-each-util
+                               $.each( obj, function ( key, conflicts ) {
+                                       var filterName,
+                                               adjustedConflicts = {};
+
+                                       conflicts.forEach( function ( conflict ) {
+                                               var filter;
+
+                                               if ( conflict.filter ) {
+                                                       filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
+                                                       filter = model.getItemByName( filterName );
+
+                                                       // Rename
+                                                       adjustedConflicts[ filterName ] = $.extend(
+                                                               {},
+                                                               conflict,
+                                                               {
+                                                                       filter: filterName,
+                                                                       item: filter
+                                                               }
+                                                       );
+                                               } else {
+                                                       // This conflict is for an entire group. Split it up to
+                                                       // represent each filter
+
+                                                       // Get the relevant group items
+                                                       model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
+                                                               // Rebuild the conflict
+                                                               adjustedConflicts[ groupItem.getName() ] = $.extend(
+                                                                       {},
+                                                                       conflict,
+                                                                       {
+                                                                               filter: groupItem.getName(),
+                                                                               item: groupItem
+                                                                       }
+                                                               );
+                                                       } );
+                                               }
+                                       } );
+
+                                       result[ key ] = adjustedConflicts;
+                               } );
+
+                               return result;
+                       };
+
+               // Reset
+               this.clearItems();
+               this.groups = {};
+               this.views = {};
+
+               // Clone
+               filterGroups = OO.copy( filterGroups );
+
+               // Normalize definition from the server
+               filterGroups.forEach( function ( data ) {
+                       var i;
+                       // What's this information needs to be normalized
+                       data.whatsThis = {
+                               body: data.whatsThisBody,
+                               header: data.whatsThisHeader,
+                               linkText: data.whatsThisLinkText,
+                               url: data.whatsThisUrl
+                       };
+
+                       // Title is a msg-key
+                       data.title = data.title ? mw.msg( data.title ) : data.name;
+
+                       // Filters are given to us with msg-keys, we need
+                       // to translate those before we hand them off
+                       for ( i = 0; i < data.filters.length; i++ ) {
+                               data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
+                               data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
+                       }
+               } );
+
+               // Collect views
+               allViews = $.extend( true, {
+                       default: {
+                               title: mw.msg( 'rcfilters-filterlist-title' ),
+                               groups: filterGroups
+                       }
+               }, views );
+
+               // Go over all views
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( allViews, function ( viewName, viewData ) {
+                       // Define the view
+                       model.views[ viewName ] = {
+                               name: viewData.name,
+                               title: viewData.title,
+                               trigger: viewData.trigger
+                       };
+
+                       // Go over groups
+                       viewData.groups.forEach( function ( groupData ) {
+                               var group = groupData.name;
+
+                               if ( !model.groups[ group ] ) {
+                                       model.groups[ group ] = new FilterGroup(
+                                               group,
+                                               $.extend( true, {}, groupData, { view: viewName } )
+                                       );
+                               }
+
+                               model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
+                               items = items.concat( model.groups[ group ].getItems() );
+
+                               // Prepare conflicts
+                               if ( groupData.conflicts ) {
+                                       // Group conflicts
+                                       groupConflictMap[ group ] = groupData.conflicts;
+                               }
+
+                               groupData.filters.forEach( function ( itemData ) {
+                                       var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
+                                       // Filter conflicts
+                                       if ( itemData.conflicts ) {
+                                               filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
+                                       }
+                               } );
+                       } );
+               } );
+
+               // Add item references to the model, for lookup
+               this.addItems( items );
+
+               // Expand conflicts
+               groupConflictResult = expandConflictDefinitions( groupConflictMap );
+               filterConflictResult = expandConflictDefinitions( filterConflictMap );
+
+               // Set conflicts for groups
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( groupConflictResult, function ( group, conflicts ) {
+                       model.groups[ group ].setConflicts( conflicts );
+               } );
+
+               // Set conflicts for items
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( filterConflictResult, function ( filterName, conflicts ) {
+                       var filterItem = model.getItemByName( filterName );
+                       // set conflicts for items in the group
+                       filterItem.setConflicts( conflicts );
+               } );
+
+               // Create a map between known parameters and their models
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( group, groupModel ) {
+                       if (
+                               groupModel.getType() === 'send_unselected_if_any' ||
+                               groupModel.getType() === 'boolean' ||
+                               groupModel.getType() === 'any_value'
+                       ) {
+                               // Individual filters
+                               groupModel.getItems().forEach( function ( filterItem ) {
+                                       model.parameterMap[ filterItem.getParamName() ] = filterItem;
+                               } );
+                       } else if (
+                               groupModel.getType() === 'string_options' ||
+                               groupModel.getType() === 'single_option'
+                       ) {
+                               // Group
+                               model.parameterMap[ groupModel.getName() ] = groupModel;
+                       }
+               } );
+
+               this.setSearch( '' );
+
+               this.updateHighlightedState();
+
+               // Finish initialization
+               this.emit( 'initialize' );
+       };
+
+       /**
+        * Update filter view model state based on a parameter object
+        *
+        * @param {Object} params Parameters object
+        */
+       FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
+               var filtersValue;
+               // For arbitrary numeric single_option values make sure the values
+               // are normalized to fit within the limits
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+                       params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
+               } );
+
+               // Update filter values
+               filtersValue = this.getFiltersFromParameters( params );
+               Object.keys( filtersValue ).forEach( function ( filterName ) {
+                       this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
+               }.bind( this ) );
+
+               // Update highlight state
+               this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
+                       var color = params[ filterItem.getName() + '_color' ];
+                       if ( color ) {
+                               filterItem.setHighlightColor( color );
+                       } else {
+                               filterItem.clearHighlightColor();
+                       }
+               } );
+               this.updateHighlightedState();
+
+               // Check all filter interactions
+               this.reassessFilterInteractions();
+       };
+
+       /**
+        * Get a representation of an empty (falsey) parameter state
+        *
+        * @return {Object} Empty parameter state
+        */
+       FiltersViewModel.prototype.getEmptyParameterState = function () {
+               if ( !this.emptyParameterState ) {
+                       this.emptyParameterState = $.extend(
+                               true,
+                               {},
+                               this.getParametersFromFilters( {} ),
+                               this.getEmptyHighlightParameters()
+                       );
+               }
+               return this.emptyParameterState;
+       };
+
+       /**
+        * Get a representation of only the non-falsey parameters
+        *
+        * @param {Object} [parameters] A given parameter state to minimize. If not given the current
+        *  state of the system will be used.
+        * @return {Object} Empty parameter state
+        */
+       FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
+               var result = {};
+
+               parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
+
+               // Params
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.getEmptyParameterState(), function ( param, value ) {
+                       if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
+                               result[ param ] = parameters[ param ];
+                       }
+               } );
+
+               // Highlights
+               Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
+                       if ( parameters[ param ] ) {
+                               // If a highlight parameter is not undefined and not null
+                               // add it to the result
+                               result[ param ] = parameters[ param ];
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get a representation of the full parameter list, including all base values
+        *
+        * @return {Object} Full parameter representation
+        */
+       FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
+               return $.extend(
+                       true,
+                       {},
+                       this.getEmptyParameterState(),
+                       this.getCurrentParameterState()
+               );
+       };
+
+       /**
+        * Get a parameter representation of the current state of the model
+        *
+        * @param {boolean} [removeStickyParams] Remove sticky filters from final result
+        * @return {Object} Parameter representation of the current state of the model
+        */
+       FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
+               var state = this.getMinimizedParamRepresentation( $.extend(
+                       true,
+                       {},
+                       this.getParametersFromFilters( this.getSelectedState() ),
+                       this.getHighlightParameters()
+               ) );
+
+               if ( removeStickyParams ) {
+                       state = this.removeStickyParams( state );
+               }
+
+               return state;
+       };
+
+       /**
+        * Delete sticky parameters from given object.
+        *
+        * @param {Object} paramState Parameter state
+        * @return {Object} Parameter state without sticky parameters
+        */
+       FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
+               this.getStickyParams().forEach( function ( paramName ) {
+                       delete paramState[ paramName ];
+               } );
+
+               return paramState;
+       };
+
+       /**
+        * Turn the highlight feature on or off
+        */
+       FiltersViewModel.prototype.updateHighlightedState = function () {
+               this.toggleHighlight( this.getHighlightedItems().length > 0 );
+       };
+
+       /**
+        * Get the object that defines groups by their name.
+        *
+        * @return {Object} Filter groups
+        */
+       FiltersViewModel.prototype.getFilterGroups = function () {
+               return this.groups;
+       };
+
+       /**
+        * Get the object that defines groups that match a certain view by their name.
+        *
+        * @param {string} [view] Requested view. If not given, uses current view
+        * @return {Object} Filter groups matching a display group
+        */
+       FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
+               var result = {};
+
+               view = view || this.getCurrentView();
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( groupName, groupModel ) {
+                       if ( groupModel.getView() === view ) {
+                               result[ groupName ] = groupModel;
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get an array of filters matching the given display group.
+        *
+        * @param {string} [view] Requested view. If not given, uses current view
+        * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
+        */
+       FiltersViewModel.prototype.getFiltersByView = function ( view ) {
+               var groups,
+                       result = [];
+
+               view = view || this.getCurrentView();
+
+               groups = this.getFilterGroupsByView( view );
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( groups, function ( groupName, groupModel ) {
+                       result = result.concat( groupModel.getItems() );
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get the trigger for the requested view.
+        *
+        * @param {string} view View name
+        * @return {string} View trigger, if exists
+        */
+       FiltersViewModel.prototype.getViewTrigger = function ( view ) {
+               return ( this.views[ view ] && this.views[ view ].trigger ) || '';
+       };
+
+       /**
+        * Get the value of a specific parameter
+        *
+        * @param {string} name Parameter name
+        * @return {number|string} Parameter value
+        */
+       FiltersViewModel.prototype.getParamValue = function ( name ) {
+               return this.parameters[ name ];
+       };
+
+       /**
+        * Get the current selected state of the filters
+        *
+        * @param {boolean} [onlySelected] return an object containing only the filters with a value
+        * @return {Object} Filters selected state
+        */
+       FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
+               var i,
+                       items = this.getItems(),
+                       result = {};
+
+               for ( i = 0; i < items.length; i++ ) {
+                       if ( !onlySelected || items[ i ].getValue() ) {
+                               result[ items[ i ].getName() ] = items[ i ].getValue();
+                       }
+               }
+
+               return result;
+       };
+
+       /**
+        * Get the current full state of the filters
+        *
+        * @return {Object} Filters full state
+        */
+       FiltersViewModel.prototype.getFullState = function () {
+               var i,
+                       items = this.getItems(),
+                       result = {};
+
+               for ( i = 0; i < items.length; i++ ) {
+                       result[ items[ i ].getName() ] = {
+                               selected: items[ i ].isSelected(),
+                               conflicted: items[ i ].isConflicted(),
+                               included: items[ i ].isIncluded()
+                       };
+               }
+
+               return result;
+       };
+
+       /**
+        * Get an object representing default parameters state
+        *
+        * @return {Object} Default parameter values
+        */
+       FiltersViewModel.prototype.getDefaultParams = function () {
+               var result = {};
+
+               // Get default filter state
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( name, model ) {
+                       if ( !model.isSticky() ) {
+                               $.extend( true, result, model.getDefaultParams() );
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get a parameter representation of all sticky parameters
+        *
+        * @return {Object} Sticky parameter values
+        */
+       FiltersViewModel.prototype.getStickyParams = function () {
+               var result = [];
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( name, model ) {
+                       if ( model.isSticky() ) {
+                               if ( model.isPerGroupRequestParameter() ) {
+                                       result.push( name );
+                               } else {
+                                       // Each filter is its own param
+                                       result = result.concat( model.getItems().map( function ( filterItem ) {
+                                               return filterItem.getParamName();
+                                       } ) );
+                               }
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get a parameter representation of all sticky parameters
+        *
+        * @return {Object} Sticky parameter values
+        */
+       FiltersViewModel.prototype.getStickyParamsValues = function () {
+               var result = {};
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( name, model ) {
+                       if ( model.isSticky() ) {
+                               $.extend( true, result, model.getParamRepresentation() );
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Analyze the groups and their filters and output an object representing
+        * the state of the parameters they represent.
+        *
+        * @param {Object} [filterDefinition] An object defining the filter values,
+        *  keyed by filter names.
+        * @return {Object} Parameter state object
+        */
+       FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
+               var groupItemDefinition,
+                       result = {},
+                       groupItems = this.getFilterGroups();
+
+               if ( filterDefinition ) {
+                       groupItemDefinition = {};
+                       // Filter definition is "flat", but in effect
+                       // each group needs to tell us its result based
+                       // on the values in it. We need to split this list
+                       // back into groupings so we can "feed" it to the
+                       // loop below, and we need to expand it so it includes
+                       // all filters (set to false)
+                       this.getItems().forEach( function ( filterItem ) {
+                               groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
+                               groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
+                       } );
+               }
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( groupItems, function ( group, model ) {
+                       $.extend(
+                               result,
+                               model.getParamRepresentation(
+                                       groupItemDefinition ?
+                                               groupItemDefinition[ group ] : null
+                               )
+                       );
+               } );
+
+               return result;
+       };
+
+       /**
+        * This is the opposite of the #getParametersFromFilters method; this goes over
+        * the given parameters and translates into a selected/unselected value in the filters.
+        *
+        * @param {Object} params Parameters query object
+        * @return {Object} Filter state object
+        */
+       FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
+               var groupMap = {},
+                       model = this,
+                       result = {};
+
+               // Go over the given parameters, break apart to groupings
+               // The resulting object represents the group with its parameter
+               // values. For example:
+               // {
+               //    group1: {
+               //       param1: "1",
+               //       param2: "0",
+               //       param3: "1"
+               //    },
+               //    group2: "param4|param5"
+               // }
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( params, function ( paramName, paramValue ) {
+                       var groupName,
+                               itemOrGroup = model.parameterMap[ paramName ];
+
+                       if ( itemOrGroup ) {
+                               groupName = itemOrGroup instanceof FilterItem ?
+                                       itemOrGroup.getGroupName() : itemOrGroup.getName();
+
+                               groupMap[ groupName ] = groupMap[ groupName ] || {};
+                               groupMap[ groupName ][ paramName ] = paramValue;
+                       }
+               } );
+
+               // Go over all groups, so we make sure we get the complete output
+               // even if the parameters don't include a certain group
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( groupName, groupModel ) {
+                       result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get the highlight parameters based on current filter configuration
+        *
+        * @return {Object} Object where keys are `<filter name>_color` and values
+        *                  are the selected highlight colors.
+        */
+       FiltersViewModel.prototype.getHighlightParameters = function () {
+               var highlightEnabled = this.isHighlightEnabled(),
+                       result = {};
+
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( filterItem.isHighlightSupported() ) {
+                               result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
+                                       filterItem.getHighlightColor() :
+                                       null;
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get an object representing the complete empty state of highlights
+        *
+        * @return {Object} Object containing all the highlight parameters set to their negative value
+        */
+       FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
+               var result = {};
+
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( filterItem.isHighlightSupported() ) {
+                               result[ filterItem.getName() + '_color' ] = null;
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get an array of currently applied highlight colors
+        *
+        * @return {string[]} Currently applied highlight colors
+        */
+       FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
+               var result = [];
+
+               if ( this.isHighlightEnabled() ) {
+                       this.getHighlightedItems().forEach( function ( filterItem ) {
+                               var color = filterItem.getHighlightColor();
+
+                               if ( result.indexOf( color ) === -1 ) {
+                                       result.push( color );
+                               }
+                       } );
+               }
+
+               return result;
+       };
+
+       /**
+        * Sanitize value group of a string_option groups type
+        * Remove duplicates and make sure to only use valid
+        * values.
+        *
+        * @private
+        * @param {string} groupName Group name
+        * @param {string[]} valueArray Array of values
+        * @return {string[]} Array of valid values
+        */
+       FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
+               var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
+                       return filterItem.getParamName();
+               } );
+
+               return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
+       };
+
+       /**
+        * Check whether no visible filter is selected.
+        *
+        * Filter groups that are hidden or sticky are not shown in the
+        * active filters area and therefore not included in this check.
+        *
+        * @return {boolean} No visible filter is selected
+        */
+       FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
+               // Check if there are either any selected items or any items
+               // that have highlight enabled
+               return !this.getItems().some( function ( filterItem ) {
+                       var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
+                               active = ( filterItem.isSelected() || filterItem.isHighlighted() );
+                       return visible && active;
+               } );
+       };
+
+       /**
+        * Check whether the invert state is a valid one. A valid invert state is one where
+        * there are actual namespaces selected.
+        *
+        * This is done to compare states to previous ones that may have had the invert model
+        * selected but effectively had no namespaces, so are not effectively different than
+        * ones where invert is not selected.
+        *
+        * @return {boolean} Invert is effectively selected
+        */
+       FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
+               return this.getInvertModel().isSelected() &&
+                       this.findSelectedItems().some( function ( itemModel ) {
+                               return itemModel.getGroupModel().getName() === 'namespace';
+                       } );
+       };
+
+       /**
+        * Get the item that matches the given name
+        *
+        * @param {string} name Filter name
+        * @return {mw.rcfilters.dm.FilterItem} Filter item
+        */
+       FiltersViewModel.prototype.getItemByName = function ( name ) {
+               return this.getItems().filter( function ( item ) {
+                       return name === item.getName();
+               } )[ 0 ];
+       };
+
+       /**
+        * Set all filters to false or empty/all
+        * This is equivalent to display all.
+        */
+       FiltersViewModel.prototype.emptyAllFilters = function () {
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( !filterItem.getGroupModel().isSticky() ) {
+                               this.toggleFilterSelected( filterItem.getName(), false );
+                       }
+               }.bind( this ) );
+       };
+
+       /**
+        * Toggle selected state of one item
+        *
+        * @param {string} name Name of the filter item
+        * @param {boolean} [isSelected] Filter selected state
+        */
+       FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
+               var item = this.getItemByName( name );
+
+               if ( item ) {
+                       item.toggleSelected( isSelected );
+               }
+       };
+
+       /**
+        * Toggle selected state of items by their names
+        *
+        * @param {Object} filterDef Filter definitions
+        */
+       FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
+               Object.keys( filterDef ).forEach( function ( name ) {
+                       this.toggleFilterSelected( name, filterDef[ name ] );
+               }.bind( this ) );
+       };
+
+       /**
+        * Get a group model from its name
+        *
+        * @param {string} groupName Group name
+        * @return {mw.rcfilters.dm.FilterGroup} Group model
+        */
+       FiltersViewModel.prototype.getGroup = function ( groupName ) {
+               return this.groups[ groupName ];
+       };
+
+       /**
+        * Get all filters within a specified group by its name
+        *
+        * @param {string} groupName Group name
+        * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
+        */
+       FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
+               return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
+       };
+
+       /**
+        * Find items whose labels match the given string
+        *
+        * @param {string} query Search string
+        * @param {boolean} [returnFlat] Return a flat array. If false, the result
+        *  is an object whose keys are the group names and values are an array of
+        *  filters per group. If set to true, returns an array of filters regardless
+        *  of their groups.
+        * @return {Object} An object of items to show
+        *  arranged by their group names
+        */
+       FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
+               var i, searchIsEmpty,
+                       groupTitle,
+                       result = {},
+                       flatResult = [],
+                       view = this.getViewByTrigger( query.substr( 0, 1 ) ),
+                       items = this.getFiltersByView( view );
+
+               // Normalize so we can search strings regardless of case and view
+               query = query.trim().toLowerCase();
+               if ( view !== 'default' ) {
+                       query = query.substr( 1 );
+               }
+               // Trim again to also intercept cases where the spaces were after the trigger
+               // eg: '#   str'
+               query = query.trim();
+
+               // Check if the search if actually empty; this can be a problem when
+               // we use prefixes to denote different views
+               searchIsEmpty = query.length === 0;
+
+               // item label starting with the query string
+               for ( i = 0; i < items.length; i++ ) {
+                       if (
+                               searchIsEmpty ||
+                               items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
+                               (
+                                       // For tags, we want the parameter name to be included in the search
+                                       view === 'tags' &&
+                                       items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+                               )
+                       ) {
+                               result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+                               result[ items[ i ].getGroupName() ].push( items[ i ] );
+                               flatResult.push( items[ i ] );
+                       }
+               }
+
+               if ( $.isEmptyObject( result ) ) {
+                       // item containing the query string in their label, description, or group title
+                       for ( i = 0; i < items.length; i++ ) {
+                               groupTitle = items[ i ].getGroupModel().getTitle();
+                               if (
+                                       searchIsEmpty ||
+                                       items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
+                                       items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
+                                       groupTitle.toLowerCase().indexOf( query ) > -1 ||
+                                       (
+                                               // For tags, we want the parameter name to be included in the search
+                                               view === 'tags' &&
+                                               items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+                                       )
+                               ) {
+                                       result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+                                       result[ items[ i ].getGroupName() ].push( items[ i ] );
+                                       flatResult.push( items[ i ] );
+                               }
+                       }
+               }
+
+               return returnFlat ? flatResult : result;
+       };
+
+       /**
+        * Get items that are highlighted
+        *
+        * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
+        */
+       FiltersViewModel.prototype.getHighlightedItems = function () {
+               return this.getItems().filter( function ( filterItem ) {
+                       return filterItem.isHighlightSupported() &&
+                               filterItem.getHighlightColor();
+               } );
+       };
+
+       /**
+        * Get items that allow highlights even if they're not currently highlighted
+        *
+        * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
+        */
+       FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
+               return this.getItems().filter( function ( filterItem ) {
+                       return filterItem.isHighlightSupported();
+               } );
+       };
+
+       /**
+        * Get all selected items
+        *
+        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+        */
+       FiltersViewModel.prototype.findSelectedItems = function () {
+               var allSelected = [];
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+                       allSelected = allSelected.concat( groupModel.findSelectedItems() );
+               } );
+
+               return allSelected;
+       };
+
+       /**
+        * Get the current view
+        *
+        * @return {string} Current view
+        */
+       FiltersViewModel.prototype.getCurrentView = function () {
+               return this.currentView;
+       };
+
+       /**
+        * Get the label for the current view
+        *
+        * @param {string} viewName View name
+        * @return {string} Label for the current view
+        */
+       FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
+               viewName = viewName || this.getCurrentView();
+
+               return this.views[ viewName ] && this.views[ viewName ].title;
+       };
+
+       /**
+        * Get the view that fits the given trigger
+        *
+        * @param {string} trigger Trigger
+        * @return {string} Name of view
+        */
+       FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
+               var result = 'default';
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.views, function ( name, data ) {
+                       if ( data.trigger === trigger ) {
+                               result = name;
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Return a version of the given string that is without any
+        * view triggers.
+        *
+        * @param {string} str Given string
+        * @return {string} Result
+        */
+       FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
+               if ( this.getViewFromString( str ) !== 'default' ) {
+                       str = str.substr( 1 );
+               }
+
+               return str;
+       };
+
+       /**
+        * Get the view from the given string by a trigger, if it exists
+        *
+        * @param {string} str Given string
+        * @return {string} View name
+        */
+       FiltersViewModel.prototype.getViewFromString = function ( str ) {
+               return this.getViewByTrigger( str.substr( 0, 1 ) );
+       };
+
+       /**
+        * Set the current search for the system.
+        * This also dictates what items and groups are visible according
+        * to the search in #findMatches
+        *
+        * @param {string} searchQuery Search query, including triggers
+        * @fires searchChange
+        */
+       FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
+               var visibleGroups, visibleGroupNames;
+
+               if ( this.searchQuery !== searchQuery ) {
+                       // Check if the view changed
+                       this.switchView( this.getViewFromString( searchQuery ) );
+
+                       visibleGroups = this.findMatches( searchQuery );
+                       visibleGroupNames = Object.keys( visibleGroups );
+
+                       // Update visibility of items and groups
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+                               // Check if the group is visible at all
+                               groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
+                               groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
+                       } );
+
+                       this.searchQuery = searchQuery;
+                       this.emit( 'searchChange', this.searchQuery );
+               }
+       };
+
+       /**
+        * Get the current search
+        *
+        * @return {string} Current search query
+        */
+       FiltersViewModel.prototype.getSearch = function () {
+               return this.searchQuery;
+       };
+
+       /**
+        * Switch the current view
+        *
+        * @private
+        * @param {string} view View name
+        */
+       FiltersViewModel.prototype.switchView = function ( view ) {
+               if ( this.views[ view ] && this.currentView !== view ) {
+                       this.currentView = view;
+               }
+       };
+
+       /**
+        * Toggle the highlight feature on and off.
+        * Propagate the change to filter items.
+        *
+        * @param {boolean} enable Highlight should be enabled
+        * @fires highlightChange
+        */
+       FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
+               enable = enable === undefined ? !this.highlightEnabled : enable;
+
+               if ( this.highlightEnabled !== enable ) {
+                       this.highlightEnabled = enable;
+                       this.emit( 'highlightChange', this.highlightEnabled );
+               }
+       };
+
+       /**
+        * Check if the highlight feature is enabled
+        * @return {boolean}
+        */
+       FiltersViewModel.prototype.isHighlightEnabled = function () {
+               return !!this.highlightEnabled;
+       };
+
+       /**
+        * Toggle the inverted namespaces property on and off.
+        * Propagate the change to namespace filter items.
+        *
+        * @param {boolean} enable Inverted property is enabled
+        */
+       FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
+               this.toggleFilterSelected( this.getInvertModel().getName(), enable );
+       };
+
+       /**
+        * Get the model object that represents the 'invert' filter
+        *
+        * @return {mw.rcfilters.dm.FilterItem}
+        */
+       FiltersViewModel.prototype.getInvertModel = function () {
+               return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
+       };
+
+       /**
+        * Set highlight color for a specific filter item
+        *
+        * @param {string} filterName Name of the filter item
+        * @param {string} color Selected color
+        */
+       FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
+               this.getItemByName( filterName ).setHighlightColor( color );
+       };
+
+       /**
+        * Clear highlight for a specific filter item
+        *
+        * @param {string} filterName Name of the filter item
+        */
+       FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
+               this.getItemByName( filterName ).clearHighlightColor();
+       };
+
+       module.exports = FiltersViewModel;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/ItemModel.js b/resources/src/mediawiki.rcfilters/dm/ItemModel.js
new file mode 100644 (file)
index 0000000..2dc578e
--- /dev/null
@@ -0,0 +1,276 @@
+( function () {
+       /**
+        * RCFilter base item model
+        *
+        * @class mw.rcfilters.dm.ItemModel
+        * @mixins OO.EventEmitter
+        *
+        * @constructor
+        * @param {string} param Filter param name
+        * @param {Object} config Configuration object
+        * @cfg {string} [label] The label for the filter
+        * @cfg {string} [description] The description of the filter
+        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
+        *  with 'default' and 'inverted' as keys.
+        * @cfg {boolean} [active=true] The filter is active and affecting the result
+        * @cfg {boolean} [selected] The item is selected
+        * @cfg {*} [value] The value of this item
+        * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
+        *  identifier
+        * @cfg {string} [cssClass] The class identifying the results that match this filter
+        * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
+        *  added and considered in the view.
+        * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
+        */
+       var ItemModel = function MwRcfiltersDmItemModel( param, config ) {
+               config = config || {};
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               this.param = param;
+               this.namePrefix = config.namePrefix || 'item_';
+               this.name = this.namePrefix + param;
+
+               this.label = config.label || this.name;
+               this.labelPrefixKey = config.labelPrefixKey;
+               this.description = config.description || '';
+               this.setValue( config.value || config.selected );
+
+               this.identifiers = config.identifiers || [];
+
+               // Highlight
+               this.cssClass = config.cssClass;
+               this.highlightColor = config.defaultHighlightColor || null;
+       };
+
+       /* Initialization */
+
+       OO.initClass( ItemModel );
+       OO.mixinClass( ItemModel, OO.EventEmitter );
+
+       /* Events */
+
+       /**
+        * @event update
+        *
+        * The state of this filter has changed
+        */
+
+       /* Methods */
+
+       /**
+        * Return the representation of the state of this item.
+        *
+        * @return {Object} State of the object
+        */
+       ItemModel.prototype.getState = function () {
+               return {
+                       selected: this.isSelected()
+               };
+       };
+
+       /**
+        * Get the name of this filter
+        *
+        * @return {string} Filter name
+        */
+       ItemModel.prototype.getName = function () {
+               return this.name;
+       };
+
+       /**
+        * Get the message key to use to wrap the label. This message takes the label as a parameter.
+        *
+        * @param {boolean} inverted Whether this item should be considered inverted
+        * @return {string|null} Message key, or null if no message
+        */
+       ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
+               if ( this.labelPrefixKey ) {
+                       if ( typeof this.labelPrefixKey === 'string' ) {
+                               return this.labelPrefixKey;
+                       }
+                       return this.labelPrefixKey[
+                               // Only use inverted-prefix if the item is selected
+                               // Highlight-only an inverted item makes no sense
+                               inverted && this.isSelected() ?
+                                       'inverted' : 'default'
+                       ];
+               }
+               return null;
+       };
+
+       /**
+        * Get the param name or value of this filter
+        *
+        * @return {string} Filter param name
+        */
+       ItemModel.prototype.getParamName = function () {
+               return this.param;
+       };
+
+       /**
+        * Get the message representing the state of this model.
+        *
+        * @return {string} State message
+        */
+       ItemModel.prototype.getStateMessage = function () {
+               // Display description
+               return this.getDescription();
+       };
+
+       /**
+        * Get the label of this filter
+        *
+        * @return {string} Filter label
+        */
+       ItemModel.prototype.getLabel = function () {
+               return this.label;
+       };
+
+       /**
+        * Get the description of this filter
+        *
+        * @return {string} Filter description
+        */
+       ItemModel.prototype.getDescription = function () {
+               return this.description;
+       };
+
+       /**
+        * Get the default value of this filter
+        *
+        * @return {boolean} Filter default
+        */
+       ItemModel.prototype.getDefault = function () {
+               return this.default;
+       };
+
+       /**
+        * Get the selected state of this filter
+        *
+        * @return {boolean} Filter is selected
+        */
+       ItemModel.prototype.isSelected = function () {
+               return !!this.value;
+       };
+
+       /**
+        * Toggle the selected state of the item
+        *
+        * @param {boolean} [isSelected] Filter is selected
+        * @fires update
+        */
+       ItemModel.prototype.toggleSelected = function ( isSelected ) {
+               isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
+               this.setValue( isSelected );
+       };
+
+       /**
+        * Get the value
+        *
+        * @return {*}
+        */
+       ItemModel.prototype.getValue = function () {
+               return this.value;
+       };
+
+       /**
+        * Convert a given value to the appropriate representation based on group type
+        *
+        * @param {*} value
+        * @return {*}
+        */
+       ItemModel.prototype.coerceValue = function ( value ) {
+               return this.getGroupModel().getType() === 'any_value' ? value : !!value;
+       };
+
+       /**
+        * Set the value
+        *
+        * @param {*} newValue
+        */
+       ItemModel.prototype.setValue = function ( newValue ) {
+               newValue = this.coerceValue( newValue );
+               if ( this.value !== newValue ) {
+                       this.value = newValue;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Set the highlight color
+        *
+        * @param {string|null} highlightColor
+        */
+       ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
+               if ( !this.isHighlightSupported() ) {
+                       return;
+               }
+               // If the highlight color on the item and in the parameter is null/undefined, return early.
+               if ( !this.highlightColor && !highlightColor ) {
+                       return;
+               }
+
+               if ( this.highlightColor !== highlightColor ) {
+                       this.highlightColor = highlightColor;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Clear the highlight color
+        */
+       ItemModel.prototype.clearHighlightColor = function () {
+               this.setHighlightColor( null );
+       };
+
+       /**
+        * Get the highlight color, or null if none is configured
+        *
+        * @return {string|null}
+        */
+       ItemModel.prototype.getHighlightColor = function () {
+               return this.highlightColor;
+       };
+
+       /**
+        * Get the CSS class that matches changes that fit this filter
+        * or null if none is configured
+        *
+        * @return {string|null}
+        */
+       ItemModel.prototype.getCssClass = function () {
+               return this.cssClass;
+       };
+
+       /**
+        * Get the item's identifiers
+        *
+        * @return {string[]}
+        */
+       ItemModel.prototype.getIdentifiers = function () {
+               return this.identifiers;
+       };
+
+       /**
+        * Check if the highlight feature is supported for this filter
+        *
+        * @return {boolean}
+        */
+       ItemModel.prototype.isHighlightSupported = function () {
+               return !!this.getCssClass();
+       };
+
+       /**
+        * Check if the filter is currently highlighted
+        *
+        * @return {boolean}
+        */
+       ItemModel.prototype.isHighlighted = function () {
+               return !!this.getHighlightColor();
+       };
+
+       module.exports = ItemModel;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js b/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js
new file mode 100644 (file)
index 0000000..34c57dd
--- /dev/null
@@ -0,0 +1,415 @@
+( function () {
+       var SavedQueryItemModel = require( './SavedQueryItemModel.js' ),
+               SavedQueriesModel;
+
+       /**
+        * View model for saved queries
+        *
+        * @class mw.rcfilters.dm.SavedQueriesModel
+        * @mixins OO.EventEmitter
+        * @mixins OO.EmitterList
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
+        * @param {Object} [config] Configuration options
+        * @cfg {string} [default] Default query ID
+        */
+       SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
+               config = config || {};
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+               OO.EmitterList.call( this );
+
+               this.default = config.default;
+               this.filtersModel = filtersModel;
+               this.converted = false;
+
+               // Events
+               this.aggregate( { update: 'itemUpdate' } );
+       };
+
+       /* Initialization */
+
+       OO.initClass( SavedQueriesModel );
+       OO.mixinClass( SavedQueriesModel, OO.EventEmitter );
+       OO.mixinClass( SavedQueriesModel, OO.EmitterList );
+
+       /* Events */
+
+       /**
+        * @event initialize
+        *
+        * Model is initialized
+        */
+
+       /**
+        * @event itemUpdate
+        * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
+        *
+        * An item has changed
+        */
+
+       /**
+        * @event default
+        * @param {string} New default ID
+        *
+        * The default has changed
+        */
+
+       /* Methods */
+
+       /**
+        * Initialize the saved queries model by reading it from the user's settings.
+        * The structure of the saved queries is:
+        * {
+        *    version: (string) Version number; if version 2, the query represents
+        *             parameters. Otherwise, the older version represented filters
+        *             and needs to be readjusted,
+        *    default: (string) Query ID
+        *    queries:{
+        *       query_id_1: {
+        *          data:{
+        *             filters: (Object) Minimal definition of the filters
+        *             highlights: (Object) Definition of the highlights
+        *          },
+        *          label: (optional) Name of this query
+        *       }
+        *    }
+        * }
+        *
+        * @param {Object} [savedQueries] An object with the saved queries with
+        *  the above structure.
+        * @fires initialize
+        */
+       SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
+               var model = this;
+
+               savedQueries = savedQueries || {};
+
+               this.clearItems();
+               this.default = null;
+               this.converted = false;
+
+               if ( savedQueries.version !== '2' ) {
+                       // Old version dealt with filter names. We need to migrate to the new structure
+                       // The new structure:
+                       // {
+                       //   version: (string) '2',
+                       //   default: (string) Query ID,
+                       //   queries: {
+                       //     query_id: {
+                       //       label: (string) Name of the query
+                       //       data: {
+                       //         params: (object) Representing all the parameter states
+                       //         highlights: (object) Representing all the filter highlight states
+                       //     }
+                       //   }
+                       // }
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( savedQueries.queries || {}, function ( id, obj ) {
+                               if ( obj.data && obj.data.filters ) {
+                                       obj.data = model.convertToParameters( obj.data );
+                               }
+                       } );
+
+                       this.converted = true;
+                       savedQueries.version = '2';
+               }
+
+               // Initialize the query items
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( savedQueries.queries || {}, function ( id, obj ) {
+                       var normalizedData = obj.data,
+                               isDefault = String( savedQueries.default ) === String( id );
+
+                       if ( normalizedData && normalizedData.params ) {
+                               // Backwards-compat fix: Remove sticky parameters from
+                               // the given data, if they exist
+                               normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );
+
+                               // Correct the invert state for effective selection
+                               if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
+                                       delete normalizedData.params.invert;
+                               }
+
+                               model.cleanupHighlights( normalizedData );
+
+                               id = String( id );
+
+                               // Skip the addNewQuery method because we don't want to unnecessarily manipulate
+                               // the given saved queries unless we literally intend to (like in backwards compat fixes)
+                               // And the addNewQuery method also uses a minimization routine that checks for the
+                               // validity of items and minimizes the query. This isn't necessary for queries loaded
+                               // from the backend, and has the risk of removing values if they're temporarily
+                               // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
+                               model.addItems( [
+                                       new SavedQueryItemModel(
+                                               id,
+                                               obj.label,
+                                               normalizedData,
+                                               { default: isDefault }
+                                       )
+                               ] );
+
+                               if ( isDefault ) {
+                                       model.default = id;
+                               }
+                       }
+               } );
+
+               this.emit( 'initialize' );
+       };
+
+       /**
+        * Clean up highlight parameters.
+        * 'highlight' used to be stored, it's not inferred based on the presence of absence of
+        * filter colors.
+        *
+        * @param {Object} data Saved query data
+        */
+       SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
+               if (
+                       data.params.highlight === '0' &&
+                       data.highlights && Object.keys( data.highlights ).length
+               ) {
+                       data.highlights = {};
+               }
+               delete data.params.highlight;
+       };
+
+       /**
+        * Convert from representation of filters to representation of parameters
+        *
+        * @param {Object} data Query data
+        * @return {Object} New converted query data
+        */
+       SavedQueriesModel.prototype.convertToParameters = function ( data ) {
+               var newData = {},
+                       defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
+                       fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
+                       highlightEnabled = data.highlights.highlight;
+
+               delete data.highlights.highlight;
+
+               // Filters
+               newData.params = this.filtersModel.getMinimizedParamRepresentation(
+                       this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
+               );
+
+               // Highlights: appending _color to keys
+               newData.highlights = {};
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( data.highlights, function ( highlightedFilterName, value ) {
+                       if ( value ) {
+                               newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
+                       }
+               } );
+
+               // Add highlight
+               newData.params.highlight = String( Number( highlightEnabled || 0 ) );
+
+               return newData;
+       };
+
+       /**
+        * Add a query item
+        *
+        * @param {string} label Label for the new query
+        * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
+        * @param {boolean} isDefault Item is default
+        * @param {string} [id] Query ID, if exists. If this isn't given, a random
+        *  new ID will be created.
+        * @return {string} ID of the newly added query
+        */
+       SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
+               var normalizedData = { params: {}, highlights: {} },
+                       highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
+                       randomID = String( id || ( new Date() ).getTime() ),
+                       data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
+
+               // Split highlight/params
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( data, function ( param, value ) {
+                       if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
+                               normalizedData.highlights[ param ] = value;
+                       } else {
+                               normalizedData.params[ param ] = value;
+                       }
+               } );
+
+               // Correct the invert state for effective selection
+               if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
+                       delete normalizedData.params.invert;
+               }
+
+               // Add item
+               this.addItems( [
+                       new SavedQueryItemModel(
+                               randomID,
+                               label,
+                               normalizedData,
+                               { default: isDefault }
+                       )
+               ] );
+
+               if ( isDefault ) {
+                       this.setDefault( randomID );
+               }
+
+               return randomID;
+       };
+
+       /**
+        * Remove query from model
+        *
+        * @param {string} queryID Query ID
+        */
+       SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
+               var query = this.getItemByID( queryID );
+
+               if ( query ) {
+                       // Check if this item was the default
+                       if ( String( this.getDefault() ) === String( queryID ) ) {
+                               // Nulify the default
+                               this.setDefault( null );
+                       }
+
+                       this.removeItems( [ query ] );
+               }
+       };
+
+       /**
+        * Get an item that matches the requested query
+        *
+        * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
+        * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
+        */
+       SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
+               // Minimize before comparison
+               fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
+
+               // Correct the invert state for effective selection
+               if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
+                       delete fullQueryComparison.invert;
+               }
+
+               return this.getItems().filter( function ( item ) {
+                       return OO.compare(
+                               item.getCombinedData(),
+                               fullQueryComparison
+                       );
+               } )[ 0 ];
+       };
+
+       /**
+        * Get query by its identifier
+        *
+        * @param {string} queryID Query identifier
+        * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
+        *  the search. Undefined if not found.
+        */
+       SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
+               return this.getItems().filter( function ( item ) {
+                       return item.getID() === queryID;
+               } )[ 0 ];
+       };
+
+       /**
+        * Get the full data representation of the default query, if it exists
+        *
+        * @return {Object|null} Representation of the default params if exists.
+        *  Null if default doesn't exist or if the user is not logged in.
+        */
+       SavedQueriesModel.prototype.getDefaultParams = function () {
+               return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
+       };
+
+       /**
+        * Get a full parameter representation of an item data
+        *
+        * @param  {Object} queryID Query ID
+        * @return {Object} Parameter representation
+        */
+       SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
+               var item = this.getItemByID( queryID ),
+                       data = item ? item.getData() : {};
+
+               return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
+       };
+
+       /**
+        * Build a full parameter representation given item data and model sticky values state
+        *
+        * @param  {Object} data Item data
+        * @return {Object} Full param representation
+        */
+       SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
+               data = data || {};
+               // Return parameter representation
+               return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
+                       data.params,
+                       data.highlights
+               ) );
+       };
+
+       /**
+        * Get the object representing the state of the entire model and items
+        *
+        * @return {Object} Object representing the state of the model and items
+        */
+       SavedQueriesModel.prototype.getState = function () {
+               var obj = { queries: {}, version: '2' };
+
+               // Translate the items to the saved object
+               this.getItems().forEach( function ( item ) {
+                       obj.queries[ item.getID() ] = item.getState();
+               } );
+
+               if ( this.getDefault() ) {
+                       obj.default = this.getDefault();
+               }
+
+               return obj;
+       };
+
+       /**
+        * Set a default query. Null to unset default.
+        *
+        * @param {string} itemID Query identifier
+        * @fires default
+        */
+       SavedQueriesModel.prototype.setDefault = function ( itemID ) {
+               if ( this.default !== itemID ) {
+                       this.default = itemID;
+
+                       // Set for individual itens
+                       this.getItems().forEach( function ( item ) {
+                               item.toggleDefault( item.getID() === itemID );
+                       } );
+
+                       this.emit( 'default', itemID );
+               }
+       };
+
+       /**
+        * Get the default query ID
+        *
+        * @return {string} Default query identifier
+        */
+       SavedQueriesModel.prototype.getDefault = function () {
+               return this.default;
+       };
+
+       /**
+        * Check if the saved queries were converted
+        *
+        * @return {boolean} Saved queries were converted from the previous
+        *  version to the new version
+        */
+       SavedQueriesModel.prototype.isConverted = function () {
+               return this.converted;
+       };
+
+       module.exports = SavedQueriesModel;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js b/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js
new file mode 100644 (file)
index 0000000..1774391
--- /dev/null
@@ -0,0 +1,127 @@
+( function () {
+       /**
+        * View model for a single saved query
+        *
+        * @class mw.rcfilters.dm.SavedQueryItemModel
+        * @mixins OO.EventEmitter
+        *
+        * @constructor
+        * @param {string} id Unique identifier
+        * @param {string} label Saved query label
+        * @param {Object} data Saved query data
+        * @param {Object} [config] Configuration options
+        * @cfg {boolean} [default] This item is the default
+        */
+       var SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
+               config = config || {};
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               this.id = id;
+               this.label = label;
+               this.data = data;
+               this.default = !!config.default;
+       };
+
+       /* Initialization */
+
+       OO.initClass( SavedQueryItemModel );
+       OO.mixinClass( SavedQueryItemModel, OO.EventEmitter );
+
+       /* Events */
+
+       /**
+        * @event update
+        *
+        * Model has been updated
+        */
+
+       /* Methods */
+
+       /**
+        * Get an object representing the state of this item
+        *
+        * @return {Object} Object representing the current data state
+        *  of the object
+        */
+       SavedQueryItemModel.prototype.getState = function () {
+               return {
+                       data: this.getData(),
+                       label: this.getLabel()
+               };
+       };
+
+       /**
+        * Get the query's identifier
+        *
+        * @return {string} Query identifier
+        */
+       SavedQueryItemModel.prototype.getID = function () {
+               return this.id;
+       };
+
+       /**
+        * Get query label
+        *
+        * @return {string} Query label
+        */
+       SavedQueryItemModel.prototype.getLabel = function () {
+               return this.label;
+       };
+
+       /**
+        * Update the query label
+        *
+        * @param {string} newLabel New label
+        */
+       SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
+               if ( newLabel && this.label !== newLabel ) {
+                       this.label = newLabel;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Get query data
+        *
+        * @return {Object} Object representing parameter and highlight data
+        */
+       SavedQueryItemModel.prototype.getData = function () {
+               return this.data;
+       };
+
+       /**
+        * Get the combined data of this item as a flat object of parameters
+        *
+        * @return {Object} Combined parameter data
+        */
+       SavedQueryItemModel.prototype.getCombinedData = function () {
+               return $.extend( true, {}, this.data.params, this.data.highlights );
+       };
+
+       /**
+        * Check whether this item is the default
+        *
+        * @return {boolean} Query is set to be default
+        */
+       SavedQueryItemModel.prototype.isDefault = function () {
+               return this.default;
+       };
+
+       /**
+        * Toggle the default state of this query item
+        *
+        * @param {boolean} isDefault Query is default
+        */
+       SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
+               isDefault = isDefault === undefined ? !this.default : isDefault;
+
+               if ( this.default !== isDefault ) {
+                       this.default = isDefault;
+                       this.emit( 'update' );
+               }
+       };
+
+       module.exports = SavedQueryItemModel;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js
deleted file mode 100644 (file)
index e51829c..0000000
+++ /dev/null
@@ -1,166 +0,0 @@
-( function () {
-       /**
-        * View model for the changes list
-        *
-        * @mixins OO.EventEmitter
-        *
-        * @param {jQuery} $initialFieldset The initial server-generated legacy form content
-        * @constructor
-        */
-       mw.rcfilters.dm.ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               this.valid = true;
-               this.newChangesExist = false;
-               this.liveUpdate = false;
-               this.unseenWatchedChanges = false;
-
-               this.extractNextFrom( $initialFieldset );
-       };
-
-       /* Initialization */
-       OO.initClass( mw.rcfilters.dm.ChangesListViewModel );
-       OO.mixinClass( mw.rcfilters.dm.ChangesListViewModel, OO.EventEmitter );
-
-       /* Events */
-
-       /**
-        * @event invalidate
-        *
-        * The list of changes is now invalid (out of date)
-        */
-
-       /**
-        * @event update
-        * @param {jQuery|string} $changesListContent List of changes
-        * @param {jQuery} $fieldset Server-generated form
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
-        * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
-        *
-        * The list of changes has been updated
-        */
-
-       /**
-        * @event newChangesExist
-        * @param {boolean} newChangesExist
-        *
-        * The existence of changes newer than those currently displayed has changed.
-        */
-
-       /**
-        * @event liveUpdateChange
-        * @param {boolean} enable
-        *
-        * The state of the 'live update' feature has changed.
-        */
-
-       /* Methods */
-
-       /**
-        * Invalidate the list of changes
-        *
-        * @fires invalidate
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.invalidate = function () {
-               if ( this.valid ) {
-                       this.valid = false;
-                       this.emit( 'invalidate' );
-               }
-       };
-
-       /**
-        * Update the model with an updated list of changes
-        *
-        * @param {jQuery|string} changesListContent
-        * @param {jQuery} $fieldset
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
-        * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
-        * @fires update
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
-               var from = this.nextFrom;
-               this.valid = true;
-               this.extractNextFrom( $fieldset );
-               this.checkForUnseenWatchedChanges( changesListContent );
-               this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
-       };
-
-       /**
-        * Specify whether new changes exist
-        *
-        * @param {boolean} newChangesExist
-        * @fires newChangesExist
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
-               if ( newChangesExist !== this.newChangesExist ) {
-                       this.newChangesExist = newChangesExist;
-                       this.emit( 'newChangesExist', newChangesExist );
-               }
-       };
-
-       /**
-        * @return {boolean} Whether new changes exist
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.getNewChangesExist = function () {
-               return this.newChangesExist;
-       };
-
-       /**
-        * Extract the value of the 'from' parameter from a link in the field set
-        *
-        * @param {jQuery} $fieldset
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
-               var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
-               if ( data && data.from ) {
-                       this.nextFrom = data.from;
-               }
-       };
-
-       /**
-        * @return {string} The 'from' parameter that can be used to query new changes
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.getNextFrom = function () {
-               return this.nextFrom;
-       };
-
-       /**
-        * Toggle the 'live update' feature on/off
-        *
-        * @param {boolean} enable
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
-               enable = enable === undefined ? !this.liveUpdate : enable;
-               if ( enable !== this.liveUpdate ) {
-                       this.liveUpdate = enable;
-                       this.emit( 'liveUpdateChange', this.liveUpdate );
-               }
-       };
-
-       /**
-        * @return {boolean} The 'live update' feature is enabled
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.getLiveUpdate = function () {
-               return this.liveUpdate;
-       };
-
-       /**
-        * Check if some of the given changes watched and unseen
-        *
-        * @param {jQuery|string} changeslistContent
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
-               this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
-                       changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
-       };
-
-       /**
-        * @return {boolean} Whether some of the current changes are watched and unseen
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
-               return this.unseenWatchedChanges;
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js
deleted file mode 100644 (file)
index df2079e..0000000
+++ /dev/null
@@ -1,988 +0,0 @@
-( function () {
-       /**
-        * View model for a filter group
-        *
-        * @mixins OO.EventEmitter
-        * @mixins OO.EmitterList
-        *
-        * @constructor
-        * @param {string} name Group name
-        * @param {Object} [config] Configuration options
-        * @cfg {string} [type='send_unselected_if_any'] Group type
-        * @cfg {string} [view='default'] Name of the display group this group
-        *  is a part of.
-        * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
-        *  with a preference, does not participate in Saved Queries, and is
-        *  not shown in the active filters area.
-        * @cfg {string} [title] Group title
-        * @cfg {boolean} [hidden] This group is hidden from the regular menu views
-        *  and the active filters area.
-        * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
-        *  group from the URL, even if it wasn't initially set up.
-        * @cfg {number} [range] An object defining minimum and maximum values for numeric
-        *  groups. { min: x, max: y }
-        * @cfg {number} [minValue] Minimum value for numeric groups
-        * @cfg {string} [separator='|'] Value separator for 'string_options' groups
-        * @cfg {boolean} [active] Group is active
-        * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
-        * @cfg {Object} [conflicts] Defines the conflicts for this filter group
-        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
-        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
-        *  with 'default' and 'inverted' as keys.
-        * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
-        * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
-        * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
-        * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
-        * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
-        * @cfg {boolean} [visible=true] The visibility of the group
-        */
-       mw.rcfilters.dm.FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-               OO.EmitterList.call( this );
-
-               this.name = name;
-               this.type = config.type || 'send_unselected_if_any';
-               this.view = config.view || 'default';
-               this.sticky = !!config.sticky;
-               this.title = config.title || name;
-               this.hidden = !!config.hidden;
-               this.allowArbitrary = !!config.allowArbitrary;
-               this.numericRange = config.range;
-               this.separator = config.separator || '|';
-               this.labelPrefixKey = config.labelPrefixKey;
-               this.visible = config.visible === undefined ? true : !!config.visible;
-
-               this.currSelected = null;
-               this.active = !!config.active;
-               this.fullCoverage = !!config.fullCoverage;
-
-               this.whatsThis = config.whatsThis || {};
-
-               this.conflicts = config.conflicts || {};
-               this.defaultParams = {};
-               this.defaultFilters = {};
-
-               this.aggregate( { update: 'filterItemUpdate' } );
-               this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
-       };
-
-       /* Initialization */
-       OO.initClass( mw.rcfilters.dm.FilterGroup );
-       OO.mixinClass( mw.rcfilters.dm.FilterGroup, OO.EventEmitter );
-       OO.mixinClass( mw.rcfilters.dm.FilterGroup, OO.EmitterList );
-
-       /* Events */
-
-       /**
-        * @event update
-        *
-        * Group state has been updated
-        */
-
-       /* Methods */
-
-       /**
-        * Initialize the group and create its filter items
-        *
-        * @param {Object} filterDefinition Filter definition for this group
-        * @param {string|Object} [groupDefault] Definition of the group default
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
-               var defaultParam,
-                       supersetMap = {},
-                       model = this,
-                       items = [];
-
-               filterDefinition.forEach( function ( filter ) {
-                       // Instantiate an item
-                       var subsetNames = [],
-                               filterItem = new mw.rcfilters.dm.FilterItem( filter.name, model, {
-                                       group: model.getName(),
-                                       label: filter.label || filter.name,
-                                       description: filter.description || '',
-                                       labelPrefixKey: model.labelPrefixKey,
-                                       cssClass: filter.cssClass,
-                                       identifiers: filter.identifiers,
-                                       defaultHighlightColor: filter.defaultHighlightColor
-                               } );
-
-                       if ( filter.subset ) {
-                               filter.subset = filter.subset.map( function ( el ) {
-                                       return el.filter;
-                               } );
-
-                               subsetNames = [];
-
-                               filter.subset.forEach( function ( subsetFilterName ) {
-                                       // Subsets (unlike conflicts) are always inside the same group
-                                       // We can re-map the names of the filters we are getting from
-                                       // the subsets with the group prefix
-                                       var subsetName = model.getPrefixedName( subsetFilterName );
-                                       // For convenience, we should store each filter's "supersets" -- these are
-                                       // the filters that have that item in their subset list. This will just
-                                       // make it easier to go through whether the item has any other items
-                                       // that affect it (and are selected) at any given time
-                                       supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
-                                       mw.rcfilters.utils.addArrayElementsUnique(
-                                               supersetMap[ subsetName ],
-                                               filterItem.getName()
-                                       );
-
-                                       // Translate subset param name to add the group name, so we
-                                       // get consistent naming. We know that subsets are only within
-                                       // the same group
-                                       subsetNames.push( subsetName );
-                               } );
-
-                               // Set translated subset
-                               filterItem.setSubset( subsetNames );
-                       }
-
-                       items.push( filterItem );
-
-                       // Store default parameter state; in this case, default is defined per filter
-                       if (
-                               model.getType() === 'send_unselected_if_any' ||
-                               model.getType() === 'boolean'
-                       ) {
-                               // Store the default parameter state
-                               // For this group type, parameter values are direct
-                               // We need to convert from a boolean to a string ('1' and '0')
-                               model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
-                       } else if ( model.getType() === 'any_value' ) {
-                               model.defaultParams[ filter.name ] = filter.default;
-                       }
-               } );
-
-               // Add items
-               this.addItems( items );
-
-               // Now that we have all items, we can apply the superset map
-               this.getItems().forEach( function ( filterItem ) {
-                       filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
-               } );
-
-               // Store default parameter state; in this case, default is defined per the
-               // entire group, given by groupDefault method parameter
-               if ( this.getType() === 'string_options' ) {
-                       // Store the default parameter group state
-                       // For this group, the parameter is group name and value is the names
-                       // of selected items
-                       this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
-                               // Current values
-                               groupDefault ?
-                                       groupDefault.split( this.getSeparator() ) :
-                                       [],
-                               // Legal values
-                               this.getItems().map( function ( item ) {
-                                       return item.getParamName();
-                               } )
-                       ).join( this.getSeparator() );
-               } else if ( this.getType() === 'single_option' ) {
-                       defaultParam = groupDefault !== undefined ?
-                               groupDefault : this.getItems()[ 0 ].getParamName();
-
-                       // For this group, the parameter is the group name,
-                       // and a single item can be selected: default or first item
-                       this.defaultParams[ this.getName() ] = defaultParam;
-               }
-
-               // add highlights to defaultParams
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isHighlighted() ) {
-                               this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
-                       }
-               }.bind( this ) );
-
-               // Store default filter state based on default params
-               this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
-
-               // Check for filters that should be initially selected by their default value
-               if ( this.isSticky() ) {
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( this.defaultFilters, function ( filterName, filterValue ) {
-                               model.getItemByName( filterName ).toggleSelected( filterValue );
-                       } );
-               }
-
-               // Verify that single_option group has at least one item selected
-               if (
-                       this.getType() === 'single_option' &&
-                       this.findSelectedItems().length === 0
-               ) {
-                       defaultParam = groupDefault !== undefined ?
-                               groupDefault : this.getItems()[ 0 ].getParamName();
-
-                       // Single option means there must be a single option
-                       // selected, so we have to either select the default
-                       // or select the first option
-                       this.selectItemByParamName( defaultParam );
-               }
-       };
-
-       /**
-        * Respond to filterItem update event
-        *
-        * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
-        * @fires update
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
-               // Update state
-               var changed = false,
-                       active = this.areAnySelected(),
-                       model = this;
-
-               if ( this.getType() === 'single_option' ) {
-                       // This group must have one item selected always
-                       // and must never have more than one item selected at a time
-                       if ( this.findSelectedItems().length === 0 ) {
-                               // Nothing is selected anymore
-                               // Select the default or the first item
-                               this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
-                                       this.getItems()[ 0 ];
-                               this.currSelected.toggleSelected( true );
-                               changed = true;
-                       } else if ( this.findSelectedItems().length > 1 ) {
-                               // There is more than one item selected
-                               // This should only happen if the item given
-                               // is the one that is selected, so unselect
-                               // all items that is not it
-                               this.findSelectedItems().forEach( function ( itemModel ) {
-                                       // Note that in case the given item is actually
-                                       // not selected, this loop will end up unselecting
-                                       // all items, which would trigger the case above
-                                       // when the last item is unselected anyways
-                                       var selected = itemModel.getName() === item.getName() &&
-                                               item.isSelected();
-
-                                       itemModel.toggleSelected( selected );
-                                       if ( selected ) {
-                                               model.currSelected = itemModel;
-                                       }
-                               } );
-                               changed = true;
-                       }
-               }
-
-               if ( this.isSticky() ) {
-                       // If this group is sticky, then change the default according to the
-                       // current selection.
-                       this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
-               }
-
-               if (
-                       changed ||
-                       this.active !== active ||
-                       this.currSelected !== item
-               ) {
-                       this.active = active;
-                       this.currSelected = item;
-
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Get group active state
-        *
-        * @return {boolean} Active state
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isActive = function () {
-               return this.active;
-       };
-
-       /**
-        * Get group hidden state
-        *
-        * @return {boolean} Hidden state
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isHidden = function () {
-               return this.hidden;
-       };
-
-       /**
-        * Get group allow arbitrary state
-        *
-        * @return {boolean} Group allows an arbitrary value from the URL
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isAllowArbitrary = function () {
-               return this.allowArbitrary;
-       };
-
-       /**
-        * Get group maximum value for numeric groups
-        *
-        * @return {number|null} Group max value
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getMaxValue = function () {
-               return this.numericRange && this.numericRange.max !== undefined ?
-                       this.numericRange.max : null;
-       };
-
-       /**
-        * Get group minimum value for numeric groups
-        *
-        * @return {number|null} Group max value
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getMinValue = function () {
-               return this.numericRange && this.numericRange.min !== undefined ?
-                       this.numericRange.min : null;
-       };
-
-       /**
-        * Get group name
-        *
-        * @return {string} Group name
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getName = function () {
-               return this.name;
-       };
-
-       /**
-        * Get the default param state of this group
-        *
-        * @return {Object} Default param state
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getDefaultParams = function () {
-               return this.defaultParams;
-       };
-
-       /**
-        * Get the default filter state of this group
-        *
-        * @return {Object} Default filter state
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getDefaultFilters = function () {
-               return this.defaultFilters;
-       };
-
-       /**
-        * This is for a single_option and string_options group types
-        * it returns the value of the default
-        *
-        * @return {string} Value of the default
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getDefaulParamValue = function () {
-               return this.defaultParams[ this.getName() ];
-       };
-       /**
-        * Get the messags defining the 'whats this' popup for this group
-        *
-        * @return {Object} What's this messages
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getWhatsThis = function () {
-               return this.whatsThis;
-       };
-
-       /**
-        * Check whether this group has a 'what's this' message
-        *
-        * @return {boolean} This group has a what's this message
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.hasWhatsThis = function () {
-               return !!this.whatsThis.body;
-       };
-
-       /**
-        * Get the conflicts associated with the entire group.
-        * Conflict object is set up by filter name keys and conflict
-        * definition. For example:
-        * [
-        *     {
-        *         filterName: {
-        *             filter: filterName,
-        *             group: group1
-        *         }
-        *     },
-        *     {
-        *         filterName2: {
-        *             filter: filterName2,
-        *             group: group2
-        *         }
-        *     }
-        * ]
-        * @return {Object} Conflict definition
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getConflicts = function () {
-               return this.conflicts;
-       };
-
-       /**
-        * Set conflicts for this group. See #getConflicts for the expected
-        * structure of the definition.
-        *
-        * @param {Object} conflicts Conflicts for this group
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.setConflicts = function ( conflicts ) {
-               this.conflicts = conflicts;
-       };
-
-       /**
-        * Set conflicts for each filter item in the group based on the
-        * given conflict map
-        *
-        * @param {Object} conflicts Object representing the conflict map,
-        *  keyed by the item name, where its value is an object for all its conflicts
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( conflicts[ filterItem.getName() ] ) {
-                               filterItem.setConflicts( conflicts[ filterItem.getName() ] );
-                       }
-               } );
-       };
-
-       /**
-        * Check whether this item has a potential conflict with the given item
-        *
-        * This checks whether the given item is in the list of conflicts of
-        * the current item, but makes no judgment about whether the conflict
-        * is currently at play (either one of the items may not be selected)
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
-        * @return {boolean} This item has a conflict with the given item
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
-               return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
-       };
-
-       /**
-        * Check whether there are any items selected
-        *
-        * @return {boolean} Any items in the group are selected
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.areAnySelected = function () {
-               return this.getItems().some( function ( filterItem ) {
-                       return filterItem.isSelected();
-               } );
-       };
-
-       /**
-        * Check whether all items selected
-        *
-        * @return {boolean} All items are selected
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.areAllSelected = function () {
-               var selected = [],
-                       unselected = [];
-
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isSelected() ) {
-                               selected.push( filterItem );
-                       } else {
-                               unselected.push( filterItem );
-                       }
-               } );
-
-               if ( unselected.length === 0 ) {
-                       return true;
-               }
-
-               // check if every unselected is a subset of a selected
-               return unselected.every( function ( unselectedFilterItem ) {
-                       return selected.some( function ( selectedFilterItem ) {
-                               return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
-                       } );
-               } );
-       };
-
-       /**
-        * Get all selected items in this group
-        *
-        * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
-        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
-               var excludeName = ( excludeItem && excludeItem.getName() ) || '';
-
-               return this.getItems().filter( function ( item ) {
-                       return item.getName() !== excludeName && item.isSelected();
-               } );
-       };
-
-       /**
-        * Check whether all selected items are in conflict with the given item
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
-        * @return {boolean} All selected items are in conflict with this item
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
-               var selectedItems = this.findSelectedItems( filterItem );
-
-               return selectedItems.length > 0 &&
-                       (
-                               // The group as a whole is in conflict with this item
-                               this.existsInConflicts( filterItem ) ||
-                               // All selected items are in conflict individually
-                               selectedItems.every( function ( selectedFilter ) {
-                                       return selectedFilter.existsInConflicts( filterItem );
-                               } )
-                       );
-       };
-
-       /**
-        * Check whether any of the selected items are in conflict with the given item
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
-        * @return {boolean} Any of the selected items are in conflict with this item
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
-               var selectedItems = this.findSelectedItems( filterItem );
-
-               return selectedItems.length > 0 && (
-                       // The group as a whole is in conflict with this item
-                       this.existsInConflicts( filterItem ) ||
-                       // Any selected items are in conflict individually
-                       selectedItems.some( function ( selectedFilter ) {
-                               return selectedFilter.existsInConflicts( filterItem );
-                       } )
-               );
-       };
-
-       /**
-        * Get the parameter representation from this group
-        *
-        * @param {Object} [filterRepresentation] An object defining the state
-        *  of the filters in this group, keyed by their name and current selected
-        *  state value.
-        * @return {Object} Parameter representation
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
-               var values,
-                       areAnySelected = false,
-                       buildFromCurrentState = !filterRepresentation,
-                       defaultFilters = this.getDefaultFilters(),
-                       result = {},
-                       model = this,
-                       filterParamNames = {},
-                       getSelectedParameter = function ( filters ) {
-                               var item,
-                                       selected = [];
-
-                               // Find if any are selected
-                               // eslint-disable-next-line jquery/no-each-util
-                               $.each( filters, function ( name, value ) {
-                                       if ( value ) {
-                                               selected.push( name );
-                                       }
-                               } );
-
-                               item = model.getItemByName( selected[ 0 ] );
-                               return ( item && item.getParamName() ) || '';
-                       };
-
-               filterRepresentation = filterRepresentation || {};
-
-               // Create or complete the filterRepresentation definition
-               this.getItems().forEach( function ( item ) {
-                       // Map filter names to their parameter names
-                       filterParamNames[ item.getName() ] = item.getParamName();
-
-                       if ( buildFromCurrentState ) {
-                               // This means we have not been given a filter representation
-                               // so we are building one based on current state
-                               filterRepresentation[ item.getName() ] = item.getValue();
-                       } else if ( filterRepresentation[ item.getName() ] === undefined ) {
-                               // We are given a filter representation, but we have to make
-                               // sure that we fill in the missing filters if there are any
-                               // we will assume they are all falsey
-                               if ( model.isSticky() ) {
-                                       filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
-                               } else {
-                                       filterRepresentation[ item.getName() ] = false;
-                               }
-                       }
-
-                       if ( filterRepresentation[ item.getName() ] ) {
-                               areAnySelected = true;
-                       }
-               } );
-
-               // Build result
-               if (
-                       this.getType() === 'send_unselected_if_any' ||
-                       this.getType() === 'boolean' ||
-                       this.getType() === 'any_value'
-               ) {
-                       // First, check if any of the items are selected at all.
-                       // If none is selected, we're treating it as if they are
-                       // all false
-
-                       // Go over the items and define the correct values
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( filterRepresentation, function ( name, value ) {
-                               // We must store all parameter values as strings '0' or '1'
-                               if ( model.getType() === 'send_unselected_if_any' ) {
-                                       result[ filterParamNames[ name ] ] = areAnySelected ?
-                                               String( Number( !value ) ) :
-                                               '0';
-                               } else if ( model.getType() === 'boolean' ) {
-                                       // Representation is straight-forward and direct from
-                                       // the parameter value to the filter state
-                                       result[ filterParamNames[ name ] ] = String( Number( !!value ) );
-                               } else if ( model.getType() === 'any_value' ) {
-                                       result[ filterParamNames[ name ] ] = value;
-                               }
-                       } );
-               } else if ( this.getType() === 'string_options' ) {
-                       values = [];
-
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( filterRepresentation, function ( name, value ) {
-                               // Collect values
-                               if ( value ) {
-                                       values.push( filterParamNames[ name ] );
-                               }
-                       } );
-
-                       result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
-                               'all' : values.join( this.getSeparator() );
-               } else if ( this.getType() === 'single_option' ) {
-                       result[ this.getName() ] = getSelectedParameter( filterRepresentation );
-               }
-
-               return result;
-       };
-
-       /**
-        * Get the filter representation this group would provide
-        * based on given parameter states.
-        *
-        * @param {Object} [paramRepresentation] An object defining a parameter
-        *  state to translate the filter state from. If not given, an object
-        *  representing all filters as falsey is returned; same as if the parameter
-        *  given were an empty object, or had some of the filters missing.
-        * @return {Object} Filter representation
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
-               var areAnySelected, paramValues, item, currentValue,
-                       oneWasSelected = false,
-                       defaultParams = this.getDefaultParams(),
-                       expandedParams = $.extend( true, {}, paramRepresentation ),
-                       model = this,
-                       paramToFilterMap = {},
-                       result = {};
-
-               if ( this.isSticky() ) {
-                       // If the group is sticky, check if all parameters are represented
-                       // and for those that aren't represented, add them with their default
-                       // values
-                       paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
-               }
-
-               paramRepresentation = paramRepresentation || {};
-               if (
-                       this.getType() === 'send_unselected_if_any' ||
-                       this.getType() === 'boolean' ||
-                       this.getType() === 'any_value'
-               ) {
-                       // Go over param representation; map and check for selections
-                       this.getItems().forEach( function ( filterItem ) {
-                               var paramName = filterItem.getParamName();
-
-                               expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
-                               paramToFilterMap[ paramName ] = filterItem;
-
-                               if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
-                                       areAnySelected = true;
-                               }
-                       } );
-
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( expandedParams, function ( paramName, paramValue ) {
-                               var filterItem = paramToFilterMap[ paramName ];
-
-                               if ( model.getType() === 'send_unselected_if_any' ) {
-                                       // Flip the definition between the parameter
-                                       // state and the filter state
-                                       // This is what the 'toggleSelected' value of the filter is
-                                       result[ filterItem.getName() ] = areAnySelected ?
-                                               !Number( paramValue ) :
-                                               // Otherwise, there are no selected items in the
-                                               // group, which means the state is false
-                                               false;
-                               } else if ( model.getType() === 'boolean' ) {
-                                       // Straight-forward definition of state
-                                       result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
-                               } else if ( model.getType() === 'any_value' ) {
-                                       result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
-                               }
-                       } );
-               } else if ( this.getType() === 'string_options' ) {
-                       currentValue = paramRepresentation[ this.getName() ] || '';
-
-                       // Normalize the given parameter values
-                       paramValues = mw.rcfilters.utils.normalizeParamOptions(
-                               // Given
-                               currentValue.split(
-                                       this.getSeparator()
-                               ),
-                               // Allowed values
-                               this.getItems().map( function ( filterItem ) {
-                                       return filterItem.getParamName();
-                               } )
-                       );
-                       // Translate the parameter values into a filter selection state
-                       this.getItems().forEach( function ( filterItem ) {
-                               // All true (either because all values are written or the term 'all' is written)
-                               // is the same as all filters set to true
-                               result[ filterItem.getName() ] = (
-                                       // If it is the word 'all'
-                                       paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
-                                       // All values are written
-                                       paramValues.length === model.getItemCount()
-                               ) ?
-                                       true :
-                                       // Otherwise, the filter is selected only if it appears in the parameter values
-                                       paramValues.indexOf( filterItem.getParamName() ) > -1;
-                       } );
-               } else if ( this.getType() === 'single_option' ) {
-                       // There is parameter that fits a single filter and if not, get the default
-                       this.getItems().forEach( function ( filterItem ) {
-                               var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];
-
-                               result[ filterItem.getName() ] = selected;
-                               oneWasSelected = oneWasSelected || selected;
-                       } );
-               }
-
-               // Go over result and make sure all filters are represented.
-               // If any filters are missing, they will get a falsey value
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( result[ filterItem.getName() ] === undefined ) {
-                               result[ filterItem.getName() ] = this.getFalsyValue();
-                       }
-               }.bind( this ) );
-
-               // Make sure that at least one option is selected in
-               // single_option groups, no matter what path was taken
-               // If none was selected by the given definition, then
-               // we need to select the one in the base state -- either
-               // the default given, or the first item
-               if (
-                       this.getType() === 'single_option' &&
-                       !oneWasSelected
-               ) {
-                       item = this.getItems()[ 0 ];
-                       if ( defaultParams[ this.getName() ] ) {
-                               item = this.getItemByParamName( defaultParams[ this.getName() ] );
-                       }
-
-                       result[ item.getName() ] = true;
-               }
-
-               return result;
-       };
-
-       /**
-        * @return {*} The appropriate falsy value for this group type
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getFalsyValue = function () {
-               return this.getType() === 'any_value' ? '' : false;
-       };
-
-       /**
-        * Get current selected state of all filter items in this group
-        *
-        * @return {Object} Selected state
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getSelectedState = function () {
-               var state = {};
-
-               this.getItems().forEach( function ( filterItem ) {
-                       state[ filterItem.getName() ] = filterItem.getValue();
-               } );
-
-               return state;
-       };
-
-       /**
-        * Get item by its filter name
-        *
-        * @param {string} filterName Filter name
-        * @return {mw.rcfilters.dm.FilterItem} Filter item
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getItemByName = function ( filterName ) {
-               return this.getItems().filter( function ( item ) {
-                       return item.getName() === filterName;
-               } )[ 0 ];
-       };
-
-       /**
-        * Select an item by its parameter name
-        *
-        * @param {string} paramName Filter parameter name
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.selectItemByParamName = function ( paramName ) {
-               this.getItems().forEach( function ( item ) {
-                       item.toggleSelected( item.getParamName() === String( paramName ) );
-               } );
-       };
-
-       /**
-        * Get item by its parameter name
-        *
-        * @param {string} paramName Parameter name
-        * @return {mw.rcfilters.dm.FilterItem} Filter item
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getItemByParamName = function ( paramName ) {
-               return this.getItems().filter( function ( item ) {
-                       return item.getParamName() === String( paramName );
-               } )[ 0 ];
-       };
-
-       /**
-        * Get group type
-        *
-        * @return {string} Group type
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getType = function () {
-               return this.type;
-       };
-
-       /**
-        * Check whether this group is represented by a single parameter
-        * or whether each item is its own parameter
-        *
-        * @return {boolean} This group is a single parameter
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isPerGroupRequestParameter = function () {
-               return (
-                       this.getType() === 'string_options' ||
-                       this.getType() === 'single_option'
-               );
-       };
-
-       /**
-        * Get display group
-        *
-        * @return {string} Display group
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getView = function () {
-               return this.view;
-       };
-
-       /**
-        * Get the prefix used for the filter names inside this group.
-        *
-        * @param {string} [name] Filter name to prefix
-        * @return {string} Group prefix
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getNamePrefix = function () {
-               return this.getName() + '__';
-       };
-
-       /**
-        * Get a filter name with the prefix used for the filter names inside this group.
-        *
-        * @param {string} name Filter name to prefix
-        * @return {string} Group prefix
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getPrefixedName = function ( name ) {
-               return this.getNamePrefix() + name;
-       };
-
-       /**
-        * Get group's title
-        *
-        * @return {string} Title
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getTitle = function () {
-               return this.title;
-       };
-
-       /**
-        * Get group's values separator
-        *
-        * @return {string} Values separator
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getSeparator = function () {
-               return this.separator;
-       };
-
-       /**
-        * Check whether the group is defined as full coverage
-        *
-        * @return {boolean} Group is full coverage
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isFullCoverage = function () {
-               return this.fullCoverage;
-       };
-
-       /**
-        * Check whether the group is defined as sticky default
-        *
-        * @return {boolean} Group is sticky default
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isSticky = function () {
-               return this.sticky;
-       };
-
-       /**
-        * Normalize a value given to this group. This is mostly for correcting
-        * arbitrary values for 'single option' groups, given by the user settings
-        * or the URL that can go outside the limits that are allowed.
-        *
-        * @param  {string} value Given value
-        * @return {string} Corrected value
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
-               if (
-                       this.getType() === 'single_option' &&
-                       this.isAllowArbitrary()
-               ) {
-                       if (
-                               this.getMaxValue() !== null &&
-                               value > this.getMaxValue()
-                       ) {
-                               // Change the value to the actual max value
-                               return String( this.getMaxValue() );
-                       } else if (
-                               this.getMinValue() !== null &&
-                               value < this.getMinValue()
-                       ) {
-                               // Change the value to the actual min value
-                               return String( this.getMinValue() );
-                       }
-               }
-
-               return value;
-       };
-
-       /**
-        * Toggle the visibility of this group
-        *
-        * @param {boolean} [isVisible] Item is visible
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.toggleVisible = function ( isVisible ) {
-               isVisible = isVisible === undefined ? !this.visible : isVisible;
-
-               if ( this.visible !== isVisible ) {
-                       this.visible = isVisible;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Check whether the group is visible
-        *
-        * @return {boolean} Group is visible
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isVisible = function () {
-               return this.visible;
-       };
-
-       /**
-        * Set the visibility of the items under this group by the given items array
-        *
-        * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
-               this.getItems().forEach( function ( itemModel ) {
-                       itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
-               } );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js
deleted file mode 100644 (file)
index dac61b2..0000000
+++ /dev/null
@@ -1,400 +0,0 @@
-( function () {
-       /**
-        * Filter item model
-        *
-        * @extends mw.rcfilters.dm.ItemModel
-        *
-        * @constructor
-        * @param {string} param Filter param name
-        * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
-        * @param {Object} config Configuration object
-        * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
-        *  selected, makes inactive.
-        * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
-        * @cfg {Object} [conflicts] Defines the conflicts for this filter
-        * @cfg {boolean} [visible=true] The visibility of the group
-        */
-       mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
-               config = config || {};
-
-               this.groupModel = groupModel;
-
-               // Parent
-               mw.rcfilters.dm.FilterItem.parent.call( this, param, $.extend( {
-                       namePrefix: this.groupModel.getNamePrefix()
-               }, config ) );
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               // Interaction definitions
-               this.subset = config.subset || [];
-               this.conflicts = config.conflicts || {};
-               this.superset = [];
-               this.visible = config.visible === undefined ? true : !!config.visible;
-
-               // Interaction states
-               this.included = false;
-               this.conflicted = false;
-               this.fullyCovered = false;
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.dm.FilterItem, mw.rcfilters.dm.ItemModel );
-
-       /* Methods */
-
-       /**
-        * Return the representation of the state of this item.
-        *
-        * @return {Object} State of the object
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getState = function () {
-               return {
-                       selected: this.isSelected(),
-                       included: this.isIncluded(),
-                       conflicted: this.isConflicted(),
-                       fullyCovered: this.isFullyCovered()
-               };
-       };
-
-       /**
-        * Get the message for the display area for the currently active conflict
-        *
-        * @private
-        * @return {string} Conflict result message key
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getCurrentConflictResultMessage = function () {
-               var details = {};
-
-               // First look in filter's own conflicts
-               details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
-               if ( !details.message ) {
-                       // Fall back onto conflicts in the group
-                       details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
-               }
-
-               return details.message;
-       };
-
-       /**
-        * Get the details of the active conflict on this filter
-        *
-        * @private
-        * @param {Object} conflicts Conflicts to examine
-        * @param {string} [key='contextDescription'] Message key
-        * @return {Object} Object with conflict message and conflict items
-        * @return {string} return.message Conflict message
-        * @return {string[]} return.names Conflicting item labels
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
-               var group,
-                       conflictMessage = '',
-                       itemLabels = [];
-
-               key = key || 'contextDescription';
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( conflicts, function ( filterName, conflict ) {
-                       if ( !conflict.item.isSelected() ) {
-                               return;
-                       }
-
-                       if ( !conflictMessage ) {
-                               conflictMessage = conflict[ key ];
-                               group = conflict.group;
-                       }
-
-                       if ( group === conflict.group ) {
-                               itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
-                       }
-               } );
-
-               return {
-                       message: conflictMessage,
-                       names: itemLabels
-               };
-
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getStateMessage = function () {
-               var messageKey, details, superset,
-                       affectingItems = [];
-
-               if ( this.isSelected() ) {
-                       if ( this.isConflicted() ) {
-                               // First look in filter's own conflicts
-                               details = this.getConflictDetails( this.getOwnConflicts() );
-                               if ( !details.message ) {
-                                       // Fall back onto conflicts in the group
-                                       details = this.getConflictDetails( this.getGroupModel().getConflicts() );
-                               }
-
-                               messageKey = details.message;
-                               affectingItems = details.names;
-                       } else if ( this.isIncluded() && !this.isHighlighted() ) {
-                               // We only show the 'no effect' full-coverage message
-                               // if the item is also not highlighted. See T161273
-                               superset = this.getSuperset();
-                               // For this message we need to collect the affecting superset
-                               affectingItems = this.getGroupModel().findSelectedItems( this )
-                                       .filter( function ( item ) {
-                                               return superset.indexOf( item.getName() ) !== -1;
-                                       } )
-                                       .map( function ( item ) {
-                                               return mw.msg( 'quotation-marks', item.getLabel() );
-                                       } );
-
-                               messageKey = 'rcfilters-state-message-subset';
-                       } else if ( this.isFullyCovered() && !this.isHighlighted() ) {
-                               affectingItems = this.getGroupModel().findSelectedItems( this )
-                                       .map( function ( item ) {
-                                               return mw.msg( 'quotation-marks', item.getLabel() );
-                                       } );
-
-                               messageKey = 'rcfilters-state-message-fullcoverage';
-                       }
-               }
-
-               if ( messageKey ) {
-                       // Build message
-                       return mw.msg(
-                               messageKey,
-                               mw.language.listToText( affectingItems ),
-                               affectingItems.length
-                       );
-               }
-
-               // Display description
-               return this.getDescription();
-       };
-
-       /**
-        * Get the model of the group this filter belongs to
-        *
-        * @return {mw.rcfilters.dm.FilterGroup} Filter group model
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getGroupModel = function () {
-               return this.groupModel;
-       };
-
-       /**
-        * Get the group name this filter belongs to
-        *
-        * @return {string} Filter group name
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getGroupName = function () {
-               return this.groupModel.getName();
-       };
-
-       /**
-        * Get filter subset
-        * This is a list of filter names that are defined to be included
-        * when this filter is selected.
-        *
-        * @return {string[]} Filter subset
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getSubset = function () {
-               return this.subset;
-       };
-
-       /**
-        * Get filter superset
-        * This is a generated list of filters that define this filter
-        * to be included when either of them is selected.
-        *
-        * @return {string[]} Filter superset
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getSuperset = function () {
-               return this.superset;
-       };
-
-       /**
-        * Check whether the filter is currently in a conflict state
-        *
-        * @return {boolean} Filter is in conflict state
-        */
-       mw.rcfilters.dm.FilterItem.prototype.isConflicted = function () {
-               return this.conflicted;
-       };
-
-       /**
-        * Check whether the filter is currently in an already included subset
-        *
-        * @return {boolean} Filter is in an already-included subset
-        */
-       mw.rcfilters.dm.FilterItem.prototype.isIncluded = function () {
-               return this.included;
-       };
-
-       /**
-        * Check whether the filter is currently fully covered
-        *
-        * @return {boolean} Filter is in fully-covered state
-        */
-       mw.rcfilters.dm.FilterItem.prototype.isFullyCovered = function () {
-               return this.fullyCovered;
-       };
-
-       /**
-        * Get all conflicts associated with this filter or its group
-        *
-        * Conflict object is set up by filter name keys and conflict
-        * definition. For example:
-        *
-        *  {
-        *      filterName: {
-        *          filter: filterName,
-        *          group: group1,
-        *          label: itemLabel,
-        *          item: itemModel
-        *      }
-        *      filterName2: {
-        *          filter: filterName2,
-        *          group: group2
-        *          label: itemLabel2,
-        *          item: itemModel2
-        *      }
-        *  }
-        *
-        * @return {Object} Filter conflicts
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getConflicts = function () {
-               return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
-       };
-
-       /**
-        * Get the conflicts associated with this filter
-        *
-        * @return {Object} Filter conflicts
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getOwnConflicts = function () {
-               return this.conflicts;
-       };
-
-       /**
-        * Set conflicts for this filter. See #getConflicts for the expected
-        * structure of the definition.
-        *
-        * @param {Object} conflicts Conflicts for this filter
-        */
-       mw.rcfilters.dm.FilterItem.prototype.setConflicts = function ( conflicts ) {
-               this.conflicts = conflicts || {};
-       };
-
-       /**
-        * Set filter superset
-        *
-        * @param {string[]} superset Filter superset
-        */
-       mw.rcfilters.dm.FilterItem.prototype.setSuperset = function ( superset ) {
-               this.superset = superset || [];
-       };
-
-       /**
-        * Set filter subset
-        *
-        * @param {string[]} subset Filter subset
-        */
-       mw.rcfilters.dm.FilterItem.prototype.setSubset = function ( subset ) {
-               this.subset = subset || [];
-       };
-
-       /**
-        * Check whether a filter exists in the subset list for this filter
-        *
-        * @param {string} filterName Filter name
-        * @return {boolean} Filter name is in the subset list
-        */
-       mw.rcfilters.dm.FilterItem.prototype.existsInSubset = function ( filterName ) {
-               return this.subset.indexOf( filterName ) > -1;
-       };
-
-       /**
-        * Check whether this item has a potential conflict with the given item
-        *
-        * This checks whether the given item is in the list of conflicts of
-        * the current item, but makes no judgment about whether the conflict
-        * is currently at play (either one of the items may not be selected)
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
-        * @return {boolean} This item has a conflict with the given item
-        */
-       mw.rcfilters.dm.FilterItem.prototype.existsInConflicts = function ( filterItem ) {
-               return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
-       };
-
-       /**
-        * Set the state of this filter as being conflicted
-        * (This means any filters in its conflicts are selected)
-        *
-        * @param {boolean} [conflicted] Filter is in conflict state
-        * @fires update
-        */
-       mw.rcfilters.dm.FilterItem.prototype.toggleConflicted = function ( conflicted ) {
-               conflicted = conflicted === undefined ? !this.conflicted : conflicted;
-
-               if ( this.conflicted !== conflicted ) {
-                       this.conflicted = conflicted;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Set the state of this filter as being already included
-        * (This means any filters in its superset are selected)
-        *
-        * @param {boolean} [included] Filter is included as part of a subset
-        * @fires update
-        */
-       mw.rcfilters.dm.FilterItem.prototype.toggleIncluded = function ( included ) {
-               included = included === undefined ? !this.included : included;
-
-               if ( this.included !== included ) {
-                       this.included = included;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Toggle the fully covered state of the item
-        *
-        * @param {boolean} [isFullyCovered] Filter is fully covered
-        * @fires update
-        */
-       mw.rcfilters.dm.FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
-               isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
-
-               if ( this.fullyCovered !== isFullyCovered ) {
-                       this.fullyCovered = isFullyCovered;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Toggle the visibility of this item
-        *
-        * @param {boolean} [isVisible] Item is visible
-        */
-       mw.rcfilters.dm.FilterItem.prototype.toggleVisible = function ( isVisible ) {
-               isVisible = isVisible === undefined ? !this.visible : !!isVisible;
-
-               if ( this.visible !== isVisible ) {
-                       this.visible = isVisible;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Check whether the item is visible
-        *
-        * @return {boolean} Item is visible
-        */
-       mw.rcfilters.dm.FilterItem.prototype.isVisible = function () {
-               return this.visible;
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
deleted file mode 100644 (file)
index 5d51d10..0000000
+++ /dev/null
@@ -1,1295 +0,0 @@
-( function () {
-       /**
-        * View model for the filters selection and display
-        *
-        * @mixins OO.EventEmitter
-        * @mixins OO.EmitterList
-        *
-        * @constructor
-        */
-       mw.rcfilters.dm.FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-               OO.EmitterList.call( this );
-
-               this.groups = {};
-               this.defaultParams = {};
-               this.highlightEnabled = false;
-               this.parameterMap = {};
-               this.emptyParameterState = null;
-
-               this.views = {};
-               this.currentView = 'default';
-               this.searchQuery = null;
-
-               // Events
-               this.aggregate( { update: 'filterItemUpdate' } );
-               this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
-       };
-
-       /* Initialization */
-       OO.initClass( mw.rcfilters.dm.FiltersViewModel );
-       OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EventEmitter );
-       OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EmitterList );
-
-       /* Events */
-
-       /**
-        * @event initialize
-        *
-        * Filter list is initialized
-        */
-
-       /**
-        * @event update
-        *
-        * Model has been updated
-        */
-
-       /**
-        * @event itemUpdate
-        * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
-        *
-        * Filter item has changed
-        */
-
-       /**
-        * @event highlightChange
-        * @param {boolean} Highlight feature is enabled
-        *
-        * Highlight feature has been toggled enabled or disabled
-        */
-
-       /* Methods */
-
-       /**
-        * Re-assess the states of filter items based on the interactions between them
-        *
-        * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
-        *  method will go over the state of all items
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
-               var allSelected,
-                       model = this,
-                       iterationItems = item !== undefined ? [ item ] : this.getItems();
-
-               iterationItems.forEach( function ( checkedItem ) {
-                       var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
-                               groupModel = checkedItem.getGroupModel();
-
-                       // Check for subsets (included filters) plus the item itself:
-                       allCheckedItems.forEach( function ( filterItemName ) {
-                               var itemInSubset = model.getItemByName( filterItemName );
-
-                               itemInSubset.toggleIncluded(
-                                       // If any of itemInSubset's supersets are selected, this item
-                                       // is included
-                                       itemInSubset.getSuperset().some( function ( supersetName ) {
-                                               return ( model.getItemByName( supersetName ).isSelected() );
-                                       } )
-                               );
-                       } );
-
-                       // Update coverage for the changed group
-                       if ( groupModel.isFullCoverage() ) {
-                               allSelected = groupModel.areAllSelected();
-                               groupModel.getItems().forEach( function ( filterItem ) {
-                                       filterItem.toggleFullyCovered( allSelected );
-                               } );
-                       }
-               } );
-
-               // Check for conflicts
-               // In this case, we must go over all items, since
-               // conflicts are bidirectional and depend not only on
-               // individual items, but also on the selected states of
-               // the groups they're in.
-               this.getItems().forEach( function ( filterItem ) {
-                       var inConflict = false,
-                               filterItemGroup = filterItem.getGroupModel();
-
-                       // For each item, see if that item is still conflicting
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( model.groups, function ( groupName, groupModel ) {
-                               if ( filterItem.getGroupName() === groupName ) {
-                                       // Check inside the group
-                                       inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
-                               } else {
-                                       // According to the spec, if two items conflict from two different
-                                       // groups, the conflict only lasts if the groups **only have selected
-                                       // items that are conflicting**. If a group has selected items that
-                                       // are conflicting and non-conflicting, the scope of the result has
-                                       // expanded enough to completely remove the conflict.
-
-                                       // For example, see two groups with conflicts:
-                                       // userExpLevel: [
-                                       //   {
-                                       //     name: 'experienced',
-                                       //     conflicts: [ 'unregistered' ]
-                                       //   }
-                                       // ],
-                                       // registration: [
-                                       //   {
-                                       //     name: 'registered',
-                                       //   },
-                                       //   {
-                                       //     name: 'unregistered',
-                                       //   }
-                                       // ]
-                                       // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
-                                       // because, inherently, 'experienced' filter only includes registered users, and so
-                                       // both filters are in conflict with one another.
-                                       // However, the minute we select 'registered', the scope of our results
-                                       // has expanded to no longer have a conflict with 'experienced' filter, and
-                                       // so the conflict is removed.
-
-                                       // In our case, we need to check if the entire group conflicts with
-                                       // the entire item's group, so we follow the above spec
-                                       inConflict = (
-                                               // The foreign group is in conflict with this item
-                                               groupModel.areAllSelectedInConflictWith( filterItem ) &&
-                                               // Every selected member of the item's own group is also
-                                               // in conflict with the other group
-                                               filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
-                                                       return groupModel.areAllSelectedInConflictWith( otherGroupItem );
-                                               } )
-                                       );
-                               }
-
-                               // If we're in conflict, this will return 'false' which
-                               // will break the loop. Otherwise, we're not in conflict
-                               // and the loop continues
-                               return !inConflict;
-                       } );
-
-                       // Toggle the item state
-                       filterItem.toggleConflicted( inConflict );
-               } );
-       };
-
-       /**
-        * Get whether the model has any conflict in its items
-        *
-        * @return {boolean} There is a conflict
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.hasConflict = function () {
-               return this.getItems().some( function ( filterItem ) {
-                       return filterItem.isSelected() && filterItem.isConflicted();
-               } );
-       };
-
-       /**
-        * Get the first item with a current conflict
-        *
-        * @return {mw.rcfilters.dm.FilterItem} Conflicted item
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFirstConflictedItem = function () {
-               var conflictedItem;
-
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isSelected() && filterItem.isConflicted() ) {
-                               conflictedItem = filterItem;
-                               return false;
-                       }
-               } );
-
-               return conflictedItem;
-       };
-
-       /**
-        * Set filters and preserve a group relationship based on
-        * the definition given by an object
-        *
-        * @param {Array} filterGroups Filters definition
-        * @param {Object} [views] Extra views definition
-        *  Expected in the following format:
-        *  {
-        *     namespaces: {
-        *       label: 'namespaces', // Message key
-        *       trigger: ':',
-        *       groups: [
-        *         {
-        *            // Group info
-        *            name: 'namespaces' // Parameter name
-        *            title: 'namespaces' // Message key
-        *            type: 'string_options',
-        *            separator: ';',
-        *            labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
-        *            fullCoverage: true
-        *            items: []
-        *         }
-        *       ]
-        *     }
-        *  }
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
-               var filterConflictResult, groupConflictResult,
-                       allViews = {},
-                       model = this,
-                       items = [],
-                       groupConflictMap = {},
-                       filterConflictMap = {},
-                       /*!
-                        * Expand a conflict definition from group name to
-                        * the list of all included filters in that group.
-                        * We do this so that the direct relationship in the
-                        * models are consistently item->items rather than
-                        * mixing item->group with item->item.
-                        *
-                        * @param {Object} obj Conflict definition
-                        * @return {Object} Expanded conflict definition
-                        */
-                       expandConflictDefinitions = function ( obj ) {
-                               var result = {};
-
-                               // eslint-disable-next-line jquery/no-each-util
-                               $.each( obj, function ( key, conflicts ) {
-                                       var filterName,
-                                               adjustedConflicts = {};
-
-                                       conflicts.forEach( function ( conflict ) {
-                                               var filter;
-
-                                               if ( conflict.filter ) {
-                                                       filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
-                                                       filter = model.getItemByName( filterName );
-
-                                                       // Rename
-                                                       adjustedConflicts[ filterName ] = $.extend(
-                                                               {},
-                                                               conflict,
-                                                               {
-                                                                       filter: filterName,
-                                                                       item: filter
-                                                               }
-                                                       );
-                                               } else {
-                                                       // This conflict is for an entire group. Split it up to
-                                                       // represent each filter
-
-                                                       // Get the relevant group items
-                                                       model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
-                                                               // Rebuild the conflict
-                                                               adjustedConflicts[ groupItem.getName() ] = $.extend(
-                                                                       {},
-                                                                       conflict,
-                                                                       {
-                                                                               filter: groupItem.getName(),
-                                                                               item: groupItem
-                                                                       }
-                                                               );
-                                                       } );
-                                               }
-                                       } );
-
-                                       result[ key ] = adjustedConflicts;
-                               } );
-
-                               return result;
-                       };
-
-               // Reset
-               this.clearItems();
-               this.groups = {};
-               this.views = {};
-
-               // Clone
-               filterGroups = OO.copy( filterGroups );
-
-               // Normalize definition from the server
-               filterGroups.forEach( function ( data ) {
-                       var i;
-                       // What's this information needs to be normalized
-                       data.whatsThis = {
-                               body: data.whatsThisBody,
-                               header: data.whatsThisHeader,
-                               linkText: data.whatsThisLinkText,
-                               url: data.whatsThisUrl
-                       };
-
-                       // Title is a msg-key
-                       data.title = data.title ? mw.msg( data.title ) : data.name;
-
-                       // Filters are given to us with msg-keys, we need
-                       // to translate those before we hand them off
-                       for ( i = 0; i < data.filters.length; i++ ) {
-                               data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
-                               data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
-                       }
-               } );
-
-               // Collect views
-               allViews = $.extend( true, {
-                       default: {
-                               title: mw.msg( 'rcfilters-filterlist-title' ),
-                               groups: filterGroups
-                       }
-               }, views );
-
-               // Go over all views
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( allViews, function ( viewName, viewData ) {
-                       // Define the view
-                       model.views[ viewName ] = {
-                               name: viewData.name,
-                               title: viewData.title,
-                               trigger: viewData.trigger
-                       };
-
-                       // Go over groups
-                       viewData.groups.forEach( function ( groupData ) {
-                               var group = groupData.name;
-
-                               if ( !model.groups[ group ] ) {
-                                       model.groups[ group ] = new mw.rcfilters.dm.FilterGroup(
-                                               group,
-                                               $.extend( true, {}, groupData, { view: viewName } )
-                                       );
-                               }
-
-                               model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
-                               items = items.concat( model.groups[ group ].getItems() );
-
-                               // Prepare conflicts
-                               if ( groupData.conflicts ) {
-                                       // Group conflicts
-                                       groupConflictMap[ group ] = groupData.conflicts;
-                               }
-
-                               groupData.filters.forEach( function ( itemData ) {
-                                       var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
-                                       // Filter conflicts
-                                       if ( itemData.conflicts ) {
-                                               filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
-                                       }
-                               } );
-                       } );
-               } );
-
-               // Add item references to the model, for lookup
-               this.addItems( items );
-
-               // Expand conflicts
-               groupConflictResult = expandConflictDefinitions( groupConflictMap );
-               filterConflictResult = expandConflictDefinitions( filterConflictMap );
-
-               // Set conflicts for groups
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( groupConflictResult, function ( group, conflicts ) {
-                       model.groups[ group ].setConflicts( conflicts );
-               } );
-
-               // Set conflicts for items
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( filterConflictResult, function ( filterName, conflicts ) {
-                       var filterItem = model.getItemByName( filterName );
-                       // set conflicts for items in the group
-                       filterItem.setConflicts( conflicts );
-               } );
-
-               // Create a map between known parameters and their models
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( group, groupModel ) {
-                       if (
-                               groupModel.getType() === 'send_unselected_if_any' ||
-                               groupModel.getType() === 'boolean' ||
-                               groupModel.getType() === 'any_value'
-                       ) {
-                               // Individual filters
-                               groupModel.getItems().forEach( function ( filterItem ) {
-                                       model.parameterMap[ filterItem.getParamName() ] = filterItem;
-                               } );
-                       } else if (
-                               groupModel.getType() === 'string_options' ||
-                               groupModel.getType() === 'single_option'
-                       ) {
-                               // Group
-                               model.parameterMap[ groupModel.getName() ] = groupModel;
-                       }
-               } );
-
-               this.setSearch( '' );
-
-               this.updateHighlightedState();
-
-               // Finish initialization
-               this.emit( 'initialize' );
-       };
-
-       /**
-        * Update filter view model state based on a parameter object
-        *
-        * @param {Object} params Parameters object
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
-               var filtersValue;
-               // For arbitrary numeric single_option values make sure the values
-               // are normalized to fit within the limits
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
-                       params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
-               } );
-
-               // Update filter values
-               filtersValue = this.getFiltersFromParameters( params );
-               Object.keys( filtersValue ).forEach( function ( filterName ) {
-                       this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
-               }.bind( this ) );
-
-               // Update highlight state
-               this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
-                       var color = params[ filterItem.getName() + '_color' ];
-                       if ( color ) {
-                               filterItem.setHighlightColor( color );
-                       } else {
-                               filterItem.clearHighlightColor();
-                       }
-               } );
-               this.updateHighlightedState();
-
-               // Check all filter interactions
-               this.reassessFilterInteractions();
-       };
-
-       /**
-        * Get a representation of an empty (falsey) parameter state
-        *
-        * @return {Object} Empty parameter state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyParameterState = function () {
-               if ( !this.emptyParameterState ) {
-                       this.emptyParameterState = $.extend(
-                               true,
-                               {},
-                               this.getParametersFromFilters( {} ),
-                               this.getEmptyHighlightParameters()
-                       );
-               }
-               return this.emptyParameterState;
-       };
-
-       /**
-        * Get a representation of only the non-falsey parameters
-        *
-        * @param {Object} [parameters] A given parameter state to minimize. If not given the current
-        *  state of the system will be used.
-        * @return {Object} Empty parameter state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
-               var result = {};
-
-               parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
-
-               // Params
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.getEmptyParameterState(), function ( param, value ) {
-                       if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
-                               result[ param ] = parameters[ param ];
-                       }
-               } );
-
-               // Highlights
-               Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
-                       if ( parameters[ param ] ) {
-                               // If a highlight parameter is not undefined and not null
-                               // add it to the result
-                               result[ param ] = parameters[ param ];
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get a representation of the full parameter list, including all base values
-        *
-        * @return {Object} Full parameter representation
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
-               return $.extend(
-                       true,
-                       {},
-                       this.getEmptyParameterState(),
-                       this.getCurrentParameterState()
-               );
-       };
-
-       /**
-        * Get a parameter representation of the current state of the model
-        *
-        * @param {boolean} [removeStickyParams] Remove sticky filters from final result
-        * @return {Object} Parameter representation of the current state of the model
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
-               var state = this.getMinimizedParamRepresentation( $.extend(
-                       true,
-                       {},
-                       this.getParametersFromFilters( this.getSelectedState() ),
-                       this.getHighlightParameters()
-               ) );
-
-               if ( removeStickyParams ) {
-                       state = this.removeStickyParams( state );
-               }
-
-               return state;
-       };
-
-       /**
-        * Delete sticky parameters from given object.
-        *
-        * @param {Object} paramState Parameter state
-        * @return {Object} Parameter state without sticky parameters
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
-               this.getStickyParams().forEach( function ( paramName ) {
-                       delete paramState[ paramName ];
-               } );
-
-               return paramState;
-       };
-
-       /**
-        * Turn the highlight feature on or off
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.updateHighlightedState = function () {
-               this.toggleHighlight( this.getHighlightedItems().length > 0 );
-       };
-
-       /**
-        * Get the object that defines groups by their name.
-        *
-        * @return {Object} Filter groups
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
-               return this.groups;
-       };
-
-       /**
-        * Get the object that defines groups that match a certain view by their name.
-        *
-        * @param {string} [view] Requested view. If not given, uses current view
-        * @return {Object} Filter groups matching a display group
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
-               var result = {};
-
-               view = view || this.getCurrentView();
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( groupName, groupModel ) {
-                       if ( groupModel.getView() === view ) {
-                               result[ groupName ] = groupModel;
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get an array of filters matching the given display group.
-        *
-        * @param {string} [view] Requested view. If not given, uses current view
-        * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersByView = function ( view ) {
-               var groups,
-                       result = [];
-
-               view = view || this.getCurrentView();
-
-               groups = this.getFilterGroupsByView( view );
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( groups, function ( groupName, groupModel ) {
-                       result = result.concat( groupModel.getItems() );
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get the trigger for the requested view.
-        *
-        * @param {string} view View name
-        * @return {string} View trigger, if exists
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getViewTrigger = function ( view ) {
-               return ( this.views[ view ] && this.views[ view ].trigger ) || '';
-       };
-
-       /**
-        * Get the value of a specific parameter
-        *
-        * @param {string} name Parameter name
-        * @return {number|string} Parameter value
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
-               return this.parameters[ name ];
-       };
-
-       /**
-        * Get the current selected state of the filters
-        *
-        * @param {boolean} [onlySelected] return an object containing only the filters with a value
-        * @return {Object} Filters selected state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
-               var i,
-                       items = this.getItems(),
-                       result = {};
-
-               for ( i = 0; i < items.length; i++ ) {
-                       if ( !onlySelected || items[ i ].getValue() ) {
-                               result[ items[ i ].getName() ] = items[ i ].getValue();
-                       }
-               }
-
-               return result;
-       };
-
-       /**
-        * Get the current full state of the filters
-        *
-        * @return {Object} Filters full state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
-               var i,
-                       items = this.getItems(),
-                       result = {};
-
-               for ( i = 0; i < items.length; i++ ) {
-                       result[ items[ i ].getName() ] = {
-                               selected: items[ i ].isSelected(),
-                               conflicted: items[ i ].isConflicted(),
-                               included: items[ i ].isIncluded()
-                       };
-               }
-
-               return result;
-       };
-
-       /**
-        * Get an object representing default parameters state
-        *
-        * @return {Object} Default parameter values
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
-               var result = {};
-
-               // Get default filter state
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( name, model ) {
-                       if ( !model.isSticky() ) {
-                               $.extend( true, result, model.getDefaultParams() );
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get a parameter representation of all sticky parameters
-        *
-        * @return {Object} Sticky parameter values
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParams = function () {
-               var result = [];
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( name, model ) {
-                       if ( model.isSticky() ) {
-                               if ( model.isPerGroupRequestParameter() ) {
-                                       result.push( name );
-                               } else {
-                                       // Each filter is its own param
-                                       result = result.concat( model.getItems().map( function ( filterItem ) {
-                                               return filterItem.getParamName();
-                                       } ) );
-                               }
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get a parameter representation of all sticky parameters
-        *
-        * @return {Object} Sticky parameter values
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParamsValues = function () {
-               var result = {};
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( name, model ) {
-                       if ( model.isSticky() ) {
-                               $.extend( true, result, model.getParamRepresentation() );
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Analyze the groups and their filters and output an object representing
-        * the state of the parameters they represent.
-        *
-        * @param {Object} [filterDefinition] An object defining the filter values,
-        *  keyed by filter names.
-        * @return {Object} Parameter state object
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
-               var groupItemDefinition,
-                       result = {},
-                       groupItems = this.getFilterGroups();
-
-               if ( filterDefinition ) {
-                       groupItemDefinition = {};
-                       // Filter definition is "flat", but in effect
-                       // each group needs to tell us its result based
-                       // on the values in it. We need to split this list
-                       // back into groupings so we can "feed" it to the
-                       // loop below, and we need to expand it so it includes
-                       // all filters (set to false)
-                       this.getItems().forEach( function ( filterItem ) {
-                               groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
-                               groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
-                       } );
-               }
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( groupItems, function ( group, model ) {
-                       $.extend(
-                               result,
-                               model.getParamRepresentation(
-                                       groupItemDefinition ?
-                                               groupItemDefinition[ group ] : null
-                               )
-                       );
-               } );
-
-               return result;
-       };
-
-       /**
-        * This is the opposite of the #getParametersFromFilters method; this goes over
-        * the given parameters and translates into a selected/unselected value in the filters.
-        *
-        * @param {Object} params Parameters query object
-        * @return {Object} Filter state object
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
-               var groupMap = {},
-                       model = this,
-                       result = {};
-
-               // Go over the given parameters, break apart to groupings
-               // The resulting object represents the group with its parameter
-               // values. For example:
-               // {
-               //    group1: {
-               //       param1: "1",
-               //       param2: "0",
-               //       param3: "1"
-               //    },
-               //    group2: "param4|param5"
-               // }
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( params, function ( paramName, paramValue ) {
-                       var groupName,
-                               itemOrGroup = model.parameterMap[ paramName ];
-
-                       if ( itemOrGroup ) {
-                               groupName = itemOrGroup instanceof mw.rcfilters.dm.FilterItem ?
-                                       itemOrGroup.getGroupName() : itemOrGroup.getName();
-
-                               groupMap[ groupName ] = groupMap[ groupName ] || {};
-                               groupMap[ groupName ][ paramName ] = paramValue;
-                       }
-               } );
-
-               // Go over all groups, so we make sure we get the complete output
-               // even if the parameters don't include a certain group
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( groupName, groupModel ) {
-                       result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get the highlight parameters based on current filter configuration
-        *
-        * @return {Object} Object where keys are `<filter name>_color` and values
-        *                  are the selected highlight colors.
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () {
-               var highlightEnabled = this.isHighlightEnabled(),
-                       result = {};
-
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isHighlightSupported() ) {
-                               result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
-                                       filterItem.getHighlightColor() :
-                                       null;
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get an object representing the complete empty state of highlights
-        *
-        * @return {Object} Object containing all the highlight parameters set to their negative value
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
-               var result = {};
-
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isHighlightSupported() ) {
-                               result[ filterItem.getName() + '_color' ] = null;
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get an array of currently applied highlight colors
-        *
-        * @return {string[]} Currently applied highlight colors
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
-               var result = [];
-
-               if ( this.isHighlightEnabled() ) {
-                       this.getHighlightedItems().forEach( function ( filterItem ) {
-                               var color = filterItem.getHighlightColor();
-
-                               if ( result.indexOf( color ) === -1 ) {
-                                       result.push( color );
-                               }
-                       } );
-               }
-
-               return result;
-       };
-
-       /**
-        * Sanitize value group of a string_option groups type
-        * Remove duplicates and make sure to only use valid
-        * values.
-        *
-        * @private
-        * @param {string} groupName Group name
-        * @param {string[]} valueArray Array of values
-        * @return {string[]} Array of valid values
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
-               var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
-                       return filterItem.getParamName();
-               } );
-
-               return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
-       };
-
-       /**
-        * Check whether no visible filter is selected.
-        *
-        * Filter groups that are hidden or sticky are not shown in the
-        * active filters area and therefore not included in this check.
-        *
-        * @return {boolean} No visible filter is selected
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
-               // Check if there are either any selected items or any items
-               // that have highlight enabled
-               return !this.getItems().some( function ( filterItem ) {
-                       var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
-                               active = ( filterItem.isSelected() || filterItem.isHighlighted() );
-                       return visible && active;
-               } );
-       };
-
-       /**
-        * Check whether the invert state is a valid one. A valid invert state is one where
-        * there are actual namespaces selected.
-        *
-        * This is done to compare states to previous ones that may have had the invert model
-        * selected but effectively had no namespaces, so are not effectively different than
-        * ones where invert is not selected.
-        *
-        * @return {boolean} Invert is effectively selected
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
-               return this.getInvertModel().isSelected() &&
-                       this.findSelectedItems().some( function ( itemModel ) {
-                               return itemModel.getGroupModel().getName() === 'namespace';
-                       } );
-       };
-
-       /**
-        * Get the item that matches the given name
-        *
-        * @param {string} name Filter name
-        * @return {mw.rcfilters.dm.FilterItem} Filter item
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
-               return this.getItems().filter( function ( item ) {
-                       return name === item.getName();
-               } )[ 0 ];
-       };
-
-       /**
-        * Set all filters to false or empty/all
-        * This is equivalent to display all.
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( !filterItem.getGroupModel().isSticky() ) {
-                               this.toggleFilterSelected( filterItem.getName(), false );
-                       }
-               }.bind( this ) );
-       };
-
-       /**
-        * Toggle selected state of one item
-        *
-        * @param {string} name Name of the filter item
-        * @param {boolean} [isSelected] Filter selected state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
-               var item = this.getItemByName( name );
-
-               if ( item ) {
-                       item.toggleSelected( isSelected );
-               }
-       };
-
-       /**
-        * Toggle selected state of items by their names
-        *
-        * @param {Object} filterDef Filter definitions
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
-               Object.keys( filterDef ).forEach( function ( name ) {
-                       this.toggleFilterSelected( name, filterDef[ name ] );
-               }.bind( this ) );
-       };
-
-       /**
-        * Get a group model from its name
-        *
-        * @param {string} groupName Group name
-        * @return {mw.rcfilters.dm.FilterGroup} Group model
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
-               return this.groups[ groupName ];
-       };
-
-       /**
-        * Get all filters within a specified group by its name
-        *
-        * @param {string} groupName Group name
-        * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
-               return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
-       };
-
-       /**
-        * Find items whose labels match the given string
-        *
-        * @param {string} query Search string
-        * @param {boolean} [returnFlat] Return a flat array. If false, the result
-        *  is an object whose keys are the group names and values are an array of
-        *  filters per group. If set to true, returns an array of filters regardless
-        *  of their groups.
-        * @return {Object} An object of items to show
-        *  arranged by their group names
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
-               var i, searchIsEmpty,
-                       groupTitle,
-                       result = {},
-                       flatResult = [],
-                       view = this.getViewByTrigger( query.substr( 0, 1 ) ),
-                       items = this.getFiltersByView( view );
-
-               // Normalize so we can search strings regardless of case and view
-               query = query.trim().toLowerCase();
-               if ( view !== 'default' ) {
-                       query = query.substr( 1 );
-               }
-               // Trim again to also intercept cases where the spaces were after the trigger
-               // eg: '#   str'
-               query = query.trim();
-
-               // Check if the search if actually empty; this can be a problem when
-               // we use prefixes to denote different views
-               searchIsEmpty = query.length === 0;
-
-               // item label starting with the query string
-               for ( i = 0; i < items.length; i++ ) {
-                       if (
-                               searchIsEmpty ||
-                               items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
-                               (
-                                       // For tags, we want the parameter name to be included in the search
-                                       view === 'tags' &&
-                                       items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
-                               )
-                       ) {
-                               result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
-                               result[ items[ i ].getGroupName() ].push( items[ i ] );
-                               flatResult.push( items[ i ] );
-                       }
-               }
-
-               if ( $.isEmptyObject( result ) ) {
-                       // item containing the query string in their label, description, or group title
-                       for ( i = 0; i < items.length; i++ ) {
-                               groupTitle = items[ i ].getGroupModel().getTitle();
-                               if (
-                                       searchIsEmpty ||
-                                       items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
-                                       items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
-                                       groupTitle.toLowerCase().indexOf( query ) > -1 ||
-                                       (
-                                               // For tags, we want the parameter name to be included in the search
-                                               view === 'tags' &&
-                                               items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
-                                       )
-                               ) {
-                                       result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
-                                       result[ items[ i ].getGroupName() ].push( items[ i ] );
-                                       flatResult.push( items[ i ] );
-                               }
-                       }
-               }
-
-               return returnFlat ? flatResult : result;
-       };
-
-       /**
-        * Get items that are highlighted
-        *
-        * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightedItems = function () {
-               return this.getItems().filter( function ( filterItem ) {
-                       return filterItem.isHighlightSupported() &&
-                               filterItem.getHighlightColor();
-               } );
-       };
-
-       /**
-        * Get items that allow highlights even if they're not currently highlighted
-        *
-        * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
-               return this.getItems().filter( function ( filterItem ) {
-                       return filterItem.isHighlightSupported();
-               } );
-       };
-
-       /**
-        * Get all selected items
-        *
-        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.findSelectedItems = function () {
-               var allSelected = [];
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
-                       allSelected = allSelected.concat( groupModel.findSelectedItems() );
-               } );
-
-               return allSelected;
-       };
-
-       /**
-        * Get the current view
-        *
-        * @return {string} Current view
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentView = function () {
-               return this.currentView;
-       };
-
-       /**
-        * Get the label for the current view
-        *
-        * @param {string} viewName View name
-        * @return {string} Label for the current view
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
-               viewName = viewName || this.getCurrentView();
-
-               return this.views[ viewName ] && this.views[ viewName ].title;
-       };
-
-       /**
-        * Get the view that fits the given trigger
-        *
-        * @param {string} trigger Trigger
-        * @return {string} Name of view
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
-               var result = 'default';
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.views, function ( name, data ) {
-                       if ( data.trigger === trigger ) {
-                               result = name;
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Return a version of the given string that is without any
-        * view triggers.
-        *
-        * @param {string} str Given string
-        * @return {string} Result
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
-               if ( this.getViewFromString( str ) !== 'default' ) {
-                       str = str.substr( 1 );
-               }
-
-               return str;
-       };
-
-       /**
-        * Get the view from the given string by a trigger, if it exists
-        *
-        * @param {string} str Given string
-        * @return {string} View name
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getViewFromString = function ( str ) {
-               return this.getViewByTrigger( str.substr( 0, 1 ) );
-       };
-
-       /**
-        * Set the current search for the system.
-        * This also dictates what items and groups are visible according
-        * to the search in #findMatches
-        *
-        * @param {string} searchQuery Search query, including triggers
-        * @fires searchChange
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
-               var visibleGroups, visibleGroupNames;
-
-               if ( this.searchQuery !== searchQuery ) {
-                       // Check if the view changed
-                       this.switchView( this.getViewFromString( searchQuery ) );
-
-                       visibleGroups = this.findMatches( searchQuery );
-                       visibleGroupNames = Object.keys( visibleGroups );
-
-                       // Update visibility of items and groups
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
-                               // Check if the group is visible at all
-                               groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
-                               groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
-                       } );
-
-                       this.searchQuery = searchQuery;
-                       this.emit( 'searchChange', this.searchQuery );
-               }
-       };
-
-       /**
-        * Get the current search
-        *
-        * @return {string} Current search query
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getSearch = function () {
-               return this.searchQuery;
-       };
-
-       /**
-        * Switch the current view
-        *
-        * @private
-        * @param {string} view View name
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.switchView = function ( view ) {
-               if ( this.views[ view ] && this.currentView !== view ) {
-                       this.currentView = view;
-               }
-       };
-
-       /**
-        * Toggle the highlight feature on and off.
-        * Propagate the change to filter items.
-        *
-        * @param {boolean} enable Highlight should be enabled
-        * @fires highlightChange
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
-               enable = enable === undefined ? !this.highlightEnabled : enable;
-
-               if ( this.highlightEnabled !== enable ) {
-                       this.highlightEnabled = enable;
-                       this.emit( 'highlightChange', this.highlightEnabled );
-               }
-       };
-
-       /**
-        * Check if the highlight feature is enabled
-        * @return {boolean}
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.isHighlightEnabled = function () {
-               return !!this.highlightEnabled;
-       };
-
-       /**
-        * Toggle the inverted namespaces property on and off.
-        * Propagate the change to namespace filter items.
-        *
-        * @param {boolean} enable Inverted property is enabled
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
-               this.toggleFilterSelected( this.getInvertModel().getName(), enable );
-       };
-
-       /**
-        * Get the model object that represents the 'invert' filter
-        *
-        * @return {mw.rcfilters.dm.FilterItem}
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getInvertModel = function () {
-               return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
-       };
-
-       /**
-        * Set highlight color for a specific filter item
-        *
-        * @param {string} filterName Name of the filter item
-        * @param {string} color Selected color
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
-               this.getItemByName( filterName ).setHighlightColor( color );
-       };
-
-       /**
-        * Clear highlight for a specific filter item
-        *
-        * @param {string} filterName Name of the filter item
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
-               this.getItemByName( filterName ).clearHighlightColor();
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js
deleted file mode 100644 (file)
index c3283c1..0000000
+++ /dev/null
@@ -1,273 +0,0 @@
-( function () {
-       /**
-        * RCFilter base item model
-        *
-        * @mixins OO.EventEmitter
-        *
-        * @constructor
-        * @param {string} param Filter param name
-        * @param {Object} config Configuration object
-        * @cfg {string} [label] The label for the filter
-        * @cfg {string} [description] The description of the filter
-        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
-        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
-        *  with 'default' and 'inverted' as keys.
-        * @cfg {boolean} [active=true] The filter is active and affecting the result
-        * @cfg {boolean} [selected] The item is selected
-        * @cfg {*} [value] The value of this item
-        * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
-        *  identifier
-        * @cfg {string} [cssClass] The class identifying the results that match this filter
-        * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
-        *  added and considered in the view.
-        * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
-        */
-       mw.rcfilters.dm.ItemModel = function MwRcfiltersDmItemModel( param, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               this.param = param;
-               this.namePrefix = config.namePrefix || 'item_';
-               this.name = this.namePrefix + param;
-
-               this.label = config.label || this.name;
-               this.labelPrefixKey = config.labelPrefixKey;
-               this.description = config.description || '';
-               this.setValue( config.value || config.selected );
-
-               this.identifiers = config.identifiers || [];
-
-               // Highlight
-               this.cssClass = config.cssClass;
-               this.highlightColor = config.defaultHighlightColor || null;
-       };
-
-       /* Initialization */
-
-       OO.initClass( mw.rcfilters.dm.ItemModel );
-       OO.mixinClass( mw.rcfilters.dm.ItemModel, OO.EventEmitter );
-
-       /* Events */
-
-       /**
-        * @event update
-        *
-        * The state of this filter has changed
-        */
-
-       /* Methods */
-
-       /**
-        * Return the representation of the state of this item.
-        *
-        * @return {Object} State of the object
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getState = function () {
-               return {
-                       selected: this.isSelected()
-               };
-       };
-
-       /**
-        * Get the name of this filter
-        *
-        * @return {string} Filter name
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getName = function () {
-               return this.name;
-       };
-
-       /**
-        * Get the message key to use to wrap the label. This message takes the label as a parameter.
-        *
-        * @param {boolean} inverted Whether this item should be considered inverted
-        * @return {string|null} Message key, or null if no message
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
-               if ( this.labelPrefixKey ) {
-                       if ( typeof this.labelPrefixKey === 'string' ) {
-                               return this.labelPrefixKey;
-                       }
-                       return this.labelPrefixKey[
-                               // Only use inverted-prefix if the item is selected
-                               // Highlight-only an inverted item makes no sense
-                               inverted && this.isSelected() ?
-                                       'inverted' : 'default'
-                       ];
-               }
-               return null;
-       };
-
-       /**
-        * Get the param name or value of this filter
-        *
-        * @return {string} Filter param name
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getParamName = function () {
-               return this.param;
-       };
-
-       /**
-        * Get the message representing the state of this model.
-        *
-        * @return {string} State message
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getStateMessage = function () {
-               // Display description
-               return this.getDescription();
-       };
-
-       /**
-        * Get the label of this filter
-        *
-        * @return {string} Filter label
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getLabel = function () {
-               return this.label;
-       };
-
-       /**
-        * Get the description of this filter
-        *
-        * @return {string} Filter description
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getDescription = function () {
-               return this.description;
-       };
-
-       /**
-        * Get the default value of this filter
-        *
-        * @return {boolean} Filter default
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getDefault = function () {
-               return this.default;
-       };
-
-       /**
-        * Get the selected state of this filter
-        *
-        * @return {boolean} Filter is selected
-        */
-       mw.rcfilters.dm.ItemModel.prototype.isSelected = function () {
-               return !!this.value;
-       };
-
-       /**
-        * Toggle the selected state of the item
-        *
-        * @param {boolean} [isSelected] Filter is selected
-        * @fires update
-        */
-       mw.rcfilters.dm.ItemModel.prototype.toggleSelected = function ( isSelected ) {
-               isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
-               this.setValue( isSelected );
-       };
-
-       /**
-        * Get the value
-        *
-        * @return {*}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getValue = function () {
-               return this.value;
-       };
-
-       /**
-        * Convert a given value to the appropriate representation based on group type
-        *
-        * @param {*} value
-        * @return {*}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.coerceValue = function ( value ) {
-               return this.getGroupModel().getType() === 'any_value' ? value : !!value;
-       };
-
-       /**
-        * Set the value
-        *
-        * @param {*} newValue
-        */
-       mw.rcfilters.dm.ItemModel.prototype.setValue = function ( newValue ) {
-               newValue = this.coerceValue( newValue );
-               if ( this.value !== newValue ) {
-                       this.value = newValue;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Set the highlight color
-        *
-        * @param {string|null} highlightColor
-        */
-       mw.rcfilters.dm.ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
-               if ( !this.isHighlightSupported() ) {
-                       return;
-               }
-               // If the highlight color on the item and in the parameter is null/undefined, return early.
-               if ( !this.highlightColor && !highlightColor ) {
-                       return;
-               }
-
-               if ( this.highlightColor !== highlightColor ) {
-                       this.highlightColor = highlightColor;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Clear the highlight color
-        */
-       mw.rcfilters.dm.ItemModel.prototype.clearHighlightColor = function () {
-               this.setHighlightColor( null );
-       };
-
-       /**
-        * Get the highlight color, or null if none is configured
-        *
-        * @return {string|null}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getHighlightColor = function () {
-               return this.highlightColor;
-       };
-
-       /**
-        * Get the CSS class that matches changes that fit this filter
-        * or null if none is configured
-        *
-        * @return {string|null}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getCssClass = function () {
-               return this.cssClass;
-       };
-
-       /**
-        * Get the item's identifiers
-        *
-        * @return {string[]}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getIdentifiers = function () {
-               return this.identifiers;
-       };
-
-       /**
-        * Check if the highlight feature is supported for this filter
-        *
-        * @return {boolean}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.isHighlightSupported = function () {
-               return !!this.getCssClass();
-       };
-
-       /**
-        * Check if the filter is currently highlighted
-        *
-        * @return {boolean}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.isHighlighted = function () {
-               return !!this.getHighlightColor();
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js
deleted file mode 100644 (file)
index adf3fbb..0000000
+++ /dev/null
@@ -1,410 +0,0 @@
-( function () {
-       /**
-        * View model for saved queries
-        *
-        * @class
-        * @mixins OO.EventEmitter
-        * @mixins OO.EmitterList
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
-        * @param {Object} [config] Configuration options
-        * @cfg {string} [default] Default query ID
-        */
-       mw.rcfilters.dm.SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-               OO.EmitterList.call( this );
-
-               this.default = config.default;
-               this.filtersModel = filtersModel;
-               this.converted = false;
-
-               // Events
-               this.aggregate( { update: 'itemUpdate' } );
-       };
-
-       /* Initialization */
-
-       OO.initClass( mw.rcfilters.dm.SavedQueriesModel );
-       OO.mixinClass( mw.rcfilters.dm.SavedQueriesModel, OO.EventEmitter );
-       OO.mixinClass( mw.rcfilters.dm.SavedQueriesModel, OO.EmitterList );
-
-       /* Events */
-
-       /**
-        * @event initialize
-        *
-        * Model is initialized
-        */
-
-       /**
-        * @event itemUpdate
-        * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
-        *
-        * An item has changed
-        */
-
-       /**
-        * @event default
-        * @param {string} New default ID
-        *
-        * The default has changed
-        */
-
-       /* Methods */
-
-       /**
-        * Initialize the saved queries model by reading it from the user's settings.
-        * The structure of the saved queries is:
-        * {
-        *    version: (string) Version number; if version 2, the query represents
-        *             parameters. Otherwise, the older version represented filters
-        *             and needs to be readjusted,
-        *    default: (string) Query ID
-        *    queries:{
-        *       query_id_1: {
-        *          data:{
-        *             filters: (Object) Minimal definition of the filters
-        *             highlights: (Object) Definition of the highlights
-        *          },
-        *          label: (optional) Name of this query
-        *       }
-        *    }
-        * }
-        *
-        * @param {Object} [savedQueries] An object with the saved queries with
-        *  the above structure.
-        * @fires initialize
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
-               var model = this;
-
-               savedQueries = savedQueries || {};
-
-               this.clearItems();
-               this.default = null;
-               this.converted = false;
-
-               if ( savedQueries.version !== '2' ) {
-                       // Old version dealt with filter names. We need to migrate to the new structure
-                       // The new structure:
-                       // {
-                       //   version: (string) '2',
-                       //   default: (string) Query ID,
-                       //   queries: {
-                       //     query_id: {
-                       //       label: (string) Name of the query
-                       //       data: {
-                       //         params: (object) Representing all the parameter states
-                       //         highlights: (object) Representing all the filter highlight states
-                       //     }
-                       //   }
-                       // }
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( savedQueries.queries || {}, function ( id, obj ) {
-                               if ( obj.data && obj.data.filters ) {
-                                       obj.data = model.convertToParameters( obj.data );
-                               }
-                       } );
-
-                       this.converted = true;
-                       savedQueries.version = '2';
-               }
-
-               // Initialize the query items
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( savedQueries.queries || {}, function ( id, obj ) {
-                       var normalizedData = obj.data,
-                               isDefault = String( savedQueries.default ) === String( id );
-
-                       if ( normalizedData && normalizedData.params ) {
-                               // Backwards-compat fix: Remove sticky parameters from
-                               // the given data, if they exist
-                               normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );
-
-                               // Correct the invert state for effective selection
-                               if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
-                                       delete normalizedData.params.invert;
-                               }
-
-                               model.cleanupHighlights( normalizedData );
-
-                               id = String( id );
-
-                               // Skip the addNewQuery method because we don't want to unnecessarily manipulate
-                               // the given saved queries unless we literally intend to (like in backwards compat fixes)
-                               // And the addNewQuery method also uses a minimization routine that checks for the
-                               // validity of items and minimizes the query. This isn't necessary for queries loaded
-                               // from the backend, and has the risk of removing values if they're temporarily
-                               // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
-                               model.addItems( [
-                                       new mw.rcfilters.dm.SavedQueryItemModel(
-                                               id,
-                                               obj.label,
-                                               normalizedData,
-                                               { default: isDefault }
-                                       )
-                               ] );
-
-                               if ( isDefault ) {
-                                       model.default = id;
-                               }
-                       }
-               } );
-
-               this.emit( 'initialize' );
-       };
-
-       /**
-        * Clean up highlight parameters.
-        * 'highlight' used to be stored, it's not inferred based on the presence of absence of
-        * filter colors.
-        *
-        * @param {Object} data Saved query data
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
-               if (
-                       data.params.highlight === '0' &&
-                       data.highlights && Object.keys( data.highlights ).length
-               ) {
-                       data.highlights = {};
-               }
-               delete data.params.highlight;
-       };
-
-       /**
-        * Convert from representation of filters to representation of parameters
-        *
-        * @param {Object} data Query data
-        * @return {Object} New converted query data
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.convertToParameters = function ( data ) {
-               var newData = {},
-                       defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
-                       fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
-                       highlightEnabled = data.highlights.highlight;
-
-               delete data.highlights.highlight;
-
-               // Filters
-               newData.params = this.filtersModel.getMinimizedParamRepresentation(
-                       this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
-               );
-
-               // Highlights: appending _color to keys
-               newData.highlights = {};
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( data.highlights, function ( highlightedFilterName, value ) {
-                       if ( value ) {
-                               newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
-                       }
-               } );
-
-               // Add highlight
-               newData.params.highlight = String( Number( highlightEnabled || 0 ) );
-
-               return newData;
-       };
-
-       /**
-        * Add a query item
-        *
-        * @param {string} label Label for the new query
-        * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
-        * @param {boolean} isDefault Item is default
-        * @param {string} [id] Query ID, if exists. If this isn't given, a random
-        *  new ID will be created.
-        * @return {string} ID of the newly added query
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
-               var normalizedData = { params: {}, highlights: {} },
-                       highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
-                       randomID = String( id || ( new Date() ).getTime() ),
-                       data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
-
-               // Split highlight/params
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( data, function ( param, value ) {
-                       if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
-                               normalizedData.highlights[ param ] = value;
-                       } else {
-                               normalizedData.params[ param ] = value;
-                       }
-               } );
-
-               // Correct the invert state for effective selection
-               if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
-                       delete normalizedData.params.invert;
-               }
-
-               // Add item
-               this.addItems( [
-                       new mw.rcfilters.dm.SavedQueryItemModel(
-                               randomID,
-                               label,
-                               normalizedData,
-                               { default: isDefault }
-                       )
-               ] );
-
-               if ( isDefault ) {
-                       this.setDefault( randomID );
-               }
-
-               return randomID;
-       };
-
-       /**
-        * Remove query from model
-        *
-        * @param {string} queryID Query ID
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
-               var query = this.getItemByID( queryID );
-
-               if ( query ) {
-                       // Check if this item was the default
-                       if ( String( this.getDefault() ) === String( queryID ) ) {
-                               // Nulify the default
-                               this.setDefault( null );
-                       }
-
-                       this.removeItems( [ query ] );
-               }
-       };
-
-       /**
-        * Get an item that matches the requested query
-        *
-        * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
-        * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
-               // Minimize before comparison
-               fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
-
-               // Correct the invert state for effective selection
-               if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
-                       delete fullQueryComparison.invert;
-               }
-
-               return this.getItems().filter( function ( item ) {
-                       return OO.compare(
-                               item.getCombinedData(),
-                               fullQueryComparison
-                       );
-               } )[ 0 ];
-       };
-
-       /**
-        * Get query by its identifier
-        *
-        * @param {string} queryID Query identifier
-        * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
-        *  the search. Undefined if not found.
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
-               return this.getItems().filter( function ( item ) {
-                       return item.getID() === queryID;
-               } )[ 0 ];
-       };
-
-       /**
-        * Get the full data representation of the default query, if it exists
-        *
-        * @return {Object|null} Representation of the default params if exists.
-        *  Null if default doesn't exist or if the user is not logged in.
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getDefaultParams = function () {
-               return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
-       };
-
-       /**
-        * Get a full parameter representation of an item data
-        *
-        * @param  {Object} queryID Query ID
-        * @return {Object} Parameter representation
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
-               var item = this.getItemByID( queryID ),
-                       data = item ? item.getData() : {};
-
-               return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
-       };
-
-       /**
-        * Build a full parameter representation given item data and model sticky values state
-        *
-        * @param  {Object} data Item data
-        * @return {Object} Full param representation
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
-               data = data || {};
-               // Return parameter representation
-               return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
-                       data.params,
-                       data.highlights
-               ) );
-       };
-
-       /**
-        * Get the object representing the state of the entire model and items
-        *
-        * @return {Object} Object representing the state of the model and items
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getState = function () {
-               var obj = { queries: {}, version: '2' };
-
-               // Translate the items to the saved object
-               this.getItems().forEach( function ( item ) {
-                       obj.queries[ item.getID() ] = item.getState();
-               } );
-
-               if ( this.getDefault() ) {
-                       obj.default = this.getDefault();
-               }
-
-               return obj;
-       };
-
-       /**
-        * Set a default query. Null to unset default.
-        *
-        * @param {string} itemID Query identifier
-        * @fires default
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.setDefault = function ( itemID ) {
-               if ( this.default !== itemID ) {
-                       this.default = itemID;
-
-                       // Set for individual itens
-                       this.getItems().forEach( function ( item ) {
-                               item.toggleDefault( item.getID() === itemID );
-                       } );
-
-                       this.emit( 'default', itemID );
-               }
-       };
-
-       /**
-        * Get the default query ID
-        *
-        * @return {string} Default query identifier
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getDefault = function () {
-               return this.default;
-       };
-
-       /**
-        * Check if the saved queries were converted
-        *
-        * @return {boolean} Saved queries were converted from the previous
-        *  version to the new version
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.isConverted = function () {
-               return this.converted;
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js
deleted file mode 100644 (file)
index 46344cb..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-( function () {
-       /**
-        * View model for a single saved query
-        *
-        * @class
-        * @mixins OO.EventEmitter
-        *
-        * @constructor
-        * @param {string} id Unique identifier
-        * @param {string} label Saved query label
-        * @param {Object} data Saved query data
-        * @param {Object} [config] Configuration options
-        * @cfg {boolean} [default] This item is the default
-        */
-       mw.rcfilters.dm.SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               this.id = id;
-               this.label = label;
-               this.data = data;
-               this.default = !!config.default;
-       };
-
-       /* Initialization */
-
-       OO.initClass( mw.rcfilters.dm.SavedQueryItemModel );
-       OO.mixinClass( mw.rcfilters.dm.SavedQueryItemModel, OO.EventEmitter );
-
-       /* Events */
-
-       /**
-        * @event update
-        *
-        * Model has been updated
-        */
-
-       /* Methods */
-
-       /**
-        * Get an object representing the state of this item
-        *
-        * @return {Object} Object representing the current data state
-        *  of the object
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.getState = function () {
-               return {
-                       data: this.getData(),
-                       label: this.getLabel()
-               };
-       };
-
-       /**
-        * Get the query's identifier
-        *
-        * @return {string} Query identifier
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.getID = function () {
-               return this.id;
-       };
-
-       /**
-        * Get query label
-        *
-        * @return {string} Query label
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.getLabel = function () {
-               return this.label;
-       };
-
-       /**
-        * Update the query label
-        *
-        * @param {string} newLabel New label
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
-               if ( newLabel && this.label !== newLabel ) {
-                       this.label = newLabel;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Get query data
-        *
-        * @return {Object} Object representing parameter and highlight data
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.getData = function () {
-               return this.data;
-       };
-
-       /**
-        * Get the combined data of this item as a flat object of parameters
-        *
-        * @return {Object} Combined parameter data
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.getCombinedData = function () {
-               return $.extend( true, {}, this.data.params, this.data.highlights );
-       };
-
-       /**
-        * Check whether this item is the default
-        *
-        * @return {boolean} Query is set to be default
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.isDefault = function () {
-               return this.default;
-       };
-
-       /**
-        * Toggle the default state of this query item
-        *
-        * @param {boolean} isDefault Query is default
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
-               isDefault = isDefault === undefined ? !this.default : isDefault;
-
-               if ( this.default !== isDefault ) {
-                       this.default = isDefault;
-                       this.emit( 'update' );
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
deleted file mode 100644 (file)
index 6eb8867..0000000
+++ /dev/null
@@ -1,1225 +0,0 @@
-( function () {
-
-       var byteLength = require( 'mediawiki.String' ).byteLength;
-
-       /* eslint no-underscore-dangle: "off" */
-       /**
-        * Controller for the filters in Recent Changes
-        * @class
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {Object} config Additional configuration
-        * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
-        * @cfg {string} daysPreferenceName Preference name for the days filter
-        * @cfg {string} limitPreferenceName Preference name for the limit filter
-        * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
-        *  the active filters area
-        * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
-        *  title normalization to separate title subpage/parts into the target= url
-        *  parameter
-        */
-       mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
-               this.filtersModel = filtersModel;
-               this.changesListModel = changesListModel;
-               this.savedQueriesModel = savedQueriesModel;
-               this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
-               this.daysPreferenceName = config.daysPreferenceName;
-               this.limitPreferenceName = config.limitPreferenceName;
-               this.collapsedPreferenceName = config.collapsedPreferenceName;
-               this.normalizeTarget = !!config.normalizeTarget;
-
-               this.requestCounter = {};
-               this.baseFilterState = {};
-               this.uriProcessor = null;
-               this.initialized = false;
-               this.wereSavedQueriesSaved = false;
-
-               this.prevLoggedItems = [];
-
-               this.FILTER_CHANGE = 'filterChange';
-               this.SHOW_NEW_CHANGES = 'showNewChanges';
-               this.LIVE_UPDATE = 'liveUpdate';
-       };
-
-       /* Initialization */
-       OO.initClass( mw.rcfilters.Controller );
-
-       /**
-        * Initialize the filter and parameter states
-        *
-        * @param {Array} filterStructure Filter definition and structure for the model
-        * @param {Object} [namespaceStructure] Namespace definition
-        * @param {Object} [tagList] Tag definition
-        * @param {Object} [conditionalViews] Conditional view definition
-        */
-       mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
-               var parsedSavedQueries, pieces,
-                       displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
-                       defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
-                       controller = this,
-                       views = $.extend( true, {}, conditionalViews ),
-                       items = [],
-                       uri = new mw.Uri();
-
-               // Prepare views
-               if ( namespaceStructure ) {
-                       items = [];
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( namespaceStructure, function ( namespaceID, label ) {
-                               // Build and clean up the individual namespace items definition
-                               items.push( {
-                                       name: namespaceID,
-                                       label: label || mw.msg( 'blanknamespace' ),
-                                       description: '',
-                                       identifiers: [
-                                               mw.Title.isTalkNamespace( namespaceID ) ?
-                                                       'talk' : 'subject'
-                                       ],
-                                       cssClass: 'mw-changeslist-ns-' + namespaceID
-                               } );
-                       } );
-
-                       views.namespaces = {
-                               title: mw.msg( 'namespaces' ),
-                               trigger: ':',
-                               groups: [ {
-                                       // Group definition (single group)
-                                       name: 'namespace', // parameter name is singular
-                                       type: 'string_options',
-                                       title: mw.msg( 'namespaces' ),
-                                       labelPrefixKey: { default: 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
-                                       separator: ';',
-                                       fullCoverage: true,
-                                       filters: items
-                               } ]
-                       };
-                       views.invert = {
-                               groups: [
-                                       {
-                                               name: 'invertGroup',
-                                               type: 'boolean',
-                                               hidden: true,
-                                               filters: [ {
-                                                       name: 'invert',
-                                                       default: '0'
-                                               } ]
-                                       } ]
-                       };
-               }
-               if ( tagList ) {
-                       views.tags = {
-                               title: mw.msg( 'rcfilters-view-tags' ),
-                               trigger: '#',
-                               groups: [ {
-                                       // Group definition (single group)
-                                       name: 'tagfilter', // Parameter name
-                                       type: 'string_options',
-                                       title: 'rcfilters-view-tags', // Message key
-                                       labelPrefixKey: 'rcfilters-tag-prefix-tags',
-                                       separator: '|',
-                                       fullCoverage: false,
-                                       filters: tagList
-                               } ]
-                       };
-               }
-
-               // Add parameter range operations
-               views.range = {
-                       groups: [
-                               {
-                                       name: 'limit',
-                                       type: 'single_option',
-                                       title: '', // Because it's a hidden group, this title actually appears nowhere
-                                       hidden: true,
-                                       allowArbitrary: true,
-                                       // FIXME: $.isNumeric is deprecated
-                                       validate: $.isNumeric,
-                                       range: {
-                                               min: 0, // The server normalizes negative numbers to 0 results
-                                               max: 1000
-                                       },
-                                       sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
-                                       default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
-                                       sticky: true,
-                                       filters: displayConfig.limitArray.map( function ( num ) {
-                                               return controller._createFilterDataFromNumber( num, num );
-                                       } )
-                               },
-                               {
-                                       name: 'days',
-                                       type: 'single_option',
-                                       title: '', // Because it's a hidden group, this title actually appears nowhere
-                                       hidden: true,
-                                       allowArbitrary: true,
-                                       // FIXME: $.isNumeric is deprecated
-                                       validate: $.isNumeric,
-                                       range: {
-                                               min: 0,
-                                               max: displayConfig.maxDays
-                                       },
-                                       sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
-                                       numToLabelFunc: function ( i ) {
-                                               return Number( i ) < 1 ?
-                                                       ( Number( i ) * 24 ).toFixed( 2 ) :
-                                                       Number( i );
-                                       },
-                                       default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
-                                       sticky: true,
-                                       filters: [
-                                               // Hours (1, 2, 6, 12)
-                                               0.04166, 0.0833, 0.25, 0.5
-                                       // Days
-                                       ].concat( displayConfig.daysArray )
-                                               .map( function ( num ) {
-                                                       return controller._createFilterDataFromNumber(
-                                                               num,
-                                                               // Convert fractions of days to number of hours for the labels
-                                                               num < 1 ? Math.round( num * 24 ) : num
-                                                       );
-                                               } )
-                               }
-                       ]
-               };
-
-               views.display = {
-                       groups: [
-                               {
-                                       name: 'display',
-                                       type: 'boolean',
-                                       title: '', // Because it's a hidden group, this title actually appears nowhere
-                                       hidden: true,
-                                       sticky: true,
-                                       filters: [
-                                               {
-                                                       name: 'enhanced',
-                                                       default: String( mw.user.options.get( 'usenewrc', 0 ) )
-                                               }
-                                       ]
-                               }
-                       ]
-               };
-
-               // Before we do anything, we need to see if we require additional items in the
-               // groups that have 'AllowArbitrary'. For the moment, those are only single_option
-               // groups; if we ever expand it, this might need further generalization:
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( views, function ( viewName, viewData ) {
-                       viewData.groups.forEach( function ( groupData ) {
-                               var extraValues = [];
-                               if ( groupData.allowArbitrary ) {
-                                       // If the value in the URI isn't in the group, add it
-                                       if ( uri.query[ groupData.name ] !== undefined ) {
-                                               extraValues.push( uri.query[ groupData.name ] );
-                                       }
-                                       // If the default value isn't in the group, add it
-                                       if ( groupData.default !== undefined ) {
-                                               extraValues.push( String( groupData.default ) );
-                                       }
-                                       controller.addNumberValuesToGroup( groupData, extraValues );
-                               }
-                       } );
-               } );
-
-               // Initialize the model
-               this.filtersModel.initializeFilters( filterStructure, views );
-
-               this.uriProcessor = new mw.rcfilters.UriProcessor(
-                       this.filtersModel,
-                       { normalizeTarget: this.normalizeTarget }
-               );
-
-               if ( !mw.user.isAnon() ) {
-                       try {
-                               parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
-                       } catch ( err ) {
-                               parsedSavedQueries = {};
-                       }
-
-                       // Initialize saved queries
-                       this.savedQueriesModel.initialize( parsedSavedQueries );
-                       if ( this.savedQueriesModel.isConverted() ) {
-                               // Since we know we converted, we're going to re-save
-                               // the queries so they are now migrated to the new format
-                               this._saveSavedQueries();
-                       }
-               }
-
-               if ( defaultSavedQueryExists ) {
-                       // This came from the server, meaning that we have a default
-                       // saved query, but the server could not load it, probably because
-                       // it was pre-conversion to the new format.
-                       // We need to load this query again
-                       this.applySavedQuery( this.savedQueriesModel.getDefault() );
-               } else {
-                       // There are either recognized parameters in the URL
-                       // or there are none, but there is also no default
-                       // saved query (so defaults are from the backend)
-                       // We want to update the state but not fetch results
-                       // again
-                       this.updateStateFromUrl( false );
-
-                       pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
-
-                       // Update the changes list with the existing data
-                       // so it gets processed
-                       this.changesListModel.update(
-                               pieces.changes,
-                               pieces.fieldset,
-                               pieces.noResultsDetails,
-                               true // We're using existing DOM elements
-                       );
-               }
-
-               this.initialized = true;
-               this.switchView( 'default' );
-
-               this.pollingRate = mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' );
-               if ( this.pollingRate ) {
-                       this._scheduleLiveUpdate();
-               }
-       };
-
-       /**
-        * Check if the controller has finished initializing.
-        * @return {boolean} Controller is initialized
-        */
-       mw.rcfilters.Controller.prototype.isInitialized = function () {
-               return this.initialized;
-       };
-
-       /**
-        * Extracts information from the changes list DOM
-        *
-        * @param {jQuery} $root Root DOM to find children from
-        * @param {boolean} [statusCode] Server response status code
-        * @return {Object} Information about changes list
-        * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
-        *   (either normally or as an error)
-        * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
-        *   'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
-        * @return {jQuery} return.fieldset Fieldset
-        */
-       mw.rcfilters.Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
-               var info,
-                       $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
-                       areResults = !!$changesListContents.length,
-                       checkForLogout = !areResults && statusCode === 200;
-
-               // We check if user logged out on different tab/browser or the session has expired.
-               // 205 status code returned from the server, which indicates that we need to reload the page
-               // is not usable on WL page, because we get redirected to login page, which gives 200 OK
-               // status code (if everything else goes well).
-               // Bug: T177717
-               if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
-                       location.reload( false );
-                       return;
-               }
-
-               info = {
-                       changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
-                       fieldset: $root.find( 'fieldset.cloptions' ).first()
-               };
-
-               if ( !areResults ) {
-                       if ( $root.find( '.mw-changeslist-timeout' ).length ) {
-                               info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
-                       } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
-                               info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
-                       } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
-                               info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
-                       } else {
-                               info.noResultsDetails = 'NO_RESULTS_NORMAL';
-                       }
-               }
-
-               return info;
-       };
-
-       /**
-        * Create filter data from a number, for the filters that are numerical value
-        *
-        * @param {number} num Number
-        * @param {number} numForDisplay Number for the label
-        * @return {Object} Filter data
-        */
-       mw.rcfilters.Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
-               return {
-                       name: String( num ),
-                       label: mw.language.convertNumber( numForDisplay )
-               };
-       };
-
-       /**
-        * Add an arbitrary values to groups that allow arbitrary values
-        *
-        * @param {Object} groupData Group data
-        * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
-        */
-       mw.rcfilters.Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
-               var controller = this,
-                       normalizeWithinRange = function ( range, val ) {
-                               if ( val < range.min ) {
-                                       return range.min; // Min
-                               } else if ( val >= range.max ) {
-                                       return range.max; // Max
-                               }
-                               return val;
-                       };
-
-               arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
-
-               // Normalize the arbitrary values and the default value for a range
-               if ( groupData.range ) {
-                       arbitraryValues = arbitraryValues.map( function ( val ) {
-                               return normalizeWithinRange( groupData.range, val );
-                       } );
-
-                       // Normalize the default, since that's user defined
-                       if ( groupData.default !== undefined ) {
-                               groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
-                       }
-               }
-
-               // This is only true for single_option group
-               // We assume these are the only groups that will allow for
-               // arbitrary, since it doesn't make any sense for the other
-               // groups.
-               arbitraryValues.forEach( function ( val ) {
-                       if (
-                               // If the group allows for arbitrary data
-                               groupData.allowArbitrary &&
-                               // and it is single_option (or string_options, but we
-                               // don't have cases of those yet, nor do we plan to)
-                               groupData.type === 'single_option' &&
-                               // and, if there is a validate method and it passes on
-                               // the data
-                               ( !groupData.validate || groupData.validate( val ) ) &&
-                               // but if that value isn't already in the definition
-                               groupData.filters
-                                       .map( function ( filterData ) {
-                                               return String( filterData.name );
-                                       } )
-                                       .indexOf( String( val ) ) === -1
-                       ) {
-                               // Add the filter information
-                               groupData.filters.push( controller._createFilterDataFromNumber(
-                                       val,
-                                       groupData.numToLabelFunc ?
-                                               groupData.numToLabelFunc( val ) :
-                                               val
-                               ) );
-
-                               // If there's a sort function set up, re-sort the values
-                               if ( groupData.sortFunc ) {
-                                       groupData.filters.sort( groupData.sortFunc );
-                               }
-                       }
-               } );
-       };
-
-       /**
-        * Reset to default filters
-        */
-       mw.rcfilters.Controller.prototype.resetToDefaults = function () {
-               var params = this._getDefaultParams();
-               if ( this.applyParamChange( params ) ) {
-                       // Only update the changes list if there was a change to actual filters
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL( params );
-               }
-       };
-
-       /**
-        * Check whether the default values of the filters are all false.
-        *
-        * @return {boolean} Defaults are all false
-        */
-       mw.rcfilters.Controller.prototype.areDefaultsEmpty = function () {
-               return $.isEmptyObject( this._getDefaultParams() );
-       };
-
-       /**
-        * Empty all selected filters
-        */
-       mw.rcfilters.Controller.prototype.emptyFilters = function () {
-               var highlightedFilterNames = this.filtersModel.getHighlightedItems()
-                       .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
-
-               if ( this.applyParamChange( {} ) ) {
-                       // Only update the changes list if there was a change to actual filters
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL();
-               }
-
-               if ( highlightedFilterNames ) {
-                       this._trackHighlight( 'clearAll', highlightedFilterNames );
-               }
-       };
-
-       /**
-        * Update the selected state of a filter
-        *
-        * @param {string} filterName Filter name
-        * @param {boolean} [isSelected] Filter selected state
-        */
-       mw.rcfilters.Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
-               var filterItem = this.filtersModel.getItemByName( filterName );
-
-               if ( !filterItem ) {
-                       // If no filter was found, break
-                       return;
-               }
-
-               isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
-
-               if ( filterItem.isSelected() !== isSelected ) {
-                       this.filtersModel.toggleFilterSelected( filterName, isSelected );
-
-                       this.updateChangesList();
-
-                       // Check filter interactions
-                       this.filtersModel.reassessFilterInteractions( filterItem );
-               }
-       };
-
-       /**
-        * Clear both highlight and selection of a filter
-        *
-        * @param {string} filterName Name of the filter item
-        */
-       mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) {
-               var filterItem = this.filtersModel.getItemByName( filterName ),
-                       isHighlighted = filterItem.isHighlighted(),
-                       isSelected = filterItem.isSelected();
-
-               if ( isSelected || isHighlighted ) {
-                       this.filtersModel.clearHighlightColor( filterName );
-                       this.filtersModel.toggleFilterSelected( filterName, false );
-
-                       if ( isSelected ) {
-                               // Only update the changes list if the filter changed
-                               // its selection state. If it only changed its highlight
-                               // then don't reload
-                               this.updateChangesList();
-                       }
-
-                       this.filtersModel.reassessFilterInteractions( filterItem );
-
-                       // Log filter grouping
-                       this.trackFilterGroupings( 'removefilter' );
-               }
-
-               if ( isHighlighted ) {
-                       this._trackHighlight( 'clear', filterName );
-               }
-       };
-
-       /**
-        * Toggle the highlight feature on and off
-        */
-       mw.rcfilters.Controller.prototype.toggleHighlight = function () {
-               this.filtersModel.toggleHighlight();
-               this.uriProcessor.updateURL();
-
-               if ( this.filtersModel.isHighlightEnabled() ) {
-                       mw.hook( 'RcFilters.highlight.enable' ).fire();
-               }
-       };
-
-       /**
-        * Toggle the namespaces inverted feature on and off
-        */
-       mw.rcfilters.Controller.prototype.toggleInvertedNamespaces = function () {
-               this.filtersModel.toggleInvertedNamespaces();
-               if (
-                       this.filtersModel.getFiltersByView( 'namespaces' ).filter(
-                               function ( filterItem ) { return filterItem.isSelected(); }
-                       ).length
-               ) {
-                       // Only re-fetch results if there are namespace items that are actually selected
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL();
-               }
-       };
-
-       /**
-        * Set the value of the 'showlinkedto' parameter
-        * @param {boolean} value
-        */
-       mw.rcfilters.Controller.prototype.setShowLinkedTo = function ( value ) {
-               var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
-                       showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
-
-               this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
-               this.uriProcessor.updateURL();
-               // reload the results only when target is set
-               if ( targetItem.getValue() ) {
-                       this.updateChangesList();
-               }
-       };
-
-       /**
-        * Set the target page
-        * @param {string} page
-        */
-       mw.rcfilters.Controller.prototype.setTargetPage = function ( page ) {
-               var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
-               targetItem.setValue( page );
-               this.uriProcessor.updateURL();
-               this.updateChangesList();
-       };
-
-       /**
-        * Set the highlight color for a filter item
-        *
-        * @param {string} filterName Name of the filter item
-        * @param {string} color Selected color
-        */
-       mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) {
-               this.filtersModel.setHighlightColor( filterName, color );
-               this.uriProcessor.updateURL();
-               this._trackHighlight( 'set', { name: filterName, color: color } );
-       };
-
-       /**
-        * Clear highlight for a filter item
-        *
-        * @param {string} filterName Name of the filter item
-        */
-       mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) {
-               this.filtersModel.clearHighlightColor( filterName );
-               this.uriProcessor.updateURL();
-               this._trackHighlight( 'clear', filterName );
-       };
-
-       /**
-        * Enable or disable live updates.
-        * @param {boolean} enable True to enable, false to disable
-        */
-       mw.rcfilters.Controller.prototype.toggleLiveUpdate = function ( enable ) {
-               this.changesListModel.toggleLiveUpdate( enable );
-               if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
-                       this.updateChangesList( null, this.LIVE_UPDATE );
-               }
-       };
-
-       /**
-        * Set a timeout for the next live update.
-        * @private
-        */
-       mw.rcfilters.Controller.prototype._scheduleLiveUpdate = function () {
-               setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
-       };
-
-       /**
-        * Perform a live update.
-        * @private
-        */
-       mw.rcfilters.Controller.prototype._doLiveUpdate = function () {
-               if ( !this._shouldCheckForNewChanges() ) {
-                       // skip this turn and check back later
-                       this._scheduleLiveUpdate();
-                       return;
-               }
-
-               this._checkForNewChanges()
-                       .then( function ( statusCode ) {
-                               // no result is 204 with the 'peek' param
-                               // logged out is 205
-                               var newChanges = statusCode === 200;
-
-                               if ( !this._shouldCheckForNewChanges() ) {
-                                       // by the time the response is received,
-                                       // it may not be appropriate anymore
-                                       return;
-                               }
-
-                               // 205 is the status code returned from server when user's logged in/out
-                               // status is not matching while fetching live update changes.
-                               // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
-                               // Bug: T177717
-                               if ( statusCode === 205 ) {
-                                       location.reload( false );
-                                       return;
-                               }
-
-                               if ( newChanges ) {
-                                       if ( this.changesListModel.getLiveUpdate() ) {
-                                               return this.updateChangesList( null, this.LIVE_UPDATE );
-                                       } else {
-                                               this.changesListModel.setNewChangesExist( true );
-                                       }
-                               }
-                       }.bind( this ) )
-                       .always( this._scheduleLiveUpdate.bind( this ) );
-       };
-
-       /**
-        * @return {boolean} It's appropriate to check for new changes now
-        * @private
-        */
-       mw.rcfilters.Controller.prototype._shouldCheckForNewChanges = function () {
-               return !document.hidden &&
-                       !this.filtersModel.hasConflict() &&
-                       !this.changesListModel.getNewChangesExist() &&
-                       !this.updatingChangesList &&
-                       this.changesListModel.getNextFrom();
-       };
-
-       /**
-        * Check if new changes, newer than those currently shown, are available
-        *
-        * @return {jQuery.Promise} Promise object that resolves with a bool
-        *   specifying if there are new changes or not
-        *
-        * @private
-        */
-       mw.rcfilters.Controller.prototype._checkForNewChanges = function () {
-               var params = {
-                       limit: 1,
-                       peek: 1, // bypasses ChangesList specific UI
-                       from: this.changesListModel.getNextFrom(),
-                       isAnon: mw.user.isAnon()
-               };
-               return this._queryChangesList( 'liveUpdate', params ).then(
-                       function ( data ) {
-                               return data.status;
-                       }
-               );
-       };
-
-       /**
-        * Show the new changes
-        *
-        * @return {jQuery.Promise} Promise object that resolves after
-        * fetching and showing the new changes
-        */
-       mw.rcfilters.Controller.prototype.showNewChanges = function () {
-               return this.updateChangesList( null, this.SHOW_NEW_CHANGES );
-       };
-
-       /**
-        * Save the current model state as a saved query
-        *
-        * @param {string} [label] Label of the saved query
-        * @param {boolean} [setAsDefault=false] This query should be set as the default
-        */
-       mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
-               // Add item
-               this.savedQueriesModel.addNewQuery(
-                       label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
-                       this.filtersModel.getCurrentParameterState( true ),
-                       setAsDefault
-               );
-
-               // Save item
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Remove a saved query
-        *
-        * @param {string} queryID Query id
-        */
-       mw.rcfilters.Controller.prototype.removeSavedQuery = function ( queryID ) {
-               this.savedQueriesModel.removeQuery( queryID );
-
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Rename a saved query
-        *
-        * @param {string} queryID Query id
-        * @param {string} newLabel New label for the query
-        */
-       mw.rcfilters.Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
-               var queryItem = this.savedQueriesModel.getItemByID( queryID );
-
-               if ( queryItem ) {
-                       queryItem.updateLabel( newLabel );
-               }
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Set a saved query as default
-        *
-        * @param {string} queryID Query Id. If null is given, default
-        *  query is reset.
-        */
-       mw.rcfilters.Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
-               this.savedQueriesModel.setDefault( queryID );
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Load a saved query
-        *
-        * @param {string} queryID Query id
-        */
-       mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
-               var currentMatchingQuery,
-                       params = this.savedQueriesModel.getItemParams( queryID );
-
-               currentMatchingQuery = this.findQueryMatchingCurrentState();
-
-               if (
-                       currentMatchingQuery &&
-                       currentMatchingQuery.getID() === queryID
-               ) {
-                       // If the query we want to load is the one that is already
-                       // loaded, don't reload it
-                       return;
-               }
-
-               if ( this.applyParamChange( params ) ) {
-                       // Update changes list only if there was a difference in filter selection
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL( params );
-               }
-
-               // Log filter grouping
-               this.trackFilterGroupings( 'savedfilters' );
-       };
-
-       /**
-        * Check whether the current filter and highlight state exists
-        * in the saved queries model.
-        *
-        * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
-        */
-       mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
-               return this.savedQueriesModel.findMatchingQuery(
-                       this.filtersModel.getCurrentParameterState( true )
-               );
-       };
-
-       /**
-        * Save the current state of the saved queries model with all
-        * query item representation in the user settings.
-        */
-       mw.rcfilters.Controller.prototype._saveSavedQueries = function () {
-               var stringified, oldPrefValue,
-                       backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
-                       state = this.savedQueriesModel.getState();
-
-               // Stringify state
-               stringified = JSON.stringify( state );
-
-               if ( byteLength( stringified ) > 65535 ) {
-                       // Sanity check, since the preference can only hold that.
-                       return;
-               }
-
-               if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
-                       // The queries were converted from the previous version
-                       // Keep the old string in the [prefname]-versionbackup
-                       oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
-
-                       // Save the old preference in the backup preference
-                       new mw.Api().saveOption( backupPrefName, oldPrefValue );
-                       // Update the preference for this session
-                       mw.user.options.set( backupPrefName, oldPrefValue );
-               }
-
-               // Save the preference
-               new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
-               // Update the preference for this session
-               mw.user.options.set( this.savedQueriesPreferenceName, stringified );
-
-               // Tag as already saved so we don't do this again
-               this.wereSavedQueriesSaved = true;
-       };
-
-       /**
-        * Update sticky preferences with current model state
-        */
-       mw.rcfilters.Controller.prototype.updateStickyPreferences = function () {
-               // Update default sticky values with selected, whether they came from
-               // the initial defaults or from the URL value that is being normalized
-               this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).findSelectedItems()[ 0 ].getParamName() );
-               this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );
-
-               // TODO: Make these automatic by having the model go over sticky
-               // items and update their default values automatically
-       };
-
-       /**
-        * Update the limit default value
-        *
-        * @param {number} newValue New value
-        */
-       mw.rcfilters.Controller.prototype.updateLimitDefault = function ( newValue ) {
-               this.updateNumericPreference( this.limitPreferenceName, newValue );
-       };
-
-       /**
-        * Update the days default value
-        *
-        * @param {number} newValue New value
-        */
-       mw.rcfilters.Controller.prototype.updateDaysDefault = function ( newValue ) {
-               this.updateNumericPreference( this.daysPreferenceName, newValue );
-       };
-
-       /**
-        * Update the group by page default value
-        *
-        * @param {boolean} newValue New value
-        */
-       mw.rcfilters.Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
-               this.updateNumericPreference( 'usenewrc', Number( newValue ) );
-       };
-
-       /**
-        * Update the collapsed state value
-        *
-        * @param {boolean} isCollapsed Filter area is collapsed
-        */
-       mw.rcfilters.Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
-               this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
-       };
-
-       /**
-        * Update a numeric preference with a new value
-        *
-        * @param {string} prefName Preference name
-        * @param {number|string} newValue New value
-        */
-       mw.rcfilters.Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
-               // FIXME: $.isNumeric is deprecated
-               // eslint-disable-next-line jquery/no-is-numeric
-               if ( !$.isNumeric( newValue ) ) {
-                       return;
-               }
-
-               newValue = Number( newValue );
-
-               if ( mw.user.options.get( prefName ) !== newValue ) {
-                       // Save the preference
-                       new mw.Api().saveOption( prefName, newValue );
-                       // Update the preference for this session
-                       mw.user.options.set( prefName, newValue );
-               }
-       };
-
-       /**
-        * Synchronize the URL with the current state of the filters
-        * without adding an history entry.
-        */
-       mw.rcfilters.Controller.prototype.replaceUrl = function () {
-               this.uriProcessor.updateURL();
-       };
-
-       /**
-        * Update filter state (selection and highlighting) based
-        * on current URL values.
-        *
-        * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
-        *  list based on the updated model.
-        */
-       mw.rcfilters.Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
-               fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
-
-               this.uriProcessor.updateModelBasedOnQuery();
-
-               // Update the sticky preferences, in case we received a value
-               // from the URL
-               this.updateStickyPreferences();
-
-               // Only update and fetch new results if it is requested
-               if ( fetchChangesList ) {
-                       this.updateChangesList();
-               }
-       };
-
-       /**
-        * Update the list of changes and notify the model
-        *
-        * @param {Object} [params] Extra parameters to add to the API call
-        * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
-        * @return {jQuery.Promise} Promise that is resolved when the update is complete
-        */
-       mw.rcfilters.Controller.prototype.updateChangesList = function ( params, updateMode ) {
-               updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
-
-               if ( updateMode === this.FILTER_CHANGE ) {
-                       this.uriProcessor.updateURL( params );
-               }
-               if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
-                       this.changesListModel.invalidate();
-               }
-               this.changesListModel.setNewChangesExist( false );
-               this.updatingChangesList = true;
-               return this._fetchChangesList()
-                       .then(
-                               // Success
-                               function ( pieces ) {
-                                       var $changesListContent = pieces.changes,
-                                               $fieldset = pieces.fieldset;
-                                       this.changesListModel.update(
-                                               $changesListContent,
-                                               $fieldset,
-                                               pieces.noResultsDetails,
-                                               false,
-                                               // separator between old and new changes
-                                               updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
-                                       );
-                               }.bind( this )
-                               // Do nothing for failure
-                       )
-                       .always( function () {
-                               this.updatingChangesList = false;
-                       }.bind( this ) );
-       };
-
-       /**
-        * Get an object representing the default parameter state, whether
-        * it is from the model defaults or from the saved queries.
-        *
-        * @return {Object} Default parameters
-        */
-       mw.rcfilters.Controller.prototype._getDefaultParams = function () {
-               if ( this.savedQueriesModel.getDefault() ) {
-                       return this.savedQueriesModel.getDefaultParams();
-               } else {
-                       return this.filtersModel.getDefaultParams();
-               }
-       };
-
-       /**
-        * Query the list of changes from the server for the current filters
-        *
-        * @param {string} counterId Id for this request. To allow concurrent requests
-        *  not to invalidate each other.
-        * @param {Object} [params={}] Parameters to add to the query
-        *
-        * @return {jQuery.Promise} Promise object resolved with { content, status }
-        */
-       mw.rcfilters.Controller.prototype._queryChangesList = function ( counterId, params ) {
-               var uri = this.uriProcessor.getUpdatedUri(),
-                       stickyParams = this.filtersModel.getStickyParamsValues(),
-                       requestId,
-                       latestRequest;
-
-               params = params || {};
-               params.action = 'render'; // bypasses MW chrome
-
-               uri.extend( params );
-
-               this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
-               requestId = ++this.requestCounter[ counterId ];
-               latestRequest = function () {
-                       return requestId === this.requestCounter[ counterId ];
-               }.bind( this );
-
-               // Sticky parameters override the URL params
-               // this is to make sure that whether we represent
-               // the sticky params in the URL or not (they may
-               // be normalized out) the sticky parameters are
-               // always being sent to the server with their
-               // current/default values
-               uri.extend( stickyParams );
-
-               return $.ajax( uri.toString(), { contentType: 'html' } )
-                       .then(
-                               function ( content, message, jqXHR ) {
-                                       if ( !latestRequest() ) {
-                                               return $.Deferred().reject();
-                                       }
-                                       return {
-                                               content: content,
-                                               status: jqXHR.status
-                                       };
-                               },
-                               // RC returns 404 when there is no results
-                               function ( jqXHR ) {
-                                       if ( latestRequest() ) {
-                                               return $.Deferred().resolve(
-                                                       {
-                                                               content: jqXHR.responseText,
-                                                               status: jqXHR.status
-                                                       }
-                                               ).promise();
-                                       }
-                               }
-                       );
-       };
-
-       /**
-        * Fetch the list of changes from the server for the current filters
-        *
-        * @return {jQuery.Promise} Promise object that will resolve with the changes list
-        *  and the fieldset.
-        */
-       mw.rcfilters.Controller.prototype._fetchChangesList = function () {
-               return this._queryChangesList( 'updateChangesList' )
-                       .then(
-                               function ( data ) {
-                                       var $parsed;
-
-                                       // Status code 0 is not HTTP status code,
-                                       // but is valid value of XMLHttpRequest status.
-                                       // It is used for variety of network errors, for example
-                                       // when an AJAX call was cancelled before getting the response
-                                       if ( data && data.status === 0 ) {
-                                               return {
-                                                       changes: 'NO_RESULTS',
-                                                       // We need empty result set, to avoid exceptions because of undefined value
-                                                       fieldset: $( [] ),
-                                                       noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
-                                               };
-                                       }
-
-                                       $parsed = $( '<div>' ).append( $( $.parseHTML(
-                                               data ? data.content : ''
-                                       ) ) );
-
-                                       return this._extractChangesListInfo( $parsed, data.status );
-                               }.bind( this )
-                       );
-       };
-
-       /**
-        * Track usage of highlight feature
-        *
-        * @param {string} action
-        * @param {Array|Object|string} filters
-        */
-       mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) {
-               filters = typeof filters === 'string' ? { name: filters } : filters;
-               filters = !Array.isArray( filters ) ? [ filters ] : filters;
-               mw.track(
-                       'event.ChangesListHighlights',
-                       {
-                               action: action,
-                               filters: filters,
-                               userId: mw.user.getId()
-                       }
-               );
-       };
-
-       /**
-        * Track filter grouping usage
-        *
-        * @param {string} action Action taken
-        */
-       mw.rcfilters.Controller.prototype.trackFilterGroupings = function ( action ) {
-               var controller = this,
-                       rightNow = new Date().getTime(),
-                       randomIdentifier = String( mw.user.sessionId() ) + String( rightNow ) + String( Math.random() ),
-                       // Get all current filters
-                       filters = this.filtersModel.findSelectedItems().map( function ( item ) {
-                               return item.getName();
-                       } );
-
-               action = action || 'filtermenu';
-
-               // Check if these filters were the ones we just logged previously
-               // (Don't log the same grouping twice, in case the user opens/closes)
-               // the menu without action, or with the same result
-               if (
-                       // Only log if the two arrays are different in size
-                       filters.length !== this.prevLoggedItems.length ||
-                       // Or if any filters are not the same as the cached filters
-                       filters.some( function ( filterName ) {
-                               return controller.prevLoggedItems.indexOf( filterName ) === -1;
-                       } ) ||
-                       // Or if any cached filters are not the same as given filters
-                       this.prevLoggedItems.some( function ( filterName ) {
-                               return filters.indexOf( filterName ) === -1;
-                       } )
-               ) {
-                       filters.forEach( function ( filterName ) {
-                               mw.track(
-                                       'event.ChangesListFilterGrouping',
-                                       {
-                                               action: action,
-                                               groupIdentifier: randomIdentifier,
-                                               filter: filterName,
-                                               userId: mw.user.getId()
-                                       }
-                               );
-                       } );
-
-                       // Cache the filter names
-                       this.prevLoggedItems = filters;
-               }
-       };
-
-       /**
-        * Apply a change of parameters to the model state, and check whether
-        * the new state is different than the old state.
-        *
-        * @param  {Object} newParamState New parameter state to apply
-        * @return {boolean} New applied model state is different than the previous state
-        */
-       mw.rcfilters.Controller.prototype.applyParamChange = function ( newParamState ) {
-               var after,
-                       before = this.filtersModel.getSelectedState();
-
-               this.filtersModel.updateStateFromParams( newParamState );
-
-               after = this.filtersModel.getSelectedState();
-
-               return !OO.compare( before, after );
-       };
-
-       /**
-        * Mark all changes as seen on Watchlist
-        */
-       mw.rcfilters.Controller.prototype.markAllChangesAsSeen = function () {
-               var api = new mw.Api();
-               api.postWithToken( 'csrf', {
-                       formatversion: 2,
-                       action: 'setnotificationtimestamp',
-                       entirewatchlist: true
-               } ).then( function () {
-                       this.updateChangesList( null, 'markSeen' );
-               }.bind( this ) );
-       };
-
-       /**
-        * Set the current search for the system.
-        *
-        * @param {string} searchQuery Search query, including triggers
-        */
-       mw.rcfilters.Controller.prototype.setSearch = function ( searchQuery ) {
-               this.filtersModel.setSearch( searchQuery );
-       };
-
-       /**
-        * Switch the view by changing the search query trigger
-        * without changing the search term
-        *
-        * @param  {string} view View to change to
-        */
-       mw.rcfilters.Controller.prototype.switchView = function ( view ) {
-               this.setSearch(
-                       this.filtersModel.getViewTrigger( view ) +
-                       this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
-               );
-       };
-
-       /**
-        * Reset the search for a specific view. This means we null the search query
-        * and replace it with the relevant trigger for the requested view
-        *
-        * @param  {string} [view='default'] View to change to
-        */
-       mw.rcfilters.Controller.prototype.resetSearchForView = function ( view ) {
-               view = view || 'default';
-
-               this.setSearch(
-                       this.filtersModel.getViewTrigger( view )
-               );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js
deleted file mode 100644 (file)
index 6231f28..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-( function () {
-       /**
-        * Supported highlight colors.
-        * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
-        *
-        * @member mw.rcfilters
-        * @property {string[]}
-        */
-       mw.rcfilters.HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];
-}() );
diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js
deleted file mode 100644 (file)
index 5344af4..0000000
+++ /dev/null
@@ -1,294 +0,0 @@
-( function () {
-       /* eslint no-underscore-dangle: "off" */
-       /**
-        * URI Processor for RCFilters
-        *
-        * @class
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
-        * @param {Object} [config] Configuration object
-        * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
-        *  title normalization to separate title subpage/parts into the target= url
-        *  parameter
-        */
-       mw.rcfilters.UriProcessor = function MwRcfiltersController( filtersModel, config ) {
-               config = config || {};
-               this.filtersModel = filtersModel;
-
-               this.normalizeTarget = !!config.normalizeTarget;
-       };
-
-       /* Initialization */
-       OO.initClass( mw.rcfilters.UriProcessor );
-
-       /* Static methods */
-
-       /**
-        * Replace the url history through replaceState
-        *
-        * @param {mw.Uri} newUri New URI to replace
-        */
-       mw.rcfilters.UriProcessor.static.replaceState = function ( newUri ) {
-               window.history.replaceState(
-                       { tag: 'rcfilters' },
-                       document.title,
-                       newUri.toString()
-               );
-       };
-
-       /**
-        * Push the url to history through pushState
-        *
-        * @param {mw.Uri} newUri New URI to push
-        */
-       mw.rcfilters.UriProcessor.static.pushState = function ( newUri ) {
-               window.history.pushState(
-                       { tag: 'rcfilters' },
-                       document.title,
-                       newUri.toString()
-               );
-       };
-
-       /* Methods */
-
-       /**
-        * Get the version that this URL query is tagged with.
-        *
-        * @param {Object} [uriQuery] URI query
-        * @return {number} URL version
-        */
-       mw.rcfilters.UriProcessor.prototype.getVersion = function ( uriQuery ) {
-               uriQuery = uriQuery || new mw.Uri().query;
-
-               return Number( uriQuery.urlversion || 1 );
-       };
-
-       /**
-        * Get an updated mw.Uri object based on the model state
-        *
-        * @param {mw.Uri} [uri] An external URI to build the new uri
-        *  with. This is mainly for tests, to be able to supply external query
-        *  parameters and make sure they are retained.
-        * @return {mw.Uri} Updated Uri
-        */
-       mw.rcfilters.UriProcessor.prototype.getUpdatedUri = function ( uri ) {
-               var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
-                       unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );
-
-               normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
-                       $.extend(
-                               true,
-                               {},
-                               normalizedUri.query,
-                               // The representation must be expanded so it can
-                               // override the uri query params but we then output
-                               // a minimized version for the entire URI representation
-                               // for the method
-                               this.filtersModel.getExpandedParamRepresentation()
-                       )
-               );
-
-               // Reapply unrecognized params and url version
-               normalizedUri.query = $.extend(
-                       true,
-                       {},
-                       normalizedUri.query,
-                       unrecognizedParams,
-                       { urlversion: '2' }
-               );
-
-               return normalizedUri;
-       };
-
-       /**
-        * Move the subpage to the target parameter
-        *
-        * @param {mw.Uri} uri
-        * @return {mw.Uri}
-        * @private
-        */
-       mw.rcfilters.UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
-               var parts,
-                       // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
-                       re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
-
-               if ( !this.normalizeTarget ) {
-                       return uri;
-               }
-
-               // target in title param
-               if ( uri.query.title ) {
-                       parts = uri.query.title.match( re );
-                       if ( parts ) {
-                               uri.query.title = parts[ 1 ];
-                               uri.query.target = parts[ 2 ];
-                       }
-               }
-
-               // target in path
-               parts = mw.Uri.decode( uri.path ).match( re );
-               if ( parts ) {
-                       uri.path = parts[ 1 ];
-                       uri.query.target = parts[ 2 ];
-               }
-
-               return uri;
-       };
-
-       /**
-        * Get an object representing given parameters that are unrecognized by the model
-        *
-        * @param  {Object} params Full params object
-        * @return {Object} Unrecognized params
-        */
-       mw.rcfilters.UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
-               // Start with full representation
-               var givenParamNames = Object.keys( params ),
-                       unrecognizedParams = $.extend( true, {}, params );
-
-               // Extract unrecognized parameters
-               Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
-                       // Remove recognized params
-                       if ( givenParamNames.indexOf( paramName ) > -1 ) {
-                               delete unrecognizedParams[ paramName ];
-                       }
-               } );
-
-               return unrecognizedParams;
-       };
-
-       /**
-        * Update the URL of the page to reflect current filters
-        *
-        * This should not be called directly from outside the controller.
-        * If an action requires changing the URL, it should either use the
-        * highlighting actions below, or call #updateChangesList which does
-        * the uri corrections already.
-        *
-        * @param {Object} [params] Extra parameters to add to the API call
-        */
-       mw.rcfilters.UriProcessor.prototype.updateURL = function ( params ) {
-               var currentUri = new mw.Uri(),
-                       updatedUri = this.getUpdatedUri();
-
-               updatedUri.extend( params || {} );
-
-               if (
-                       this.getVersion( currentUri.query ) !== 2 ||
-                       this.isNewState( currentUri.query, updatedUri.query )
-               ) {
-                       this.constructor.static.replaceState( updatedUri );
-               }
-       };
-
-       /**
-        * Update the filters model based on the URI query
-        * This happens on initialization, and from this moment on,
-        * we consider the system synchronized, and the model serves
-        * as the source of truth for the URL.
-        *
-        * This methods should only be called once on initialization.
-        * After initialization, the model updates the URL, not the
-        * other way around.
-        *
-        * @param {Object} [uriQuery] URI query
-        */
-       mw.rcfilters.UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
-               uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
-               this.filtersModel.updateStateFromParams(
-                       this._getNormalizedQueryParams( uriQuery )
-               );
-       };
-
-       /**
-        * Compare two URI queries to decide whether they are different
-        * enough to represent a new state.
-        *
-        * @param {Object} currentUriQuery Current Uri query
-        * @param {Object} updatedUriQuery Updated Uri query
-        * @return {boolean} This is a new state
-        */
-       mw.rcfilters.UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
-               var currentParamState, updatedParamState,
-                       notEquivalent = function ( obj1, obj2 ) {
-                               var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
-                               return keys.some( function ( key ) {
-                                       return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
-                               } );
-                       };
-
-               // Compare states instead of parameters
-               // This will allow us to always have a proper check of whether
-               // the requested new url is one to change or not, regardless of
-               // actual parameter visibility/representation in the URL
-               currentParamState = $.extend(
-                       true,
-                       {},
-                       this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
-                       this.getUnrecognizedParams( currentUriQuery )
-               );
-               updatedParamState = $.extend(
-                       true,
-                       {},
-                       this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
-                       this.getUnrecognizedParams( updatedUriQuery )
-               );
-
-               return notEquivalent( currentParamState, updatedParamState );
-       };
-
-       /**
-        * Check whether the given query has parameters that are
-        * recognized as parameters we should load the system with
-        *
-        * @param {mw.Uri} [uriQuery] Given URI query
-        * @return {boolean} Query contains valid recognized parameters
-        */
-       mw.rcfilters.UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
-               var anyValidInUrl,
-                       validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );
-
-               uriQuery = uriQuery || new mw.Uri().query;
-
-               anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
-                       return validParameterNames.indexOf( parameter ) > -1;
-               } );
-
-               // URL version 2 is allowed to be empty or within nonrecognized params
-               return anyValidInUrl || this.getVersion( uriQuery ) === 2;
-       };
-
-       /**
-        * Get the adjusted URI params based on the url version
-        * If the urlversion is not 2, the parameters are merged with
-        * the model's defaults.
-        * Always merge in the hidden parameter defaults.
-        *
-        * @private
-        * @param {Object} uriQuery Current URI query
-        * @return {Object} Normalized parameters
-        */
-       mw.rcfilters.UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
-               // Check whether we are dealing with urlversion=2
-               // If we are, we do not merge the initial request with
-               // defaults. Not having urlversion=2 means we need to
-               // reproduce the server-side request and merge the
-               // requested parameters (or starting state) with the
-               // wiki default.
-               // Any subsequent change of the URL through the RCFilters
-               // system will receive 'urlversion=2'
-               var base = this.getVersion( uriQuery ) === 2 ?
-                       {} :
-                       this.filtersModel.getDefaultParams();
-
-               return $.extend(
-                       true,
-                       {},
-                       this.filtersModel.getMinimizedParamRepresentation(
-                               $.extend( true, {}, base, uriQuery )
-                       ),
-                       { urlversion: '2' }
-               );
-       };
-}() );
index f866aa4..4e0d3da 100644 (file)
  * JavaScript for Special:RecentChanges
  */
 ( function () {
-       var rcfilters = {
-               /**
-                * @member mw.rcfilters
-                * @private
-                */
-               init: function () {
-                       var $topSection,
-                               mainWrapperWidget,
-                               conditionalViews = {},
-                               $initialFieldset = $( 'fieldset.cloptions' ),
-                               savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
-                               daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
-                               limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
-                               activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
-                               initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
-                               filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
-                               changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
-                               savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
-                               specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
-                               controller = new mw.rcfilters.Controller(
-                                       filtersModel, changesListModel, savedQueriesModel,
-                                       {
-                                               savedQueriesPreferenceName: savedQueriesPreferenceName,
-                                               daysPreferenceName: daysPreferenceName,
-                                               limitPreferenceName: limitPreferenceName,
-                                               collapsedPreferenceName: activeFiltersCollapsedName,
-                                               normalizeTarget: specialPage === 'Recentchangeslinked'
-                                       }
-                               );
-
-                       // TODO: The changesListWrapperWidget should be able to initialize
-                       // after the model is ready.
-
-                       if ( specialPage === 'Recentchanges' ) {
-                               $topSection = $( '.mw-recentchanges-toplinks' ).detach();
-                       } else if ( specialPage === 'Watchlist' ) {
-                               $( '#contentSub, form#mw-watchlist-resetbutton' ).remove();
-                               $topSection = $( '.watchlistDetails' ).detach().contents();
-                       } else if ( specialPage === 'Recentchangeslinked' ) {
-                               conditionalViews.recentChangesLinked = {
-                                       groups: [
-                                               {
-                                                       name: 'page',
-                                                       type: 'any_value',
-                                                       title: '',
-                                                       hidden: true,
-                                                       sticky: true,
-                                                       filters: [
-                                                               {
-                                                                       name: 'target',
-                                                                       default: ''
-                                                               }
-                                                       ]
-                                               },
-                                               {
-                                                       name: 'toOrFrom',
-                                                       type: 'boolean',
-                                                       title: '',
-                                                       hidden: true,
-                                                       sticky: true,
-                                                       filters: [
-                                                               {
-                                                                       name: 'showlinkedto',
-                                                                       default: false
-                                                               }
-                                                       ]
-                                               }
-                                       ]
-                               };
-                       }
 
-                       mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
-                               controller,
-                               filtersModel,
-                               savedQueriesModel,
-                               changesListModel,
+       mw.rcfilters.HighlightColors = require( './HighlightColors.js' );
+       mw.rcfilters.ui.MainWrapperWidget = require( './ui/MainWrapperWidget.js' );
+
+       /**
+        * Get list of namespaces and remove unused ones
+        *
+        * @member mw.rcfilters
+        * @private
+        *
+        * @param {Array} unusedNamespaces Names of namespaces to remove
+        * @return {Array} Filtered array of namespaces
+        */
+       function getNamespaces( unusedNamespaces ) {
+               var i, length, name, id,
+                       namespaceIds = mw.config.get( 'wgNamespaceIds' ),
+                       namespaces = mw.config.get( 'wgFormattedNamespaces' );
+
+               for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
+                       name = unusedNamespaces[ i ];
+                       id = namespaceIds[ name.toLowerCase() ];
+                       delete namespaces[ id ];
+               }
+
+               return namespaces;
+       }
+
+       /**
+        * @member mw.rcfilters
+        * @private
+        */
+       function init() {
+               var $topSection,
+                       mainWrapperWidget,
+                       conditionalViews = {},
+                       $initialFieldset = $( 'fieldset.cloptions' ),
+                       savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
+                       daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
+                       limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
+                       activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
+                       initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
+                       filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+                       changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
+                       savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
+                       specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
+                       controller = new mw.rcfilters.Controller(
+                               filtersModel, changesListModel, savedQueriesModel,
                                {
-                                       $wrapper: $( 'body' ),
-                                       $topSection: $topSection,
-                                       $filtersContainer: $( '.rcfilters-container' ),
-                                       $changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
-                                       $formContainer: $initialFieldset,
-                                       collapsed: initialCollapsedState
+                                       savedQueriesPreferenceName: savedQueriesPreferenceName,
+                                       daysPreferenceName: daysPreferenceName,
+                                       limitPreferenceName: limitPreferenceName,
+                                       collapsedPreferenceName: activeFiltersCollapsedName,
+                                       normalizeTarget: specialPage === 'Recentchangeslinked'
                                }
                        );
 
-                       // Remove the -loading class that may have been added on the server side.
-                       // If we are in fact going to load a default saved query, this .initialize()
-                       // call will do that and add the -loading class right back.
-                       $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
-
-                       controller.initialize(
-                               mw.config.get( 'wgStructuredChangeFilters' ),
-                               // All namespaces without Media namespace
-                               rcfilters.getNamespaces( [ 'Media' ] ),
-                               mw.config.get( 'wgRCFiltersChangeTags' ),
-                               conditionalViews
-                       );
+               // TODO: The changesListWrapperWidget should be able to initialize
+               // after the model is ready.
+
+               if ( specialPage === 'Recentchanges' ) {
+                       $topSection = $( '.mw-recentchanges-toplinks' ).detach();
+               } else if ( specialPage === 'Watchlist' ) {
+                       $( '#contentSub, form#mw-watchlist-resetbutton' ).remove();
+                       $topSection = $( '.watchlistDetails' ).detach().contents();
+               } else if ( specialPage === 'Recentchangeslinked' ) {
+                       conditionalViews.recentChangesLinked = {
+                               groups: [
+                                       {
+                                               name: 'page',
+                                               type: 'any_value',
+                                               title: '',
+                                               hidden: true,
+                                               sticky: true,
+                                               filters: [
+                                                       {
+                                                               name: 'target',
+                                                               default: ''
+                                                       }
+                                               ]
+                                       },
+                                       {
+                                               name: 'toOrFrom',
+                                               type: 'boolean',
+                                               title: '',
+                                               hidden: true,
+                                               sticky: true,
+                                               filters: [
+                                                       {
+                                                               name: 'showlinkedto',
+                                                               default: false
+                                                       }
+                                               ]
+                                       }
+                               ]
+                       };
+               }
+
+               mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
+                       controller,
+                       filtersModel,
+                       savedQueriesModel,
+                       changesListModel,
+                       {
+                               $wrapper: $( 'body' ),
+                               $topSection: $topSection,
+                               $filtersContainer: $( '.rcfilters-container' ),
+                               $changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
+                               $formContainer: $initialFieldset,
+                               collapsed: initialCollapsedState
+                       }
+               );
 
-                       mainWrapperWidget.initFormWidget( specialPage );
+               // Remove the -loading class that may have been added on the server side.
+               // If we are in fact going to load a default saved query, this .initialize()
+               // call will do that and add the -loading class right back.
+               $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
 
-                       $( 'a.mw-helplink' ).attr(
-                               'href',
-                               'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
-                       );
+               controller.initialize(
+                       mw.config.get( 'wgStructuredChangeFilters' ),
+                       // All namespaces without Media namespace
+                       getNamespaces( [ 'Media' ] ),
+                       mw.config.get( 'wgRCFiltersChangeTags' ),
+                       conditionalViews
+               );
+
+               mainWrapperWidget.initFormWidget( specialPage );
 
-                       controller.replaceUrl();
+               $( 'a.mw-helplink' ).attr(
+                       'href',
+                       'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
+               );
 
-                       mainWrapperWidget.setTopSection( specialPage );
+               controller.replaceUrl();
 
-                       /**
-                        * Fired when initialization of the filtering interface for changes list is complete.
-                        *
-                        * @event structuredChangeFilters_ui_initialized
-                        * @member mw.hook
-                        */
-                       mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
-               },
+               mainWrapperWidget.setTopSection( specialPage );
 
                /**
-                * Get list of namespaces and remove unused ones
+                * Fired when initialization of the filtering interface for changes list is complete.
                 *
-                * @member mw.rcfilters
-                * @private
-                *
-                * @param {Array} unusedNamespaces Names of namespaces to remove
-                * @return {Array} Filtered array of namespaces
+                * @event structuredChangeFilters_ui_initialized
+                * @member mw.hook
                 */
-               getNamespaces: function ( unusedNamespaces ) {
-                       var i, length, name, id,
-                               namespaceIds = mw.config.get( 'wgNamespaceIds' ),
-                               namespaces = mw.config.get( 'wgFormattedNamespaces' );
-
-                       for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
-                               name = unusedNamespaces[ i ];
-                               id = namespaceIds[ name.toLowerCase() ];
-                               delete namespaces[ id ];
-                       }
-
-                       return namespaces;
-               }
-       };
+               mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
+       }
 
        // Import i18n messages from config
        mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) );
 
        // Early execute of init
        if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
-               rcfilters.init();
+               init();
        } else {
-               $( rcfilters.init );
+               $( init );
        }
 
-       module.exports = rcfilters;
+       module.exports = mw.rcfilters;
 
 }() );
index f30c278..b32fb38 100644 (file)
@@ -4,7 +4,17 @@
         * @singleton
         */
        mw.rcfilters = {
-               dm: {},
+               Controller: require( './Controller.js' ),
+               UriProcessor: require( './UriProcessor.js' ),
+               dm: {
+                       ChangesListViewModel: require( './dm/ChangesListViewModel.js' ),
+                       FilterGroup: require( './dm/FilterGroup.js' ),
+                       FilterItem: require( './dm/FilterItem.js' ),
+                       FiltersViewModel: require( './dm/FiltersViewModel.js' ),
+                       ItemModel: require( './dm/ItemModel.js' ),
+                       SavedQueriesModel: require( './dm/SavedQueriesModel.js' ),
+                       SavedQueryItemModel: require( './dm/SavedQueryItemModel.js' )
+               },
                ui: {},
                utils: {
                        addArrayElementsUnique: function ( arr, elements ) {
@@ -46,4 +56,6 @@
                        }
                }
        };
+
+       module.exports = mw.rcfilters;
 }() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js
new file mode 100644 (file)
index 0000000..23b05e8
--- /dev/null
@@ -0,0 +1,174 @@
+( function () {
+       var ChangesLimitPopupWidget = require( './ChangesLimitPopupWidget.js' ),
+               DatePopupWidget = require( './DatePopupWidget.js' ),
+               ChangesLimitAndDateButtonWidget;
+
+       /**
+        * Widget defining the button controlling the popup for the number of results
+        *
+        * @class mw.rcfilters.ui.ChangesLimitAndDateButtonWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {Object} [config] Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        */
+       ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
+               config = config || {};
+
+               // Parent
+               ChangesLimitAndDateButtonWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = model;
+
+               this.$overlay = config.$overlay || this.$element;
+
+               this.button = null;
+               this.limitGroupModel = null;
+               this.groupByPageItemModel = null;
+               this.daysGroupModel = null;
+
+               this.model.connect( this, {
+                       initialize: 'onModelInitialize'
+               } );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( ChangesLimitAndDateButtonWidget, OO.ui.Widget );
+
+       /**
+        * Respond to model initialize event
+        */
+       ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
+               var changesLimitPopupWidget, selectedItem, currentValue, datePopupWidget,
+                       displayGroupModel = this.model.getGroup( 'display' );
+
+               this.limitGroupModel = this.model.getGroup( 'limit' );
+               this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
+               this.daysGroupModel = this.model.getGroup( 'days' );
+
+               // HACK: We need the model to be ready before we populate the button
+               // and the widget, because we require the filter items for the
+               // limit and their events. This addition is only done after the
+               // model is initialized.
+               // Note: This will be fixed soon!
+               if ( this.limitGroupModel && this.daysGroupModel ) {
+                       changesLimitPopupWidget = new ChangesLimitPopupWidget(
+                               this.limitGroupModel,
+                               this.groupByPageItemModel
+                       );
+
+                       datePopupWidget = new DatePopupWidget(
+                               this.daysGroupModel,
+                               {
+                                       label: mw.msg( 'rcfilters-date-popup-title' )
+                               }
+                       );
+
+                       selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
+                       currentValue = ( selectedItem && selectedItem.getLabel() ) ||
+                               mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );
+
+                       this.button = new OO.ui.PopupButtonWidget( {
+                               icon: 'settings',
+                               indicator: 'down',
+                               label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
+                               $overlay: this.$overlay,
+                               popup: {
+                                       width: 300,
+                                       padded: false,
+                                       anchor: false,
+                                       align: 'backwards',
+                                       $autoCloseIgnore: this.$overlay,
+                                       $content: $( '<div>' ).append(
+                                               // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
+                                               changesLimitPopupWidget.$element,
+                                               datePopupWidget.$element
+                                       )
+                               }
+                       } );
+                       this.updateButtonLabel();
+
+                       // Events
+                       this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
+                       this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
+                       changesLimitPopupWidget.connect( this, {
+                               limit: 'onPopupLimit',
+                               groupByPage: 'onPopupGroupByPage'
+                       } );
+                       datePopupWidget.connect( this, { days: 'onPopupDays' } );
+
+                       this.$element.append( this.button.$element );
+               }
+       };
+
+       /**
+        * Respond to popup limit change event
+        *
+        * @param {string} filterName Chosen filter name
+        */
+       ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
+               var item = this.limitGroupModel.getItemByName( filterName );
+
+               this.controller.toggleFilterSelect( filterName, true );
+               this.controller.updateLimitDefault( item.getParamName() );
+               this.button.popup.toggle( false );
+       };
+
+       /**
+        * Respond to popup limit change event
+        *
+        * @param {boolean} isGrouped The result set is grouped by page
+        */
+       ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
+               this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
+               this.controller.updateGroupByPageDefault( isGrouped );
+               this.button.popup.toggle( false );
+       };
+
+       /**
+        * Respond to popup limit change event
+        *
+        * @param {string} filterName Chosen filter name
+        */
+       ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
+               var item = this.daysGroupModel.getItemByName( filterName );
+
+               this.controller.toggleFilterSelect( filterName, true );
+               this.controller.updateDaysDefault( item.getParamName() );
+               this.button.popup.toggle( false );
+       };
+
+       /**
+        * Respond to limit choose event
+        *
+        * @param {string} filterName Filter name
+        */
+       ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
+               var message,
+                       limit = this.limitGroupModel.findSelectedItems()[ 0 ],
+                       label = limit && limit.getLabel(),
+                       days = this.daysGroupModel.findSelectedItems()[ 0 ],
+                       daysParamName = Number( days.getParamName() ) < 1 ?
+                               'rcfilters-days-show-hours' :
+                               'rcfilters-days-show-days';
+
+               // Update the label
+               if ( label && days ) {
+                       message = mw.msg( 'rcfilters-limit-and-date-label', label,
+                               mw.msg( daysParamName, days.getLabel() )
+                       );
+                       this.button.setLabel( message );
+               }
+       };
+
+       module.exports = ChangesLimitAndDateButtonWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js
new file mode 100644 (file)
index 0000000..d78c42b
--- /dev/null
@@ -0,0 +1,84 @@
+( function () {
+       var ValuePickerWidget = require( './ValuePickerWidget.js' ),
+               ChangesLimitPopupWidget;
+
+       /**
+        * Widget defining the popup to choose number of results
+        *
+        * @class mw.rcfilters.ui.ChangesLimitPopupWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
+        * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
+        * @param {Object} [config] Configuration object
+        */
+       ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
+               config = config || {};
+
+               // Parent
+               ChangesLimitPopupWidget.parent.call( this, config );
+
+               this.limitModel = limitModel;
+               this.groupByPageItemModel = groupByPageItemModel;
+
+               this.valuePicker = new ValuePickerWidget(
+                       this.limitModel,
+                       {
+                               label: mw.msg( 'rcfilters-limit-title' )
+                       }
+               );
+
+               this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
+                       selected: this.groupByPageItemModel.isSelected()
+               } );
+
+               // Events
+               this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
+               this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
+               this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
+                       .append(
+                               this.valuePicker.$element,
+                               new OO.ui.FieldLayout(
+                                       this.groupByPageCheckbox,
+                                       {
+                                               align: 'inline',
+                                               label: mw.msg( 'rcfilters-group-results-by-page' )
+                                       }
+                               ).$element
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget );
+
+       /* Events */
+
+       /**
+        * @event limit
+        * @param {string} name Item name
+        *
+        * A limit item was chosen
+        */
+
+       /**
+        * @event groupByPage
+        * @param {boolean} isGrouped The results are grouped by page
+        *
+        * Results are grouped by page
+        */
+
+       /**
+        * Respond to group by page model update
+        */
+       ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
+               this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
+       };
+
+       module.exports = ChangesLimitPopupWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js
new file mode 100644 (file)
index 0000000..361fe31
--- /dev/null
@@ -0,0 +1,388 @@
+( function () {
+       /**
+        * List of changes
+        *
+        * @class mw.rcfilters.ui.ChangesListWrapperWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
+        * @param {mw.rcfilters.Controller} controller
+        * @param {jQuery} $changesListRoot Root element of the changes list to attach to
+        * @param {Object} [config] Configuration object
+        */
+       var ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
+               filtersViewModel,
+               changesListViewModel,
+               controller,
+               $changesListRoot,
+               config
+       ) {
+               config = $.extend( {}, config, {
+                       $element: $changesListRoot
+               } );
+
+               // Parent
+               ChangesListWrapperWidget.parent.call( this, config );
+
+               this.filtersViewModel = filtersViewModel;
+               this.changesListViewModel = changesListViewModel;
+               this.controller = controller;
+               this.highlightClasses = null;
+
+               // Events
+               this.filtersViewModel.connect( this, {
+                       itemUpdate: 'onItemUpdate',
+                       highlightChange: 'onHighlightChange'
+               } );
+               this.changesListViewModel.connect( this, {
+                       invalidate: 'onModelInvalidate',
+                       update: 'onModelUpdate'
+               } );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
+                       // We handle our own display/hide of the empty results message
+                       // We keep the timeout class here and remove it later, since at this
+                       // stage it is still needed to identify that the timeout occurred.
+                       .removeClass( 'mw-changeslist-empty' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( ChangesListWrapperWidget, OO.ui.Widget );
+
+       /**
+        * Get all available highlight classes
+        *
+        * @return {string[]} An array of available highlight class names
+        */
+       ChangesListWrapperWidget.prototype.getHighlightClasses = function () {
+               if ( !this.highlightClasses || !this.highlightClasses.length ) {
+                       this.highlightClasses = this.filtersViewModel.getItemsSupportingHighlights()
+                               .map( function ( filterItem ) {
+                                       return filterItem.getCssClass();
+                               } );
+               }
+
+               return this.highlightClasses;
+       };
+
+       /**
+        * Respond to the highlight feature being toggled on and off
+        *
+        * @param {boolean} highlightEnabled
+        */
+       ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
+               if ( highlightEnabled ) {
+                       this.applyHighlight();
+               } else {
+                       this.clearHighlight();
+               }
+       };
+
+       /**
+        * Respond to a filter item model update
+        */
+       ChangesListWrapperWidget.prototype.onItemUpdate = function () {
+               if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
+                       // this.controller.isInitialized() is still false during page load,
+                       // we don't want to clear/apply highlights at this stage.
+                       this.clearHighlight();
+                       this.applyHighlight();
+               }
+       };
+
+       /**
+        * Respond to changes list model invalidate
+        */
+       ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
+               $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
+       };
+
+       /**
+        * Respond to changes list model update
+        *
+        * @param {jQuery|string} $changesListContent The content of the updated changes list
+        * @param {jQuery} $fieldset The content of the updated fieldset
+        * @param {string} noResultsDetails Type of no result error
+        * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
+        * @param {boolean} from Timestamp of the new changes
+        */
+       ChangesListWrapperWidget.prototype.onModelUpdate = function (
+               $changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
+       ) {
+               var conflictItem,
+                       $message = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
+                       isEmpty = $changesListContent === 'NO_RESULTS',
+                       // For enhanced mode, we have to load these modules, which are
+                       // not loaded for the 'regular' mode in the backend
+                       loaderPromise = mw.user.options.get( 'usenewrc' ) ?
+                               mw.loader.using( [ 'mediawiki.special.changeslist.enhanced', 'mediawiki.icon' ] ) :
+                               $.Deferred().resolve(),
+                       widget = this;
+
+               this.$element.toggleClass( 'mw-changeslist', !isEmpty );
+               if ( isEmpty ) {
+                       this.$element.empty();
+
+                       if ( this.filtersViewModel.hasConflict() ) {
+                               conflictItem = this.filtersViewModel.getFirstConflictedItem();
+
+                               $message
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
+                                                       .text( mw.message( 'rcfilters-noresults-conflict' ).text() ),
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
+                                                       .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() )
+                                       );
+                       } else {
+                               $message
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
+                                                       .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
+                                       );
+
+                               // remove all classes matching mw-changeslist-*
+                               this.$element.removeClass( function ( elementIndex, allClasses ) {
+                                       return allClasses
+                                               .split( ' ' )
+                                               .filter( function ( className ) {
+                                                       return className.indexOf( 'mw-changeslist-' ) === 0;
+                                               } )
+                                               .join( ' ' );
+                               } );
+                       }
+
+                       this.$element.append( $message );
+               } else {
+                       if ( !isInitialDOM ) {
+                               this.$element.empty().append( $changesListContent );
+
+                               if ( from ) {
+                                       this.emphasizeNewChanges( from );
+                               }
+                       }
+
+                       // Apply highlight
+                       this.applyHighlight();
+
+               }
+
+               this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
+
+               loaderPromise.done( function () {
+                       if ( !isInitialDOM && !isEmpty ) {
+                               // Make sure enhanced RC re-initializes correctly
+                               mw.hook( 'wikipage.content' ).fire( widget.$element );
+                       }
+
+                       $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
+               } );
+       };
+
+       /** Toggles overlay class on changes list
+        *
+        * @param {boolean} isVisible True if overlay should be visible
+        */
+       ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
+               this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
+       };
+
+       /**
+        * Map a reason for having no results to its message key
+        *
+        * @param {string} reason One of the NO_RESULTS_* "constant" that represent
+        *   a reason for having no results
+        * @return {string} Key for the message that explains why there is no results in this case
+        */
+       ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
+               var reasonMsgKeyMap = {
+                       NO_RESULTS_NORMAL: 'recentchanges-noresult',
+                       NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
+                       NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
+                       NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
+                       NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
+               };
+               return reasonMsgKeyMap[ reason ];
+       };
+
+       /**
+        * Emphasize the elements (or groups) newer than the 'from' parameter
+        * @param {string} from Anything newer than this is considered 'new'
+        */
+       ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
+               var $firstNew,
+                       $indicator,
+                       $newChanges = $( [] ),
+                       selector = this.inEnhancedMode() ?
+                               'table.mw-enhanced-rc[data-mw-ts]' :
+                               'li[data-mw-ts]',
+                       set = this.$element.find( selector ),
+                       length = set.length;
+
+               set.each( function ( index ) {
+                       var $this = $( this ),
+                               ts = $this.data( 'mw-ts' );
+
+                       if ( ts >= from ) {
+                               $newChanges = $newChanges.add( $this );
+                               $firstNew = $this;
+
+                               // guards against putting the marker after the last element
+                               if ( index === ( length - 1 ) ) {
+                                       $firstNew = null;
+                               }
+                       }
+               } );
+
+               if ( $firstNew ) {
+                       $indicator = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
+
+                       $firstNew.after( $indicator );
+               }
+
+               // FIXME: Use CSS transition
+               // eslint-disable-next-line jquery/no-fade
+               $newChanges
+                       .hide()
+                       .fadeIn( 1000 );
+       };
+
+       /**
+        * In enhanced mode, we need to check whether the grouped results all have the
+        * same active highlights in order to see whether the "parent" of the group should
+        * be grey or highlighted normally.
+        *
+        * This is called every time highlights are applied.
+        */
+       ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
+               var activeHighlightClasses,
+                       $enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc.mw-collapsible' );
+
+               activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
+                       return 'mw-rcfilters-highlight-color-' + color;
+               } );
+
+               // Go over top pages and their children, and figure out if all sub-pages have the
+               // same highlights between themselves. If they do, the parent should be highlighted
+               // with all colors. If classes are different, the parent should receive a grey
+               // background
+               $enhancedTopPageCell.each( function () {
+                       var firstChildClasses, $rowsWithDifferentHighlights,
+                               $table = $( this );
+
+                       // Collect the relevant classes from the first nested child
+                       firstChildClasses = activeHighlightClasses.filter( function ( className ) {
+                               return $table.find( 'tr:nth-child(2)' ).hasClass( className );
+                       } );
+                       // Filter the non-head rows and see if they all have the same classes
+                       // to the first row
+                       $rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
+                               var classesInThisRow,
+                                       $this = $( this );
+
+                               classesInThisRow = activeHighlightClasses.filter( function ( className ) {
+                                       return $this.hasClass( className );
+                               } );
+
+                               return !OO.compare( firstChildClasses, classesInThisRow );
+                       } );
+
+                       // If classes are different, tag the row for using grey color
+                       $table.find( 'tr:first-child' )
+                               .toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
+               } );
+       };
+
+       /**
+        * @return {boolean} Whether the changes are grouped by page
+        */
+       ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
+               var uri = new mw.Uri();
+               return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
+                       ( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
+       };
+
+       /**
+        * Apply color classes based on filters highlight configuration
+        */
+       ChangesListWrapperWidget.prototype.applyHighlight = function () {
+               if ( !this.filtersViewModel.isHighlightEnabled() ) {
+                       return;
+               }
+
+               this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
+                       var $elements = this.$element.find( '.' + filterItem.getCssClass() );
+
+                       // Add highlight class to all highlighted list items
+                       $elements
+                               .addClass(
+                                       'mw-rcfilters-highlighted ' +
+                                       'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
+                               );
+
+                       // Track the filters for each item in .data( 'highlightedFilters' )
+                       $elements.each( function () {
+                               var filters = $( this ).data( 'highlightedFilters' );
+                               if ( !filters ) {
+                                       filters = [];
+                                       $( this ).data( 'highlightedFilters', filters );
+                               }
+                               if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
+                                       filters.push( filterItem.getLabel() );
+                               }
+                       } );
+               }.bind( this ) );
+               // Apply a title to each highlighted item, with a list of filters
+               this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
+                       var filters = $( this ).data( 'highlightedFilters' );
+
+                       if ( filters && filters.length ) {
+                               $( this ).attr( 'title', mw.msg(
+                                       'rcfilters-highlighted-filters-list',
+                                       filters.join( mw.msg( 'comma-separator' ) )
+                               ) );
+                       }
+
+               } );
+               if ( this.inEnhancedMode() ) {
+                       this.updateEnhancedParentHighlight();
+               }
+
+               // Turn on highlights
+               this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
+       };
+
+       /**
+        * Remove all color classes
+        */
+       ChangesListWrapperWidget.prototype.clearHighlight = function () {
+               // Remove highlight classes
+               mw.rcfilters.HighlightColors.forEach( function ( color ) {
+                       this.$element
+                               .find( '.mw-rcfilters-highlight-color-' + color )
+                               .removeClass( 'mw-rcfilters-highlight-color-' + color );
+               }.bind( this ) );
+
+               this.$element.find( '.mw-rcfilters-highlighted' )
+                       .removeAttr( 'title' )
+                       .removeData( 'highlightedFilters' )
+                       .removeClass( 'mw-rcfilters-highlighted' );
+
+               // Remove grey from enhanced rows
+               this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
+                       .removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );
+
+               // Turn off highlights
+               this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
+       };
+
+       module.exports = ChangesListWrapperWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js b/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js
new file mode 100644 (file)
index 0000000..490d54e
--- /dev/null
@@ -0,0 +1,66 @@
+( function () {
+       /**
+        * A widget representing a single toggle filter
+        *
+        * @class mw.rcfilters.ui.CheckboxInputWidget
+        * @extends OO.ui.CheckboxInputWidget
+        *
+        * @constructor
+        * @param {Object} config Configuration object
+        */
+       var CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
+               config = config || {};
+
+               // Parent
+               CheckboxInputWidget.parent.call( this, config );
+
+               // Event
+               this.$input
+                       // HACK: This widget just pretends to be a checkbox for visual purposes.
+                       // In reality, all actions - setting to true or false, etc - are
+                       // decided by the model, and executed by the controller. This means
+                       // that we want to let the controller and model make the decision
+                       // of whether to check/uncheck this checkboxInputWidget, and for that,
+                       // we have to bypass the browser action that checks/unchecks it during
+                       // click.
+                       .on( 'click', false )
+                       .on( 'change', this.onUserChange.bind( this ) );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget );
+
+       /* Events */
+
+       /**
+        * @event userChange
+        * @param {boolean} Current state of the checkbox
+        *
+        * The user has checked or unchecked this checkbox
+        */
+
+       /* Methods */
+
+       /**
+        * @inheritdoc
+        */
+       CheckboxInputWidget.prototype.onEdit = function () {
+               // Similarly to preventing defaults in 'click' event, we want
+               // to prevent this widget from deciding anything about its own
+               // state; it emits a change event and the model and controller
+               // make a decision about what its select state is.
+               // onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
+               // so we really want to prevent that from messing with what
+               // the model decides the state of the widget is.
+       };
+
+       /**
+        * Respond to checkbox change by a user and emit 'userChange'.
+        */
+       CheckboxInputWidget.prototype.onUserChange = function () {
+               this.emit( 'userChange', this.$input.prop( 'checked' ) );
+       };
+
+       module.exports = CheckboxInputWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js b/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js
new file mode 100644 (file)
index 0000000..1ac0d49
--- /dev/null
@@ -0,0 +1,72 @@
+( function () {
+       var ValuePickerWidget = require( './ValuePickerWidget.js' ),
+               DatePopupWidget;
+
+       /**
+        * Widget defining the popup to choose date for the results
+        *
+        * @class mw.rcfilters.ui.DatePopupWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days'
+        * @param {Object} [config] Configuration object
+        */
+       DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) {
+               config = config || {};
+
+               // Parent
+               DatePopupWidget.parent.call( this, config );
+               // Mixin constructors
+               OO.ui.mixin.LabelElement.call( this, config );
+
+               this.model = model;
+
+               this.hoursValuePicker = new ValuePickerWidget(
+                       this.model,
+                       {
+                               classes: [ 'mw-rcfilters-ui-datePopupWidget-hours' ],
+                               label: mw.msg( 'rcfilters-hours-title' ),
+                               itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) < 1; }
+                       }
+               );
+               this.daysValuePicker = new ValuePickerWidget(
+                       this.model,
+                       {
+                               classes: [ 'mw-rcfilters-ui-datePopupWidget-days' ],
+                               label: mw.msg( 'rcfilters-days-title' ),
+                               itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) >= 1; }
+                       }
+               );
+
+               // Events
+               this.hoursValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
+               this.daysValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-datePopupWidget' )
+                       .append(
+                               this.$label
+                                       .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
+                               this.hoursValuePicker.$element,
+                               this.daysValuePicker.$element
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( DatePopupWidget, OO.ui.Widget );
+       OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement );
+
+       /* Events */
+
+       /**
+        * @event days
+        * @param {string} name Item name
+        *
+        * A days item was chosen
+        */
+
+       module.exports = DatePopupWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js b/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js
new file mode 100644 (file)
index 0000000..1327755
--- /dev/null
@@ -0,0 +1,85 @@
+( function () {
+       /**
+        * A button to configure highlight for a filter item
+        *
+        * @class mw.rcfilters.ui.FilterItemHighlightButton
+        * @extends OO.ui.PopupButtonWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {mw.rcfilters.dm.FilterItem} model Filter item model
+        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
+        * @param {Object} [config] Configuration object
+        */
+       var FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
+               config = config || {};
+
+               // Parent
+               FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
+                       icon: 'highlight',
+                       indicator: 'down'
+               } ) );
+
+               this.controller = controller;
+               this.model = model;
+               this.popup = highlightPopup;
+
+               // Event
+               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+               // This lives inside a MenuOptionWidget, which intercepts mousedown
+               // to select the item. We want to prevent that when we click the highlight
+               // button
+               this.$element.on( 'mousedown', function ( e ) {
+                       e.stopPropagation();
+               } );
+
+               this.updateUiBasedOnModel();
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterItemHighlightButton, OO.ui.PopupButtonWidget );
+
+       /* Static Properties */
+
+       /**
+        * @static
+        */
+       FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;
+
+       /* Methods */
+
+       FilterItemHighlightButton.prototype.onAction = function () {
+               this.popup.setAssociatedButton( this );
+               this.popup.setFilterItem( this.model );
+
+               // Parent method
+               FilterItemHighlightButton.parent.prototype.onAction.call( this );
+       };
+
+       /**
+        * Respond to item model update event
+        */
+       FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
+               var currentColor = this.model.getHighlightColor(),
+                       widget = this;
+
+               this.$icon.toggleClass(
+                       'mw-rcfilters-ui-filterItemHighlightButton-circle',
+                       currentColor !== null
+               );
+
+               mw.rcfilters.HighlightColors.forEach( function ( c ) {
+                       widget.$icon
+                               .toggleClass(
+                                       'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
+                                       c === currentColor
+                               );
+               } );
+       };
+
+       module.exports = FilterItemHighlightButton;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js
new file mode 100644 (file)
index 0000000..1396341
--- /dev/null
@@ -0,0 +1,184 @@
+( function () {
+       /**
+        * Menu header for the RCFilters filters menu
+        *
+        * @class mw.rcfilters.ui.FilterMenuHeaderWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {Object} config Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        */
+       var FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
+               config = config || {};
+
+               this.controller = controller;
+               this.model = model;
+               this.$overlay = config.$overlay || this.$element;
+
+               // Parent
+               FilterMenuHeaderWidget.parent.call( this, config );
+               OO.ui.mixin.LabelElement.call( this, $.extend( {
+                       label: mw.msg( 'rcfilters-filterlist-title' ),
+                       $label: $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
+               }, config ) );
+
+               // "Back" to default view button
+               this.backButton = new OO.ui.ButtonWidget( {
+                       icon: 'previous',
+                       framed: false,
+                       title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
+                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
+               } );
+               this.backButton.toggle( this.model.getCurrentView() !== 'default' );
+
+               // Help icon for Tagged edits
+               this.helpIcon = new OO.ui.ButtonWidget( {
+                       icon: 'helpNotice',
+                       framed: false,
+                       title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
+                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
+                       href: mw.util.getUrl( 'Special:Tags' ),
+                       target: '_blank'
+               } );
+               this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );
+
+               // Highlight button
+               this.highlightButton = new OO.ui.ToggleButtonWidget( {
+                       icon: 'highlight',
+                       label: mw.message( 'rcfilters-highlightbutton-title' ).text(),
+                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
+               } );
+
+               // Invert namespaces button
+               this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
+                       icon: '',
+                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
+               } );
+               this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
+
+               // Events
+               this.backButton.connect( this, { click: 'onBackButtonClick' } );
+               this.highlightButton
+                       .connect( this, { click: 'onHighlightButtonClick' } );
+               this.invertNamespacesButton
+                       .connect( this, { click: 'onInvertNamespacesButtonClick' } );
+               this.model.connect( this, {
+                       highlightChange: 'onModelHighlightChange',
+                       searchChange: 'onModelSearchChange',
+                       initialize: 'onModelInitialize'
+               } );
+               this.view = this.model.getCurrentView();
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
+                                                                       .append( this.backButton.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
+                                                                       .append( this.$label, this.helpIcon.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
+                                                                       .append( this.invertNamespacesButton.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
+                                                                       .append( this.highlightButton.$element )
+                                                       )
+                                       )
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterMenuHeaderWidget, OO.ui.Widget );
+       OO.mixinClass( FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );
+
+       /* Methods */
+
+       /**
+        * Respond to model initialization event
+        *
+        * Note: need to wait for initialization before getting the invertModel
+        * and registering its update event. Creating all the models before the UI
+        * would help with that.
+        */
+       FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
+               this.invertModel = this.model.getInvertModel();
+               this.updateInvertButton();
+               this.invertModel.connect( this, { update: 'updateInvertButton' } );
+       };
+
+       /**
+        * Respond to model update event
+        */
+       FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
+               var currentView = this.model.getCurrentView();
+
+               if ( this.view !== currentView ) {
+                       this.setLabel( this.model.getViewTitle( currentView ) );
+
+                       this.invertNamespacesButton.toggle( currentView === 'namespaces' );
+                       this.backButton.toggle( currentView !== 'default' );
+                       this.helpIcon.toggle( currentView === 'tags' );
+                       this.view = currentView;
+               }
+       };
+
+       /**
+        * Respond to model highlight change event
+        *
+        * @param {boolean} highlightEnabled Highlight is enabled
+        */
+       FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
+               this.highlightButton.setActive( highlightEnabled );
+       };
+
+       /**
+        * Update the state of the invert button
+        */
+       FilterMenuHeaderWidget.prototype.updateInvertButton = function () {
+               this.invertNamespacesButton.setActive( this.invertModel.isSelected() );
+               this.invertNamespacesButton.setLabel(
+                       this.invertModel.isSelected() ?
+                               mw.msg( 'rcfilters-exclude-button-on' ) :
+                               mw.msg( 'rcfilters-exclude-button-off' )
+               );
+       };
+
+       FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
+               this.controller.switchView( 'default' );
+       };
+
+       /**
+        * Respond to highlight button click
+        */
+       FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
+               this.controller.toggleHighlight();
+       };
+
+       /**
+        * Respond to highlight button click
+        */
+       FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
+               this.controller.toggleInvertedNamespaces();
+       };
+
+       module.exports = FilterMenuHeaderWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js
new file mode 100644 (file)
index 0000000..4080f4d
--- /dev/null
@@ -0,0 +1,96 @@
+( function () {
+       var ItemMenuOptionWidget = require( './ItemMenuOptionWidget.js' ),
+               FilterMenuOptionWidget;
+
+       /**
+        * A widget representing a single toggle filter
+        *
+        * @class mw.rcfilters.ui.FilterMenuOptionWidget
+        * @extends mw.rcfilters.ui.ItemMenuOptionWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+        * @param {mw.rcfilters.dm.FilterItem} invertModel
+        * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
+        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
+        * @param {Object} config Configuration object
+        */
+       FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
+               controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
+       ) {
+               config = config || {};
+
+               this.controller = controller;
+               this.invertModel = invertModel;
+               this.model = itemModel;
+
+               // Parent
+               FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
+
+               // Event
+               this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
+       };
+
+       /* Initialization */
+       OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget );
+
+       /* Static properties */
+
+       // We do our own scrolling to top
+       FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+
+       /* Methods */
+
+       /**
+        * @inheritdoc
+        */
+       FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+               // Parent
+               FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
+
+               this.setCurrentMuteState();
+       };
+
+       /**
+        * Respond to item group model update event
+        */
+       FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
+               this.setCurrentMuteState();
+       };
+
+       /**
+        * Set the current muted view of the widget based on its state
+        */
+       FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
+               if (
+                       this.model.getGroupModel().getView() === 'namespaces' &&
+                       this.invertModel.isSelected()
+               ) {
+                       // This is an inverted behavior than the other rules, specifically
+                       // for inverted namespaces
+                       this.setFlags( {
+                               muted: this.model.isSelected()
+                       } );
+               } else {
+                       this.setFlags( {
+                               muted: (
+                                       this.model.isConflicted() ||
+                                       (
+                                               // Item is also muted when any of the items in its group is active
+                                               this.model.getGroupModel().isActive() &&
+                                               // But it isn't selected
+                                               !this.model.isSelected() &&
+                                               // And also not included
+                                               !this.model.isIncluded()
+                                       )
+                               )
+                       } );
+               }
+       };
+
+       module.exports = FilterMenuOptionWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js
new file mode 100644 (file)
index 0000000..5b9e359
--- /dev/null
@@ -0,0 +1,127 @@
+( function () {
+       /**
+        * A widget representing a menu section for filter groups
+        *
+        * @class mw.rcfilters.ui.FilterMenuSectionOptionWidget
+        * @extends OO.ui.MenuSectionOptionWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
+        * @param {Object} config Configuration object
+        * @cfg {jQuery} [$overlay] Overlay
+        */
+       var FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
+               var whatsThisMessages,
+                       $header = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
+                       $popupContent = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );
+
+               config = config || {};
+
+               this.controller = controller;
+               this.model = model;
+               this.$overlay = config.$overlay || this.$element;
+
+               // Parent
+               FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
+                       label: this.model.getTitle(),
+                       $label: $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
+               }, config ) );
+
+               $header.append( this.$label );
+
+               if ( this.model.hasWhatsThis() ) {
+                       whatsThisMessages = this.model.getWhatsThis();
+
+                       // Create popup
+                       if ( whatsThisMessages.header ) {
+                               $popupContent.append(
+                                       ( new OO.ui.LabelWidget( {
+                                               label: mw.msg( whatsThisMessages.header ),
+                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
+                                       } ) ).$element
+                               );
+                       }
+                       if ( whatsThisMessages.body ) {
+                               $popupContent.append(
+                                       ( new OO.ui.LabelWidget( {
+                                               label: mw.msg( whatsThisMessages.body ),
+                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
+                                       } ) ).$element
+                               );
+                       }
+                       if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
+                               $popupContent.append(
+                                       ( new OO.ui.ButtonWidget( {
+                                               framed: false,
+                                               flags: [ 'progressive' ],
+                                               href: whatsThisMessages.url,
+                                               label: mw.msg( whatsThisMessages.linkText ),
+                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
+                                       } ) ).$element
+                               );
+                       }
+
+                       // Add button
+                       this.whatsThisButton = new OO.ui.PopupButtonWidget( {
+                               framed: false,
+                               label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
+                               $overlay: this.$overlay,
+                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
+                               flags: [ 'progressive' ],
+                               popup: {
+                                       padded: false,
+                                       align: 'center',
+                                       position: 'above',
+                                       $content: $popupContent,
+                                       classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
+                               }
+                       } );
+
+                       $header
+                               .append( this.whatsThisButton.$element );
+               }
+
+               // Events
+               this.model.connect( this, { update: 'updateUiBasedOnState' } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
+                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
+                       .append( $header );
+               this.updateUiBasedOnState();
+       };
+
+       /* Initialize */
+
+       OO.inheritClass( FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );
+
+       /* Methods */
+
+       /**
+        * Respond to model update event
+        */
+       FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
+               this.$element.toggleClass(
+                       'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
+                       this.model.isActive()
+               );
+               this.toggle( this.model.isVisible() );
+       };
+
+       /**
+        * Get the group name
+        *
+        * @return {string} Group name
+        */
+       FilterMenuSectionOptionWidget.prototype.getName = function () {
+               return this.model.getName();
+       };
+
+       module.exports = FilterMenuSectionOptionWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js
new file mode 100644 (file)
index 0000000..bda898b
--- /dev/null
@@ -0,0 +1,50 @@
+( function () {
+       var TagItemWidget = require( './TagItemWidget.js' ),
+               FilterTagItemWidget;
+
+       /**
+        * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
+        *
+        * @class mw.rcfilters.ui.FilterTagItemWidget
+        * @extends mw.rcfilters.ui.TagItemWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+        * @param {mw.rcfilters.dm.FilterItem} invertModel
+        * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
+        * @param {Object} config Configuration object
+        */
+       FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
+               controller, filtersViewModel, invertModel, itemModel, config
+       ) {
+               config = config || {};
+
+               FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterTagItemWidget, TagItemWidget );
+
+       /* Methods */
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagItemWidget.prototype.setCurrentMuteState = function () {
+               this.setFlags( {
+                       muted: (
+                               !this.itemModel.isSelected() ||
+                               this.itemModel.isIncluded() ||
+                               this.itemModel.isFullyCovered()
+                       ),
+                       invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
+               } );
+       };
+
+       module.exports = FilterTagItemWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js
new file mode 100644 (file)
index 0000000..4881542
--- /dev/null
@@ -0,0 +1,778 @@
+( function () {
+       var ViewSwitchWidget = require( './ViewSwitchWidget.js' ),
+               SaveFiltersPopupButtonWidget = require( './SaveFiltersPopupButtonWidget.js' ),
+               MenuSelectWidget = require( './MenuSelectWidget.js' ),
+               FilterTagItemWidget = require( './FilterTagItemWidget.js' ),
+               FilterTagMultiselectWidget;
+
+       /**
+        * List displaying all filter groups
+        *
+        * @class mw.rcfilters.ui.FilterTagMultiselectWidget
+        * @extends OO.ui.MenuTagMultiselectWidget
+        * @mixins OO.ui.mixin.PendingElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+        * @param {Object} config Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+        *  system. If not given, falls back to this widget's $element
+        * @cfg {boolean} [collapsed] Filter area is collapsed
+        */
+       FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
+               var rcFiltersRow,
+                       title = new OO.ui.LabelWidget( {
+                               label: mw.msg( 'rcfilters-activefilters' ),
+                               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
+                       } ),
+                       $contentWrapper = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
+
+               config = config || {};
+
+               this.controller = controller;
+               this.model = model;
+               this.queriesModel = savedQueriesModel;
+               this.$overlay = config.$overlay || this.$element;
+               this.$wrapper = config.$wrapper || this.$element;
+               this.matchingQuery = null;
+               this.currentView = this.model.getCurrentView();
+               this.collapsed = false;
+
+               // Parent
+               FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
+                       label: mw.msg( 'rcfilters-filterlist-title' ),
+                       placeholder: mw.msg( 'rcfilters-empty-filter' ),
+                       inputPosition: 'outline',
+                       allowArbitrary: false,
+                       allowDisplayInvalidTags: false,
+                       allowReordering: false,
+                       $overlay: this.$overlay,
+                       menu: {
+                               // Our filtering is done through the model
+                               filterFromInput: false,
+                               hideWhenOutOfView: false,
+                               hideOnChoose: false,
+                               width: 650,
+                               footers: [
+                                       {
+                                               name: 'viewSelect',
+                                               sticky: false,
+                                               // View select menu, appears on default view only
+                                               $element: $( '<div>' )
+                                                       .append( new ViewSwitchWidget( this.controller, this.model ).$element ),
+                                               views: [ 'default' ]
+                                       },
+                                       {
+                                               name: 'feedback',
+                                               // Feedback footer, appears on all views
+                                               $element: $( '<div>' )
+                                                       .append(
+                                                               new OO.ui.ButtonWidget( {
+                                                                       framed: false,
+                                                                       icon: 'feedback',
+                                                                       flags: [ 'progressive' ],
+                                                                       label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
+                                                                       href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
+                                                               } ).$element
+                                                       )
+                                       }
+                               ]
+                       },
+                       input: {
+                               icon: 'menu',
+                               placeholder: mw.msg( 'rcfilters-search-placeholder' )
+                       }
+               }, config ) );
+
+               this.savedQueryTitle = new OO.ui.LabelWidget( {
+                       label: '',
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
+               } );
+
+               this.resetButton = new OO.ui.ButtonWidget( {
+                       framed: false,
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
+               } );
+
+               this.hideShowButton = new OO.ui.ButtonWidget( {
+                       framed: false,
+                       flags: [ 'progressive' ],
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
+               } );
+               this.toggleCollapsed( !!config.collapsed );
+
+               if ( !mw.user.isAnon() ) {
+                       this.saveQueryButton = new SaveFiltersPopupButtonWidget(
+                               this.controller,
+                               this.queriesModel,
+                               {
+                                       $overlay: this.$overlay
+                               }
+                       );
+
+                       this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
+                               e.stopPropagation();
+                       } );
+
+                       this.saveQueryButton.connect( this, {
+                               click: 'onSaveQueryButtonClick',
+                               saveCurrent: 'setSavedQueryVisibility'
+                       } );
+                       this.queriesModel.connect( this, {
+                               itemUpdate: 'onSavedQueriesItemUpdate',
+                               initialize: 'onSavedQueriesInitialize',
+                               default: 'reevaluateResetRestoreState'
+                       } );
+               }
+
+               this.emptyFilterMessage = new OO.ui.LabelWidget( {
+                       label: mw.msg( 'rcfilters-empty-filter' ),
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
+               } );
+               this.$content.append( this.emptyFilterMessage.$element );
+
+               // Events
+               this.resetButton.connect( this, { click: 'onResetButtonClick' } );
+               this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
+               // Stop propagation for mousedown, so that the widget doesn't
+               // trigger the focus on the input and scrolls up when we click the reset button
+               this.resetButton.$element.on( 'mousedown', function ( e ) {
+                       e.stopPropagation();
+               } );
+               this.hideShowButton.$element.on( 'mousedown', function ( e ) {
+                       e.stopPropagation();
+               } );
+               this.model.connect( this, {
+                       initialize: 'onModelInitialize',
+                       update: 'onModelUpdate',
+                       searchChange: 'onModelSearchChange',
+                       itemUpdate: 'onModelItemUpdate',
+                       highlightChange: 'onModelHighlightChange'
+               } );
+               this.input.connect( this, { change: 'onInputChange' } );
+
+               // The filter list and button should appear side by side regardless of how
+               // wide the button is; the button also changes its width depending
+               // on language and its state, so the safest way to present both side
+               // by side is with a table layout
+               rcFiltersRow = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-row' )
+                       .append(
+                               this.$content
+                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
+                       );
+
+               if ( !mw.user.isAnon() ) {
+                       rcFiltersRow.append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
+                                       .append( this.saveQueryButton.$element )
+                       );
+               }
+
+               // Add a selector at the right of the input
+               this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
+                       items: [
+                               new OO.ui.ButtonOptionWidget( {
+                                       framed: false,
+                                       data: 'namespaces',
+                                       icon: 'article',
+                                       label: mw.msg( 'namespaces' ),
+                                       title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
+                               } ),
+                               new OO.ui.ButtonOptionWidget( {
+                                       framed: false,
+                                       data: 'tags',
+                                       icon: 'tag',
+                                       label: mw.msg( 'tags-title' ),
+                                       title: mw.msg( 'rcfilters-view-tags-tooltip' )
+                               } )
+                       ]
+               } );
+
+               // Rearrange the UI so the select widget is at the right of the input
+               this.$element.append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-table' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-row' )
+                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
+                                               .append(
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
+                                                               .append( this.input.$element ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
+                                                               .append( this.viewsSelectWidget.$element )
+                                               )
+                               )
+               );
+
+               // Event
+               this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
+
+               rcFiltersRow.append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-cell' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
+                               .append( this.resetButton.$element )
+               );
+
+               // Build the content
+               $contentWrapper.append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
+                                               .append( title.$element ),
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
+                                               .append( this.savedQueryTitle.$element ),
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
+                                               .append(
+                                                       this.hideShowButton.$element
+                                               )
+                               ),
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-table' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
+                               .append( rcFiltersRow )
+               );
+
+               // Initialize
+               this.$handle.append( $contentWrapper );
+               this.emptyFilterMessage.toggle( this.isEmpty() );
+               this.savedQueryTitle.toggle( false );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
+
+               this.reevaluateResetRestoreState();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
+
+       /* Methods */
+
+       /**
+        * Override parent method to avoid unnecessary resize events.
+        */
+       FilterTagMultiselectWidget.prototype.updateIfHeightChanged = function () { };
+
+       /**
+        * Respond to view select widget choose event
+        *
+        * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
+        */
+       FilterTagMultiselectWidget.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget ) {
+               this.controller.switchView( buttonOptionWidget.getData() );
+               this.viewsSelectWidget.selectItem( null );
+               this.focus();
+       };
+
+       /**
+        * Respond to model search change event
+        *
+        * @param {string} value Search value
+        */
+       FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
+               this.input.setValue( value );
+       };
+
+       /**
+        * Respond to input change event
+        *
+        * @param {string} value Value of the input
+        */
+       FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
+               this.controller.setSearch( value );
+       };
+
+       /**
+        * Respond to query button click
+        */
+       FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
+               this.getMenu().toggle( false );
+       };
+
+       /**
+        * Respond to save query model initialization
+        */
+       FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
+               this.setSavedQueryVisibility();
+       };
+
+       /**
+        * Respond to save query item change. Mainly this is done to update the label in case
+        * a query item has been edited
+        *
+        * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
+        */
+       FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
+               if ( this.matchingQuery === item ) {
+                       // This means we just edited the item that is currently matched
+                       this.savedQueryTitle.setLabel( item.getLabel() );
+               }
+       };
+
+       /**
+        * Respond to menu toggle
+        *
+        * @param {boolean} isVisible Menu is visible
+        */
+       FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
+               // Parent
+               FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
+
+               if ( isVisible ) {
+                       this.focus();
+
+                       mw.hook( 'RcFilters.popup.open' ).fire();
+
+                       if ( !this.getMenu().findSelectedItem() ) {
+                               // If there are no selected items, scroll menu to top
+                               // This has to be in a setTimeout so the menu has time
+                               // to be positioned and fixed
+                               setTimeout(
+                                       function () {
+                                               this.getMenu().scrollToTop();
+                                       }.bind( this )
+                               );
+                       }
+               } else {
+                       // Clear selection
+                       this.selectTag( null );
+
+                       // Clear the search
+                       this.controller.setSearch( '' );
+
+                       // Log filter grouping
+                       this.controller.trackFilterGroupings( 'filtermenu' );
+
+                       this.blur();
+               }
+
+               this.input.setIcon( isVisible ? 'search' : 'menu' );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onInputFocus = function () {
+               // Parent
+               FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
+
+               // Only scroll to top of the viewport if:
+               // - The widget is more than 20px from the top
+               // - The widget is not above the top of the viewport (do not scroll downwards)
+               //   (This isn't represented because >20 is, anyways and always, bigger than 0)
+               this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.doInputEscape = function () {
+               // Parent
+               FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
+
+               // Blur the input
+               this.input.$input.trigger( 'blur' );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
+               if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
+                       this.menu.toggle();
+
+                       return false;
+               }
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onChangeTags = function () {
+               // If initialized, call parent method.
+               if ( this.controller.isInitialized() ) {
+                       FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
+               }
+
+               this.emptyFilterMessage.toggle( this.isEmpty() );
+       };
+
+       /**
+        * Respond to model initialize event
+        */
+       FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
+               this.setSavedQueryVisibility();
+       };
+
+       /**
+        * Respond to model update event
+        */
+       FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
+               this.updateElementsForView();
+       };
+
+       /**
+        * Update the elements in the widget to the current view
+        */
+       FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
+               var view = this.model.getCurrentView(),
+                       inputValue = this.input.getValue().trim(),
+                       inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
+
+               if ( inputView !== 'default' ) {
+                       // We have a prefix already, remove it
+                       inputValue = inputValue.substr( 1 );
+               }
+
+               if ( inputView !== view ) {
+                       // Add the correct prefix
+                       inputValue = this.model.getViewTrigger( view ) + inputValue;
+               }
+
+               // Update input
+               this.input.setValue( inputValue );
+
+               if ( this.currentView !== view ) {
+                       this.scrollToTop( this.$element );
+                       this.currentView = view;
+               }
+       };
+
+       /**
+        * Set the visibility of the saved query button
+        */
+       FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
+               if ( mw.user.isAnon() ) {
+                       return;
+               }
+
+               this.matchingQuery = this.controller.findQueryMatchingCurrentState();
+
+               this.savedQueryTitle.setLabel(
+                       this.matchingQuery ? this.matchingQuery.getLabel() : ''
+               );
+               this.savedQueryTitle.toggle( !!this.matchingQuery );
+               this.saveQueryButton.setDisabled( !!this.matchingQuery );
+               this.saveQueryButton.setTitle( !this.matchingQuery ?
+                       mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
+                       mw.msg( 'rcfilters-savedqueries-already-saved' ) );
+
+               if ( this.matchingQuery ) {
+                       this.emphasize();
+               }
+       };
+
+       /**
+        * Respond to model itemUpdate event
+        * fixme: when a new state is applied to the model this function is called 60+ times in a row
+        *
+        * @param {mw.rcfilters.dm.FilterItem} item Filter item model
+        */
+       FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
+               if ( !item.getGroupModel().isHidden() ) {
+                       if (
+                               item.isSelected() ||
+                               (
+                                       this.model.isHighlightEnabled() &&
+                                       item.getHighlightColor()
+                               )
+                       ) {
+                               this.addTag( item.getName(), item.getLabel() );
+                       } else {
+                               // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
+                               if ( this.findItemFromData( item.getName() ) !== null ) {
+                                       this.removeTagByData( item.getName() );
+                               }
+                       }
+               }
+
+               this.setSavedQueryVisibility();
+
+               // Re-evaluate reset state
+               this.reevaluateResetRestoreState();
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
+               return (
+                       this.model.getItemByName( data ) &&
+                       !this.isDuplicateData( data )
+               );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
+               this.controller.toggleFilterSelect( item.model.getName() );
+
+               // Select the tag if it exists, or reset selection otherwise
+               this.selectTag( this.findItemFromData( item.model.getName() ) );
+
+               this.focus();
+       };
+
+       /**
+        * Respond to highlightChange event
+        *
+        * @param {boolean} isHighlightEnabled Highlight is enabled
+        */
+       FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
+               var highlightedItems = this.model.getHighlightedItems();
+
+               if ( isHighlightEnabled ) {
+                       // Add capsule widgets
+                       highlightedItems.forEach( function ( filterItem ) {
+                               this.addTag( filterItem.getName(), filterItem.getLabel() );
+                       }.bind( this ) );
+               } else {
+                       // Remove capsule widgets if they're not selected
+                       highlightedItems.forEach( function ( filterItem ) {
+                               if ( !filterItem.isSelected() ) {
+                                       // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
+                                       if ( this.findItemFromData( filterItem.getName() ) !== null ) {
+                                               this.removeTagByData( filterItem.getName() );
+                                       }
+                               }
+                       }.bind( this ) );
+               }
+
+               this.setSavedQueryVisibility();
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
+               var menuOption = this.menu.getItemFromModel( tagItem.getModel() );
+
+               this.menu.setUserSelecting( true );
+               // Parent method
+               FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
+
+               // Switch view
+               this.controller.resetSearchForView( tagItem.getView() );
+
+               this.selectTag( tagItem );
+               this.scrollToTop( menuOption.$element );
+
+               this.menu.setUserSelecting( false );
+       };
+
+       /**
+        * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
+        * If no items are given, reset selection from all.
+        *
+        * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
+        *  omit to deselect all
+        */
+       FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
+               var i, len, selected;
+
+               for ( i = 0, len = this.items.length; i < len; i++ ) {
+                       selected = this.items[ i ] === item;
+                       if ( this.items[ i ].isSelected() !== selected ) {
+                               this.items[ i ].toggleSelected( selected );
+                       }
+               }
+       };
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
+               // Parent method
+               FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
+
+               this.controller.clearFilter( tagItem.getName() );
+
+               tagItem.destroy();
+       };
+
+       /**
+        * Respond to click event on the reset button
+        */
+       FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
+               if ( this.model.areVisibleFiltersEmpty() ) {
+                       // Reset to default filters
+                       this.controller.resetToDefaults();
+               } else {
+                       // Reset to have no filters
+                       this.controller.emptyFilters();
+               }
+       };
+
+       /**
+        * Respond to hide/show button click
+        */
+       FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
+               this.toggleCollapsed();
+       };
+
+       /**
+        * Toggle the collapsed state of the filters widget
+        *
+        * @param {boolean} isCollapsed Widget is collapsed
+        */
+       FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
+               isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;
+
+               this.collapsed = isCollapsed;
+
+               if ( isCollapsed ) {
+                       // If we are collapsing, close the menu, in case it was open
+                       // We should make sure the menu closes before the rest of the elements
+                       // are hidden, otherwise there is an unknown error in jQuery as ooui
+                       // sets and unsets properties on the input (which is hidden at that point)
+                       this.menu.toggle( false );
+               }
+               this.input.setDisabled( isCollapsed );
+               this.hideShowButton.setLabel( mw.msg(
+                       isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
+               ) );
+               this.hideShowButton.setTitle( mw.msg(
+                       isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
+               ) );
+
+               // Toggle the wrapper class, so we have min height values correctly throughout
+               this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );
+
+               // Save the state
+               this.controller.updateCollapsedState( isCollapsed );
+       };
+
+       /**
+        * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
+        */
+       FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
+               var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
+                       currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
+                       hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
+
+               this.resetButton.setIcon(
+                       currFiltersAreEmpty ? 'history' : 'trash'
+               );
+
+               this.resetButton.setLabel(
+                       currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
+               );
+               this.resetButton.setTitle(
+                       currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
+               );
+
+               this.resetButton.toggle( !hideResetButton );
+               this.emptyFilterMessage.toggle( currFiltersAreEmpty );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
+               return new MenuSelectWidget(
+                       this.controller,
+                       this.model,
+                       menuConfig
+               );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
+               var filterItem = this.model.getItemByName( data );
+
+               if ( filterItem ) {
+                       return new FilterTagItemWidget(
+                               this.controller,
+                               this.model,
+                               this.model.getInvertModel(),
+                               filterItem,
+                               {
+                                       $overlay: this.$overlay
+                               }
+                       );
+               }
+       };
+
+       FilterTagMultiselectWidget.prototype.emphasize = function () {
+               if (
+                       !this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
+               ) {
+                       this.$handle
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+
+                       setTimeout( function () {
+                               this.$handle
+                                       .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
+
+                               setTimeout( function () {
+                                       this.$handle
+                                               .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+                               }.bind( this ), 1000 );
+                       }.bind( this ), 500 );
+
+               }
+       };
+       /**
+        * Scroll the element to top within its container
+        *
+        * @private
+        * @param {jQuery} $element Element to position
+        * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
+        *  much space (in pixels) above the widget.
+        * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
+        * @param {number} [threshold.min] Minimum distance above the element
+        * @param {number} [threshold.max] Minimum distance below the element
+        */
+       FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
+               var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
+                       pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
+                       containerScrollTop = $( container ).scrollTop(),
+                       effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
+                       newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );
+
+               // Scroll to item
+               if (
+                       threshold === undefined ||
+                       (
+                               (
+                                       threshold.min === undefined ||
+                                       newScrollTop - containerScrollTop >= threshold.min
+                               ) &&
+                               (
+                                       threshold.max === undefined ||
+                                       newScrollTop - containerScrollTop <= threshold.max
+                               )
+                       )
+               ) {
+                       // eslint-disable-next-line jquery/no-animate
+                       $( container ).animate( {
+                               scrollTop: newScrollTop
+                       } );
+               }
+       };
+
+       module.exports = FilterTagMultiselectWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js
new file mode 100644 (file)
index 0000000..2674cc2
--- /dev/null
@@ -0,0 +1,139 @@
+( function () {
+       var FilterTagMultiselectWidget = require( './FilterTagMultiselectWidget.js' ),
+               LiveUpdateButtonWidget = require( './LiveUpdateButtonWidget.js' ),
+               ChangesLimitAndDateButtonWidget = require( './ChangesLimitAndDateButtonWidget.js' ),
+               FilterWrapperWidget;
+
+       /**
+        * List displaying all filter groups
+        *
+        * @class mw.rcfilters.ui.FilterWrapperWidget
+        * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.PendingElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+        * @param {Object} [config] Configuration object
+        * @cfg {Object} [filters] A definition of the filter groups in this list
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+        *  system. If not given, falls back to this widget's $element
+        * @cfg {boolean} [collapsed] Filter area is collapsed
+        */
+       FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
+               controller, model, savedQueriesModel, changesListModel, config
+       ) {
+               var $bottom;
+               config = config || {};
+
+               // Parent
+               FilterWrapperWidget.parent.call( this, config );
+               // Mixin constructors
+               OO.ui.mixin.PendingElement.call( this, config );
+
+               this.controller = controller;
+               this.model = model;
+               this.queriesModel = savedQueriesModel;
+               this.changesListModel = changesListModel;
+               this.$overlay = config.$overlay || this.$element;
+               this.$wrapper = config.$wrapper || this.$element;
+
+               this.filterTagWidget = new FilterTagMultiselectWidget(
+                       this.controller,
+                       this.model,
+                       this.queriesModel,
+                       {
+                               $overlay: this.$overlay,
+                               collapsed: config.collapsed,
+                               $wrapper: this.$wrapper
+                       }
+               );
+
+               this.liveUpdateButton = new LiveUpdateButtonWidget(
+                       this.controller,
+                       this.changesListModel
+               );
+
+               this.numChangesAndDateWidget = new ChangesLimitAndDateButtonWidget(
+                       this.controller,
+                       this.model,
+                       {
+                               $overlay: this.$overlay
+                       }
+               );
+
+               this.showNewChangesLink = new OO.ui.ButtonWidget( {
+                       icon: 'reload',
+                       framed: false,
+                       label: mw.msg( 'rcfilters-show-new-changes' ),
+                       flags: [ 'progressive' ],
+                       classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
+               } );
+
+               // Events
+               this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
+               this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
+               this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
+               this.showNewChangesLink.toggle( false );
+
+               // Initialize
+               this.$top = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
+
+               $bottom = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
+                       .append(
+                               this.showNewChangesLink.$element,
+                               this.numChangesAndDateWidget.$element
+                       );
+
+               if ( mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' ) ) {
+                       $bottom.prepend( this.liveUpdateButton.$element );
+               }
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
+                       .append(
+                               this.$top,
+                               this.filterTagWidget.$element,
+                               $bottom
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterWrapperWidget, OO.ui.Widget );
+       OO.mixinClass( FilterWrapperWidget, OO.ui.mixin.PendingElement );
+
+       /* Methods */
+
+       /**
+        * Set the content of the top section
+        *
+        * @param {jQuery} $topSectionElement
+        */
+       FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
+               this.$top.append( $topSectionElement );
+       };
+
+       /**
+        * Respond to the user clicking the 'show new changes' button
+        */
+       FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
+               this.controller.showNewChanges();
+       };
+
+       /**
+        * Respond to changes list model newChangesExist
+        *
+        * @param {boolean} newChangesExist Whether new changes exist
+        */
+       FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
+               this.showNewChangesLink.toggle( newChangesExist );
+       };
+
+       module.exports = FilterWrapperWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js
new file mode 100644 (file)
index 0000000..dbf1776
--- /dev/null
@@ -0,0 +1,176 @@
+( function () {
+       /**
+        * Wrapper for the RC form with hide/show links
+        * Must be constructed after the model is initialized.
+        *
+        * @class mw.rcfilters.ui.FormWrapperWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
+        * @param {mw.rcfilters.Controller} controller RCfilters controller
+        * @param {jQuery} $formRoot Root element of the form to attach to
+        * @param {Object} config Configuration object
+        */
+       var FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
+               config = config || {};
+
+               // Parent
+               FormWrapperWidget.parent.call( this, $.extend( {}, config, {
+                       $element: $formRoot
+               } ) );
+
+               this.changeListModel = changeListModel;
+               this.filtersModel = filtersModel;
+               this.controller = controller;
+               this.$submitButton = this.$element.find( 'form input[type=submit]' );
+
+               this.$element
+                       .on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );
+
+               this.$element
+                       .on( 'submit', 'form', this.onFormSubmit.bind( this ) );
+
+               // Events
+               this.changeListModel.connect( this, {
+                       invalidate: 'onChangesModelInvalidate',
+                       update: 'onChangesModelUpdate'
+               } );
+
+               // Initialize
+               this.cleanUpFieldset();
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FormWrapperWidget, OO.ui.Widget );
+
+       /**
+        * Respond to link click
+        *
+        * @param {jQuery.Event} e Event
+        * @return {boolean} false
+        */
+       FormWrapperWidget.prototype.onLinkClick = function ( e ) {
+               this.controller.updateChangesList( $( e.target ).data( 'params' ) );
+               return false;
+       };
+
+       /**
+        * Respond to form submit event
+        *
+        * @param {jQuery.Event} e Event
+        * @return {boolean} false
+        */
+       FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
+               var data = {};
+
+               // Collect all data from form
+               $( e.target ).find( 'input:not([type="hidden"],[type="submit"]), select' ).each( function () {
+                       var value = '';
+
+                       if ( !$( this ).is( ':checkbox' ) || $( this ).is( ':checked' ) ) {
+                               value = $( this ).val();
+                       }
+
+                       data[ $( this ).prop( 'name' ) ] = value;
+               } );
+
+               this.controller.updateChangesList( data );
+               return false;
+       };
+
+       /**
+        * Respond to model invalidate
+        */
+       FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
+               this.$submitButton.prop( 'disabled', true );
+       };
+
+       /**
+        * Respond to model update, replace the show/hide links with the ones from the
+        * server so they feature the correct state.
+        *
+        * @param {jQuery|string} $changesList Updated changes list
+        * @param {jQuery} $fieldset Updated fieldset
+        * @param {string} noResultsDetails Type of no result error
+        * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
+        */
+       FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
+               this.$submitButton.prop( 'disabled', false );
+
+               // Replace the entire fieldset
+               this.$element.empty().append( $fieldset.contents() );
+
+               if ( !isInitialDOM ) {
+                       // Make sure enhanced RC re-initializes correctly
+                       mw.hook( 'wikipage.content' ).fire( this.$element );
+               }
+
+               this.cleanUpFieldset();
+       };
+
+       /**
+        * Clean up the old-style show/hide that we have implemented in the filter list
+        */
+       FormWrapperWidget.prototype.cleanUpFieldset = function () {
+               this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
+                       // HACK: Remove the text node after the span.
+                       // If there isn't one, we're at the end, so remove the text node before the span.
+                       // This would be unnecessary if we added separators with CSS.
+                       if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
+                               this.parentNode.removeChild( this.nextSibling );
+                       } else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
+                               this.parentNode.removeChild( this.previousSibling );
+                       }
+                       // Remove the span itself
+                       this.parentNode.removeChild( this );
+               } );
+
+               // Hide namespaces and tags
+               this.$element.find( '.namespaceForm' ).detach();
+               this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
+
+               // Hide Related Changes page name form
+               this.$element.find( '.targetForm' ).detach();
+
+               // misc: limit, days, watchlist info msg
+               this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();
+
+               if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
+                       this.$element.find( '.mw-recentchanges-table' ).detach();
+                       this.$element.find( 'hr' ).detach();
+               }
+
+               // Get rid of all <br>s, which are inside rcshowhide
+               // If we still have content in rcshowhide, the <br>s are
+               // gone. Instead, the CSS now has a rule to mark all <span>s
+               // inside .rcshowhide with display:block; to simulate newlines
+               // where they're actually needed.
+               this.$element.find( 'br' ).detach();
+               if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
+                       this.$element.find( '.rcshowhide' ).detach();
+               }
+
+               if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
+                       this.$element.find( '.cloption-submit' ).detach();
+               }
+
+               this.$element.find(
+                       '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
+               ).detach();
+
+               // Get rid of the legend
+               this.$element.find( 'legend' ).detach();
+
+               // Check if the element is essentially empty, and detach it if it is
+               if ( !this.$element.text().trim().length ) {
+                       this.$element.detach();
+               }
+       };
+
+       module.exports = FormWrapperWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/GroupWidget.js b/resources/src/mediawiki.rcfilters/ui/GroupWidget.js
new file mode 100644 (file)
index 0000000..17c038e
--- /dev/null
@@ -0,0 +1,45 @@
+( function () {
+       /**
+        * A group widget to allow for aggregation of events
+        *
+        * @class mw.rcfilters.ui.GroupWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {Object} [config] Configuration object
+        * @param {Object} [events] Events to aggregate. The object represent the
+        *  event name to aggregate and the event value to emit on aggregate for items.
+        */
+       var GroupWidget = function MwRcfiltersUiViewSwitchWidget( config ) {
+               var aggregate = {};
+
+               config = config || {};
+
+               // Parent constructor
+               GroupWidget.parent.call( this, config );
+
+               // Mixin constructors
+               OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+               if ( config.events ) {
+                       // Aggregate events
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( config.events, function ( eventName, eventEmit ) {
+                               aggregate[ eventName ] = eventEmit;
+                       } );
+
+                       this.aggregate( aggregate );
+               }
+
+               if ( Array.isArray( config.items ) ) {
+                       this.addItems( config.items );
+               }
+       };
+
+       /* Initialize */
+
+       OO.inheritClass( GroupWidget, OO.ui.Widget );
+       OO.mixinClass( GroupWidget, OO.ui.mixin.GroupWidget );
+
+       module.exports = GroupWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js b/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js
new file mode 100644 (file)
index 0000000..cb5f8eb
--- /dev/null
@@ -0,0 +1,125 @@
+( function () {
+       /**
+        * A widget representing a filter item highlight color picker
+        *
+        * @class mw.rcfilters.ui.HighlightColorPickerWidget
+        * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.LabelElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {Object} [config] Configuration object
+        */
+       var HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
+               var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
+               config = config || {};
+
+               // Parent
+               HighlightColorPickerWidget.parent.call( this, config );
+               // Mixin constructors
+               OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
+                       label: mw.message( 'rcfilters-highlightmenu-title' ).text()
+               } ) );
+
+               this.controller = controller;
+
+               this.currentSelection = 'none';
+               this.buttonSelect = new OO.ui.ButtonSelectWidget( {
+                       items: colors.map( function ( color ) {
+                               return new OO.ui.ButtonOptionWidget( {
+                                       icon: color === 'none' ? 'check' : null,
+                                       data: color,
+                                       classes: [
+                                               'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
+                                               'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
+                                       ],
+                                       framed: false
+                               } );
+                       } ),
+                       classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
+               } );
+
+               // Event
+               this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
+                       .append(
+                               this.$label
+                                       .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
+                               this.buttonSelect.$element
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( HighlightColorPickerWidget, OO.ui.Widget );
+       OO.mixinClass( HighlightColorPickerWidget, OO.ui.mixin.LabelElement );
+
+       /* Events */
+
+       /**
+        * @event chooseColor
+        * @param {string} The chosen color
+        *
+        * A color has been chosen
+        */
+
+       /* Methods */
+
+       /**
+        * Bind the color picker to an item
+        * @param {mw.rcfilters.dm.FilterItem} filterItem
+        */
+       HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
+               if ( this.filterItem ) {
+                       this.filterItem.disconnect( this );
+               }
+
+               this.filterItem = filterItem;
+               this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
+               this.updateUiBasedOnModel();
+       };
+
+       /**
+        * Respond to item model update event
+        */
+       HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
+               this.selectColor( this.filterItem.getHighlightColor() || 'none' );
+       };
+
+       /**
+        * Select the color for this widget
+        *
+        * @param {string} color Selected color
+        */
+       HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
+               var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
+                       selectedItem = this.buttonSelect.findItemFromData( color );
+
+               if ( this.currentSelection !== color ) {
+                       this.currentSelection = color;
+
+                       this.buttonSelect.selectItem( selectedItem );
+                       if ( previousItem ) {
+                               previousItem.setIcon( null );
+                       }
+
+                       if ( selectedItem ) {
+                               selectedItem.setIcon( 'check' );
+                       }
+               }
+       };
+
+       HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
+               var color = button.data;
+               if ( color === 'none' ) {
+                       this.controller.clearHighlightColor( this.filterItem.getName() );
+               } else {
+                       this.controller.setHighlightColor( this.filterItem.getName(), color );
+               }
+               this.emit( 'chooseColor', color );
+       };
+
+       module.exports = HighlightColorPickerWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js
new file mode 100644 (file)
index 0000000..4c467df
--- /dev/null
@@ -0,0 +1,68 @@
+( function () {
+       var HighlightColorPickerWidget = require( './HighlightColorPickerWidget.js' ),
+               HighlightPopupWidget;
+       /**
+        * A popup containing a color picker, for setting highlight colors.
+        *
+        * @class mw.rcfilters.ui.HighlightPopupWidget
+        * @extends OO.ui.PopupWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {Object} [config] Configuration object
+        */
+       HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
+               config = config || {};
+
+               // Parent
+               HighlightPopupWidget.parent.call( this, $.extend( {
+                       autoClose: true,
+                       anchor: false,
+                       padded: true,
+                       align: 'backwards',
+                       horizontalPosition: 'end',
+                       width: 290
+               }, config ) );
+
+               this.colorPicker = new HighlightColorPickerWidget( controller );
+
+               this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
+
+               this.$body.append( this.colorPicker.$element );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget );
+
+       /* Methods */
+
+       /**
+        * Set the button (or other widget) that this popup should hang off.
+        *
+        * @param {OO.ui.Widget} widget Widget the popup should orient itself to
+        */
+       HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
+               this.setFloatableContainer( widget.$element );
+               this.$autoCloseIgnore = widget.$element;
+       };
+
+       /**
+        * Set the filter item that this popup should control the highlight color for.
+        *
+        * @param {mw.rcfilters.dm.FilterItem} item
+        */
+       HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
+               this.colorPicker.setFilterItem( item );
+       };
+
+       /**
+        * When the user chooses a color in the color picker, close the popup.
+        */
+       HighlightPopupWidget.prototype.onChooseColor = function () {
+               this.toggle( false );
+       };
+
+       module.exports = HighlightPopupWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js
new file mode 100644 (file)
index 0000000..56ed628
--- /dev/null
@@ -0,0 +1,172 @@
+( function () {
+       var FilterItemHighlightButton = require( './FilterItemHighlightButton.js' ),
+               CheckboxInputWidget = require( './CheckboxInputWidget.js' ),
+               ItemMenuOptionWidget;
+
+       /**
+        * A widget representing a base toggle item
+        *
+        * @class mw.rcfilters.ui.ItemMenuOptionWidget
+        * @extends OO.ui.MenuOptionWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+        * @param {mw.rcfilters.dm.ItemModel} invertModel
+        * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
+        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
+        * @param {Object} config Configuration object
+        */
+       ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
+               controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
+       ) {
+               var layout,
+                       classes = [],
+                       $label = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
+
+               config = config || {};
+
+               this.controller = controller;
+               this.filtersViewModel = filtersViewModel;
+               this.invertModel = invertModel;
+               this.itemModel = itemModel;
+
+               // Parent
+               ItemMenuOptionWidget.parent.call( this, $.extend( {
+                       // Override the 'check' icon that OOUI defines
+                       icon: '',
+                       data: this.itemModel.getName(),
+                       label: this.itemModel.getLabel()
+               }, config ) );
+
+               this.checkboxWidget = new CheckboxInputWidget( {
+                       value: this.itemModel.getName(),
+                       selected: this.itemModel.isSelected()
+               } );
+
+               $label.append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
+                               .append( $( '<bdi>' ).append( this.$label ) )
+               );
+               if ( this.itemModel.getDescription() ) {
+                       $label.append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
+                                       .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
+                       );
+               }
+
+               this.highlightButton = new FilterItemHighlightButton(
+                       this.controller,
+                       this.itemModel,
+                       highlightPopup,
+                       {
+                               $overlay: config.$overlay || this.$element,
+                               title: mw.msg( 'rcfilters-highlightmenu-help' )
+                       }
+               );
+               this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
+
+               this.excludeLabel = new OO.ui.LabelWidget( {
+                       label: mw.msg( 'rcfilters-filter-excluded' )
+               } );
+               this.excludeLabel.toggle(
+                       this.itemModel.getGroupModel().getView() === 'namespaces' &&
+                       this.itemModel.isSelected() &&
+                       this.invertModel.isSelected()
+               );
+
+               layout = new OO.ui.FieldLayout( this.checkboxWidget, {
+                       label: $label,
+                       align: 'inline'
+               } );
+
+               // Events
+               this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
+               this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
+               this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
+               // HACK: Prevent defaults on 'click' for the label so it
+               // doesn't steal the focus away from the input. This means
+               // we can continue arrow-movement after we click the label
+               // and is consistent with the checkbox *itself* also preventing
+               // defaults on 'click' as well.
+               layout.$label.on( 'click', false );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
+                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView() )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
+                                                                       .append( layout.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
+                                                                       .append( this.excludeLabel.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
+                                                                       .append( this.highlightButton.$element )
+                                                       )
+                                       )
+                       );
+
+               if ( this.itemModel.getIdentifiers() ) {
+                       this.itemModel.getIdentifiers().forEach( function ( ident ) {
+                               classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
+                       } );
+
+                       this.$element.addClass( classes );
+               }
+
+               this.updateUiBasedOnState();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
+
+       /* Static properties */
+
+       // We do our own scrolling to top
+       ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+
+       /* Methods */
+
+       /**
+        * Respond to item model update event
+        */
+       ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+               this.checkboxWidget.setSelected( this.itemModel.isSelected() );
+
+               this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
+               this.excludeLabel.toggle(
+                       this.itemModel.getGroupModel().getView() === 'namespaces' &&
+                       this.itemModel.isSelected() &&
+                       this.invertModel.isSelected()
+               );
+               this.toggle( this.itemModel.isVisible() );
+       };
+
+       /**
+        * Get the name of this filter
+        *
+        * @return {string} Filter name
+        */
+       ItemMenuOptionWidget.prototype.getName = function () {
+               return this.itemModel.getName();
+       };
+
+       ItemMenuOptionWidget.prototype.getModel = function () {
+               return this.itemModel;
+       };
+
+       module.exports = ItemMenuOptionWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js
new file mode 100644 (file)
index 0000000..3ccb6e2
--- /dev/null
@@ -0,0 +1,72 @@
+( function () {
+       /**
+        * Widget for toggling live updates
+        *
+        * @class mw.rcfilters.ui.LiveUpdateButtonWidget
+        * @extends OO.ui.ToggleButtonWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+        * @param {Object} [config] Configuration object
+        */
+       var LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
+               config = config || {};
+
+               // Parent
+               LiveUpdateButtonWidget.parent.call( this, $.extend( {
+                       label: mw.message( 'rcfilters-liveupdates-button' ).text()
+               }, config ) );
+
+               this.controller = controller;
+               this.model = changesListModel;
+
+               // Events
+               this.connect( this, { click: 'onClick' } );
+               this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
+
+               this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
+
+               this.setState( false );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
+
+       /* Methods */
+
+       /**
+        * Respond to the button being clicked
+        */
+       LiveUpdateButtonWidget.prototype.onClick = function () {
+               this.controller.toggleLiveUpdate();
+       };
+
+       /**
+        * Set the button's state and change its appearance
+        *
+        * @param {boolean} enable Whether the 'live update' feature is now on/off
+        */
+       LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
+               this.setValue( enable );
+               this.setIcon( enable ? 'stop' : 'play' );
+               this.setTitle( mw.message(
+                       enable ?
+                               'rcfilters-liveupdates-button-title-on' :
+                               'rcfilters-liveupdates-button-title-off'
+               ).text() );
+       };
+
+       /**
+        * Respond to the 'live update' feature being turned on/off
+        *
+        * @param {boolean} enable Whether the 'live update' feature is now on/off
+        */
+       LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
+               this.setState( enable );
+       };
+
+       module.exports = LiveUpdateButtonWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js
new file mode 100644 (file)
index 0000000..bc1cac8
--- /dev/null
@@ -0,0 +1,142 @@
+( function () {
+       var SavedLinksListWidget = require( './SavedLinksListWidget.js' ),
+               FilterWrapperWidget = require( './FilterWrapperWidget.js' ),
+               ChangesListWrapperWidget = require( './ChangesListWrapperWidget.js' ),
+               RcTopSectionWidget = require( './RcTopSectionWidget.js' ),
+               RclTopSectionWidget = require( './RclTopSectionWidget.js' ),
+               WatchlistTopSectionWidget = require( './WatchlistTopSectionWidget.js' ),
+               FormWrapperWidget = require( './FormWrapperWidget.js' ),
+               MainWrapperWidget;
+
+       /**
+        * Wrapper for changes list content
+        *
+        * @class mw.rcfilters.ui.MainWrapperWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+        * @param {Object} config Configuration object
+        * @cfg {jQuery} $topSection Top section container
+        * @cfg {jQuery} $filtersContainer
+        * @cfg {jQuery} $changesListContainer
+        * @cfg {jQuery} $formContainer
+        * @cfg {boolean} [collapsed] Filter area is collapsed
+        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+        *  system. If not given, falls back to this widget's $element
+        */
+       MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
+               controller, model, savedQueriesModel, changesListModel, config
+       ) {
+               config = $.extend( {}, config );
+
+               // Parent
+               MainWrapperWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = model;
+               this.changesListModel = changesListModel;
+               this.$topSection = config.$topSection;
+               this.$filtersContainer = config.$filtersContainer;
+               this.$changesListContainer = config.$changesListContainer;
+               this.$formContainer = config.$formContainer;
+               this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay' );
+               this.$wrapper = config.$wrapper || this.$element;
+
+               this.savedLinksListWidget = new SavedLinksListWidget(
+                       controller, savedQueriesModel, { $overlay: this.$overlay }
+               );
+
+               this.filtersWidget = new FilterWrapperWidget(
+                       controller,
+                       model,
+                       savedQueriesModel,
+                       changesListModel,
+                       {
+                               $overlay: this.$overlay,
+                               $wrapper: this.$wrapper,
+                               collapsed: config.collapsed
+                       }
+               );
+
+               this.changesListWidget = new ChangesListWrapperWidget(
+                       model, changesListModel, controller, this.$changesListContainer );
+
+               /* Events */
+
+               // Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
+               // to prevent users from accidentally clicking on links in results, while menu is opened.
+               // Overlay on changes list is not the same as this.$overlay
+               this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );
+
+               // Initialize
+               this.$filtersContainer.append( this.filtersWidget.$element );
+               $( 'body' )
+                       .append( this.$overlay )
+                       .addClass( 'mw-rcfilters-ui-initialized' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( MainWrapperWidget, OO.ui.Widget );
+
+       /* Methods */
+
+       /**
+        * Set the content of the top section, depending on the type of special page.
+        *
+        * @param {string} specialPage
+        */
+       MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
+               var topSection;
+
+               if ( specialPage === 'Recentchanges' ) {
+                       topSection = new RcTopSectionWidget(
+                               this.savedLinksListWidget, this.$topSection
+                       );
+                       this.filtersWidget.setTopSection( topSection.$element );
+               }
+
+               if ( specialPage === 'Recentchangeslinked' ) {
+                       topSection = new RclTopSectionWidget(
+                               this.savedLinksListWidget, this.controller,
+                               this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
+                               this.model.getGroup( 'page' ).getItemByParamName( 'target' )
+                       );
+
+                       this.filtersWidget.setTopSection( topSection.$element );
+               }
+
+               if ( specialPage === 'Watchlist' ) {
+                       topSection = new WatchlistTopSectionWidget(
+                               this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
+                       );
+
+                       this.filtersWidget.setTopSection( topSection.$element );
+               }
+       };
+
+       /**
+        * Filter menu toggle event listener
+        *
+        * @param {boolean} isVisible
+        */
+       MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
+               this.changesListWidget.toggleOverlay( isVisible );
+       };
+
+       /**
+        * Initialize FormWrapperWidget
+        *
+        * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
+        */
+       MainWrapperWidget.prototype.initFormWidget = function () {
+               return new FormWrapperWidget(
+                       this.model, this.changesListModel, this.controller, this.$formContainer );
+       };
+
+       module.exports = MainWrapperWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js
new file mode 100644 (file)
index 0000000..3914337
--- /dev/null
@@ -0,0 +1,58 @@
+( function () {
+       /**
+        * Button for marking all changes as seen on the Watchlist
+        *
+        * @class mw.rcfilters.ui.MarkSeenButtonWidget
+        * @extends OO.ui.ButtonWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
+        * @param {Object} [config] Configuration object
+        */
+       var MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
+               config = config || {};
+
+               // Parent
+               MarkSeenButtonWidget.parent.call( this, $.extend( {
+                       label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
+                       icon: 'checkAll'
+               }, config ) );
+
+               this.controller = controller;
+               this.model = model;
+
+               // Events
+               this.connect( this, { click: 'onClick' } );
+               this.model.connect( this, { update: 'onModelUpdate' } );
+
+               this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );
+
+               this.onModelUpdate();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( MarkSeenButtonWidget, OO.ui.ButtonWidget );
+
+       /* Methods */
+
+       /**
+        * Respond to the button being clicked
+        */
+       MarkSeenButtonWidget.prototype.onClick = function () {
+               this.controller.markAllChangesAsSeen();
+               // assume there's no more unseen changes until the next model update
+               this.setDisabled( true );
+       };
+
+       /**
+        * Respond to the model being updated with new changes
+        */
+       MarkSeenButtonWidget.prototype.onModelUpdate = function () {
+               this.setDisabled( !this.model.hasUnseenWatchedChanges() );
+       };
+
+       module.exports = MarkSeenButtonWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js b/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js
new file mode 100644 (file)
index 0000000..c352f5a
--- /dev/null
@@ -0,0 +1,368 @@
+( function () {
+       var FilterMenuHeaderWidget = require( './FilterMenuHeaderWidget.js' ),
+               HighlightPopupWidget = require( './HighlightPopupWidget.js' ),
+               FilterMenuSectionOptionWidget = require( './FilterMenuSectionOptionWidget.js' ),
+               FilterMenuOptionWidget = require( './FilterMenuOptionWidget.js' ),
+               MenuSelectWidget;
+
+       /**
+        * A floating menu widget for the filter list
+        *
+        * @class mw.rcfilters.ui.MenuSelectWidget
+        * @extends OO.ui.MenuSelectWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {Object} [config] Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        * @cfg {Object[]} [footers] An array of objects defining the footers for
+        *  this menu, with a definition whether they appear per specific views.
+        *  The expected structure is:
+        *  [
+        *     {
+        *        name: {string} A unique name for the footer object
+        *        $element: {jQuery} A jQuery object for the content of the footer
+        *        views: {string[]} Optional. An array stating which views this footer is
+        *               active on. Use null or omit to display this on all views.
+        *     }
+        *  ]
+        */
+       MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
+               var header;
+
+               config = config || {};
+
+               this.controller = controller;
+               this.model = model;
+               this.currentView = '';
+               this.views = {};
+               this.userSelecting = false;
+
+               this.menuInitialized = false;
+               this.$overlay = config.$overlay || this.$element;
+               this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
+               this.footers = [];
+
+               // Parent
+               MenuSelectWidget.parent.call( this, $.extend( config, {
+                       $autoCloseIgnore: this.$overlay,
+                       width: 650,
+                       // Our filtering is done through the model
+                       filterFromInput: false
+               } ) );
+               this.setGroupElement(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
+               );
+               this.setClippableElement( this.$body );
+               this.setClippableContainer( this.$element );
+
+               header = new FilterMenuHeaderWidget(
+                       this.controller,
+                       this.model,
+                       {
+                               $overlay: this.$overlay
+                       }
+               );
+
+               this.noResults = new OO.ui.LabelWidget( {
+                       label: mw.msg( 'rcfilters-filterlist-noresults' ),
+                       classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
+               } );
+
+               // Events
+               this.model.connect( this, {
+                       initialize: 'onModelInitialize',
+                       searchChange: 'onModelSearchChange'
+               } );
+
+               // Initialization
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
+                       .append( header.$element )
+                       .append(
+                               this.$body
+                                       .append( this.$group, this.noResults.$element )
+                       );
+
+               // Append all footers; we will control their visibility
+               // based on view
+               config.footers = config.footers || [];
+               config.footers.forEach( function ( footerData ) {
+                       var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
+                               adjustedData = {
+                                       // Wrap the element with our own footer wrapper
+                                       $element: $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
+                                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
+                                               .append( footerData.$element ),
+                                       views: footerData.views
+                               };
+
+                       if ( !footerData.disabled ) {
+                               this.footers.push( adjustedData );
+
+                               if ( isSticky ) {
+                                       this.$element.append( adjustedData.$element );
+                               } else {
+                                       this.$body.append( adjustedData.$element );
+                               }
+                       }
+               }.bind( this ) );
+
+               // Switch to the correct view
+               this.updateView();
+       };
+
+       /* Initialize */
+
+       OO.inheritClass( MenuSelectWidget, OO.ui.MenuSelectWidget );
+
+       /* Events */
+
+       /* Methods */
+       MenuSelectWidget.prototype.onModelSearchChange = function () {
+               this.updateView();
+       };
+
+       /**
+        * @inheritdoc
+        */
+       MenuSelectWidget.prototype.toggle = function ( show ) {
+               this.lazyMenuCreation();
+               MenuSelectWidget.parent.prototype.toggle.call( this, show );
+               // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
+               this.setVerticalPosition( 'below' );
+       };
+
+       /**
+        * lazy creation of the menu
+        */
+       MenuSelectWidget.prototype.lazyMenuCreation = function () {
+               var widget = this,
+                       items = [],
+                       viewGroupCount = {},
+                       groups = this.model.getFilterGroups();
+
+               if ( this.menuInitialized ) {
+                       return;
+               }
+
+               this.menuInitialized = true;
+
+               // Create shared popup for highlight buttons
+               this.highlightPopup = new HighlightPopupWidget( this.controller );
+               this.$overlay.append( this.highlightPopup.$element );
+
+               // Count groups per view
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( groups, function ( groupName, groupModel ) {
+                       if ( !groupModel.isHidden() ) {
+                               viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
+                               viewGroupCount[ groupModel.getView() ]++;
+                       }
+               } );
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( groups, function ( groupName, groupModel ) {
+                       var currentItems = [],
+                               view = groupModel.getView();
+
+                       if ( !groupModel.isHidden() ) {
+                               if ( viewGroupCount[ view ] > 1 ) {
+                                       // Only add a section header if there is more than
+                                       // one group
+                                       currentItems.push(
+                                               // Group section
+                                               new FilterMenuSectionOptionWidget(
+                                                       widget.controller,
+                                                       groupModel,
+                                                       {
+                                                               $overlay: widget.$overlay
+                                                       }
+                                               )
+                                       );
+                               }
+
+                               // Add items
+                               widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
+                                       currentItems.push(
+                                               new FilterMenuOptionWidget(
+                                                       widget.controller,
+                                                       widget.model,
+                                                       widget.model.getInvertModel(),
+                                                       filterItem,
+                                                       widget.highlightPopup,
+                                                       {
+                                                               $overlay: widget.$overlay
+                                                       }
+                                               )
+                                       );
+                               } );
+
+                               // Cache the items per view, so we can switch between them
+                               // without rebuilding the widgets each time
+                               widget.views[ view ] = widget.views[ view ] || [];
+                               widget.views[ view ] = widget.views[ view ].concat( currentItems );
+                               items = items.concat( currentItems );
+                       }
+               } );
+
+               this.addItems( items );
+               this.updateView();
+       };
+
+       /**
+        * Respond to model initialize event. Populate the menu from the model
+        */
+       MenuSelectWidget.prototype.onModelInitialize = function () {
+               this.menuInitialized = false;
+               // Set timeout for the menu to lazy build.
+               setTimeout( this.lazyMenuCreation.bind( this ) );
+       };
+
+       /**
+        * Update view
+        */
+       MenuSelectWidget.prototype.updateView = function () {
+               var viewName = this.model.getCurrentView();
+
+               if ( this.views[ viewName ] && this.currentView !== viewName ) {
+                       this.updateFooterVisibility( viewName );
+
+                       this.$element
+                               .data( 'view', viewName )
+                               .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
+                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
+
+                       this.currentView = viewName;
+                       this.scrollToTop();
+               }
+
+               this.postProcessItems();
+               this.clip();
+       };
+
+       /**
+        * Go over the available footers and decide which should be visible
+        * for this view
+        *
+        * @param {string} [currentView] Current view
+        */
+       MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
+               currentView = currentView || this.model.getCurrentView();
+
+               this.footers.forEach( function ( data ) {
+                       data.$element.toggle(
+                               // This footer should only be shown if it is configured
+                               // for all views or for this specific view
+                               !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
+                       );
+               } );
+       };
+
+       /**
+        * Post-process items after the visibility changed. Make sure
+        * that we always have an item selected, and that the no-results
+        * widget appears if the menu is empty.
+        */
+       MenuSelectWidget.prototype.postProcessItems = function () {
+               var i,
+                       itemWasSelected = false,
+                       items = this.getItems();
+
+               // If we are not already selecting an item, always make sure
+               // that the top item is selected
+               if ( !this.userSelecting ) {
+                       // Select the first item in the list
+                       for ( i = 0; i < items.length; i++ ) {
+                               if (
+                                       !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
+                                       items[ i ].isVisible()
+                               ) {
+                                       itemWasSelected = true;
+                                       this.selectItem( items[ i ] );
+                                       break;
+                               }
+                       }
+
+                       if ( !itemWasSelected ) {
+                               this.selectItem( null );
+                       }
+               }
+
+               this.noResults.toggle( !this.getItems().some( function ( item ) {
+                       return item.isVisible();
+               } ) );
+       };
+
+       /**
+        * Get the option widget that matches the model given
+        *
+        * @param {mw.rcfilters.dm.ItemModel} model Item model
+        * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
+        */
+       MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
+               this.lazyMenuCreation();
+               return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
+                       return item.getName() === model.getName();
+               } )[ 0 ];
+       };
+
+       /**
+        * @inheritdoc
+        */
+       MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
+               var nextItem,
+                       currentItem = this.findHighlightedItem() || this.findSelectedItem();
+
+               // Call parent
+               MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
+
+               // We want to select the item on arrow movement
+               // rather than just highlight it, like the menu
+               // does by default
+               if ( !this.isDisabled() && this.isVisible() ) {
+                       switch ( e.keyCode ) {
+                               case OO.ui.Keys.UP:
+                               case OO.ui.Keys.LEFT:
+                                       // Get the next item
+                                       nextItem = this.findRelativeSelectableItem( currentItem, -1 );
+                                       break;
+                               case OO.ui.Keys.DOWN:
+                               case OO.ui.Keys.RIGHT:
+                                       // Get the next item
+                                       nextItem = this.findRelativeSelectableItem( currentItem, 1 );
+                                       break;
+                       }
+
+                       nextItem = nextItem && nextItem.constructor.static.selectable ?
+                               nextItem : null;
+
+                       // Select the next item
+                       this.selectItem( nextItem );
+               }
+       };
+
+       /**
+        * Scroll to the top of the menu
+        */
+       MenuSelectWidget.prototype.scrollToTop = function () {
+               this.$body.scrollTop( 0 );
+       };
+
+       /**
+        * Set whether the user is currently selecting an item.
+        * This is important when the user selects an item that is in between
+        * different views, and makes sure we do not re-select a different
+        * item (like the item on top) when this is happening.
+        *
+        * @param {boolean} isSelecting User is selecting
+        */
+       MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
+               this.userSelecting = !!isSelecting;
+       };
+
+       module.exports = MenuSelectWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js
new file mode 100644 (file)
index 0000000..6de9c40
--- /dev/null
@@ -0,0 +1,116 @@
+( function () {
+       /**
+        * Top section (between page title and filters) on Special:Recentchanges
+        *
+        * @class mw.rcfilters.ui.RcTopSectionWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+        * @param {jQuery} $topLinks Content of the community-defined links
+        * @param {Object} [config] Configuration object
+        */
+       var RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
+               savedLinksListWidget, $topLinks, config
+       ) {
+               var toplinksTitle,
+                       topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
+                       topLinksCookie = mw.cookie.get( topLinksCookieName ),
+                       topLinksCookieValue = topLinksCookie || 'collapsed',
+                       widget = this;
+
+               config = config || {};
+
+               // Parent
+               RcTopSectionWidget.parent.call( this, config );
+
+               this.$topLinks = $topLinks;
+
+               toplinksTitle = new OO.ui.ButtonWidget( {
+                       framed: false,
+                       indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
+                       flags: [ 'progressive' ],
+                       label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
+               } );
+
+               this.$topLinks
+                       .makeCollapsible( {
+                               collapsed: topLinksCookieValue === 'collapsed',
+                               $customTogglers: toplinksTitle.$element
+                       } )
+                       .on( 'beforeExpand.mw-collapsible', function () {
+                               mw.cookie.set( topLinksCookieName, 'expanded' );
+                               toplinksTitle.setIndicator( 'up' );
+                               widget.switchTopLinks( 'expanded' );
+                       } )
+                       .on( 'beforeCollapse.mw-collapsible', function () {
+                               mw.cookie.set( topLinksCookieName, 'collapsed' );
+                               toplinksTitle.setIndicator( 'down' );
+                               widget.switchTopLinks( 'collapsed' );
+                       } );
+
+               this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
+                       .replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );
+
+               // Create two positions for the toplinks to toggle between
+               // in the table (first cell) or up above it
+               this.$top = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
+               this.$tableTopLinks = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-cell' )
+                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               this.$tableTopLinks,
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-table-placeholder' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' ),
+                                                               !mw.user.isAnon() ?
+                                                                       $( '<div>' )
+                                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                                               .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
+                                                                               .append( savedLinksListWidget.$element ) :
+                                                                       null
+                                                       )
+                                       )
+                       );
+
+               // Hack: For jumpiness reasons, this should be a sibling of -head
+               $( '.rcfilters-head' ).before( this.$top );
+
+               // Initialize top links position
+               widget.switchTopLinks( topLinksCookieValue );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( RcTopSectionWidget, OO.ui.Widget );
+
+       /**
+        * Switch the top links widget from inside the table (when collapsed)
+        * to the 'top' (when open)
+        *
+        * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
+        */
+       RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
+               state = state || 'expanded';
+
+               if ( state === 'expanded' ) {
+                       this.$top.append( this.$topLinks );
+               } else {
+                       this.$tableTopLinks.append( this.$topLinks );
+               }
+               this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
+       };
+
+       module.exports = RcTopSectionWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js b/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js
new file mode 100644 (file)
index 0000000..6eb0d5b
--- /dev/null
@@ -0,0 +1,82 @@
+( function () {
+       /**
+        * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
+        *
+        * @class mw.rcfilters.ui.RclTargetPageWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FilterItem} targetPageModel
+        * @param {Object} [config] Configuration object
+        */
+       var RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
+               controller, targetPageModel, config
+       ) {
+               config = config || {};
+
+               // Parent
+               RclTargetPageWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = targetPageModel;
+
+               this.titleSearch = new mw.widgets.TitleInputWidget( {
+                       validate: false,
+                       placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
+                       showImages: true,
+                       showDescriptions: true,
+                       addQueryInput: false
+               } );
+
+               // Events
+               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+
+               this.titleSearch.$input.on( {
+                       blur: this.onLookupInputBlur.bind( this )
+               } );
+
+               this.titleSearch.lookupMenu.connect( this, {
+                       choose: 'onLookupMenuItemChoose'
+               } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
+                       .append( this.titleSearch.$element );
+
+               this.updateUiBasedOnModel();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( RclTargetPageWidget, OO.ui.Widget );
+
+       /* Methods */
+
+       /**
+        * Respond to the user choosing a title
+        */
+       RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
+               this.titleSearch.$input.trigger( 'blur' );
+       };
+
+       /**
+        * Respond to titleSearch $input blur
+        */
+       RclTargetPageWidget.prototype.onLookupInputBlur = function () {
+               this.controller.setTargetPage( this.titleSearch.getQueryValue() );
+       };
+
+       /**
+        * Respond to the model being updated
+        */
+       RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
+               var title = mw.Title.newFromText( this.model.getValue() ),
+                       text = title ? title.toText() : this.model.getValue();
+               this.titleSearch.setValue( text );
+               this.titleSearch.setTitle( text );
+       };
+
+       module.exports = RclTargetPageWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js b/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js
new file mode 100644 (file)
index 0000000..e2c58d0
--- /dev/null
@@ -0,0 +1,76 @@
+( function () {
+       /**
+        * Widget to select to view changes that link TO or FROM the target page
+        * on Special:RecentChangesLinked (AKA Related Changes)
+        *
+        * @class mw.rcfilters.ui.RclToOrFromWidget
+        * @extends OO.ui.DropdownWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
+        * @param {Object} [config] Configuration object
+        */
+       var RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
+               controller, showLinkedToModel, config
+       ) {
+               config = config || {};
+
+               this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
+                       data: 'from', // default (showlinkedto=0)
+                       label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
+               } );
+               this.showLinkedTo = new OO.ui.MenuOptionWidget( {
+                       data: 'to', // showlinkedto=1
+                       label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
+               } );
+
+               // Parent
+               RclToOrFromWidget.parent.call( this, $.extend( {
+                       classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
+                       menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
+               }, config ) );
+
+               this.controller = controller;
+               this.model = showLinkedToModel;
+
+               this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
+               this.model.connect( this, { update: 'onModelUpdate' } );
+
+               // force an initial update of the component based on the state
+               this.onModelUpdate();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget );
+
+       /* Methods */
+
+       /**
+        * Respond to the user choosing an item in the menu
+        *
+        * @param {OO.ui.MenuOptionWidget} chosenItem
+        */
+       RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
+               this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
+       };
+
+       /**
+        * Respond to model update
+        */
+       RclToOrFromWidget.prototype.onModelUpdate = function () {
+               this.getMenu().selectItem(
+                       this.model.isSelected() ?
+                               this.showLinkedTo :
+                               this.showLinkedFrom
+               );
+               this.setLabel( mw.msg(
+                       this.model.isSelected() ?
+                               'rcfilters-filter-showlinkedto-label' :
+                               'rcfilters-filter-showlinkedfrom-label'
+               ) );
+       };
+
+       module.exports = RclToOrFromWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js
new file mode 100644 (file)
index 0000000..d968b9e
--- /dev/null
@@ -0,0 +1,73 @@
+( function () {
+       var RclToOrFromWidget = require( './RclToOrFromWidget.js' ),
+               RclTargetPageWidget = require( './RclTargetPageWidget.js' ),
+               RclTopSectionWidget;
+
+       /**
+        * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
+        *
+        * @class mw.rcfilters.ui.RclTopSectionWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
+        * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
+        * @param {Object} [config] Configuration object
+        */
+       RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
+               savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
+       ) {
+               var toOrFromWidget,
+                       targetPage;
+               config = config || {};
+
+               // Parent
+               RclTopSectionWidget.parent.call( this, config );
+
+               this.controller = controller;
+
+               toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel );
+               targetPage = new RclTargetPageWidget( controller, targetPageModel );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .append( toOrFromWidget.$element )
+                                                       ),
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .append( targetPage.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-table-placeholder' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' ),
+                                                               !mw.user.isAnon() ?
+                                                                       $( '<div>' )
+                                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                                               .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
+                                                                               .append( savedLinksListWidget.$element ) :
+                                                                       null
+                                                       )
+                                       )
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( RclTopSectionWidget, OO.ui.Widget );
+
+       module.exports = RclTopSectionWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js
new file mode 100644 (file)
index 0000000..8c3d550
--- /dev/null
@@ -0,0 +1,191 @@
+( function () {
+       /**
+        * Save filters widget. This widget is displayed in the tag area
+        * and allows the user to save the current state of the system
+        * as a new saved filter query they can later load or set as
+        * default.
+        *
+        * @class mw.rcfilters.ui.SaveFiltersPopupButtonWidget
+        * @extends OO.ui.PopupButtonWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
+        * @param {Object} [config] Configuration object
+        */
+       var SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
+               var layout,
+                       checkBoxLayout,
+                       $popupContent = $( '<div>' );
+
+               config = config || {};
+
+               this.controller = controller;
+               this.model = model;
+
+               // Parent
+               SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
+                       framed: false,
+                       icon: 'bookmark',
+                       title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
+                       popup: {
+                               classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
+                               padded: true,
+                               head: true,
+                               label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
+                               $content: $popupContent
+                       }
+               }, config ) );
+               // // HACK: Add an icon to the popup head label
+               this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
+
+               this.input = new OO.ui.TextInputWidget( {
+                       placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
+               } );
+               layout = new OO.ui.FieldLayout( this.input, {
+                       label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
+                       align: 'top'
+               } );
+
+               this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
+               checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
+                       label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
+                       align: 'inline'
+               } );
+
+               this.applyButton = new OO.ui.ButtonWidget( {
+                       label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
+                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
+                       flags: [ 'primary', 'progressive' ]
+               } );
+               this.cancelButton = new OO.ui.ButtonWidget( {
+                       label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
+                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
+               } );
+
+               $popupContent
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
+                                       .append( layout.$element ),
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
+                                       .append( checkBoxLayout.$element ),
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
+                                       .append(
+                                               this.cancelButton.$element,
+                                               this.applyButton.$element
+                                       )
+                       );
+
+               // Events
+               this.popup.connect( this, {
+                       ready: 'onPopupReady'
+               } );
+               this.input.connect( this, {
+                       change: 'onInputChange',
+                       enter: 'onInputEnter'
+               } );
+               this.input.$input.on( {
+                       keyup: this.onInputKeyup.bind( this )
+               } );
+               this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
+               this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
+               this.applyButton.connect( this, { click: 'onApplyButtonClick' } );
+
+               // Initialize
+               this.applyButton.setDisabled( !this.input.getValue() );
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
+       };
+
+       /* Initialization */
+       OO.inheritClass( SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );
+
+       /**
+        * Respond to input enter event
+        */
+       SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
+               this.apply();
+       };
+
+       /**
+        * Respond to input change event
+        *
+        * @param {string} value Input value
+        */
+       SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
+               value = value.trim();
+
+               this.applyButton.setDisabled( !value );
+       };
+
+       /**
+        * Respond to input keyup event, this is the way to intercept 'escape' key
+        *
+        * @param {jQuery.Event} e Event data
+        * @return {boolean} false
+        */
+       SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
+               if ( e.which === OO.ui.Keys.ESCAPE ) {
+                       this.popup.toggle( false );
+                       return false;
+               }
+       };
+
+       /**
+        * Respond to popup ready event
+        */
+       SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
+               this.input.focus();
+       };
+
+       /**
+        * Respond to "set as default" checkbox change
+        * @param {boolean} checked State of the checkbox
+        */
+       SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
+               var messageKey = checked ?
+                       'rcfilters-savedqueries-apply-and-setdefault-label' :
+                       'rcfilters-savedqueries-apply-label';
+
+               this.applyButton
+                       .setIcon( checked ? 'pushPin' : null )
+                       .setLabel( mw.msg( messageKey ) );
+       };
+
+       /**
+        * Respond to cancel button click event
+        */
+       SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
+               this.popup.toggle( false );
+       };
+
+       /**
+        * Respond to apply button click event
+        */
+       SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
+               this.apply();
+       };
+
+       /**
+        * Apply and add the new quick link
+        */
+       SaveFiltersPopupButtonWidget.prototype.apply = function () {
+               var label = this.input.getValue().trim();
+
+               // This condition is more for sanity-check, since the
+               // apply button should be disabled if the label is empty
+               if ( label ) {
+                       this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
+                       this.input.setValue( '' );
+                       this.setAsDefaultCheckbox.setSelected( false );
+                       this.popup.toggle( false );
+
+                       this.emit( 'saveCurrent' );
+               }
+       };
+
+       module.exports = SaveFiltersPopupButtonWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js b/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js
new file mode 100644 (file)
index 0000000..ceb5ef8
--- /dev/null
@@ -0,0 +1,333 @@
+( function () {
+       /**
+        * Quick links menu option widget
+        *
+        * @class mw.rcfilters.ui.SavedLinksListItemWidget
+        * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.LabelElement
+        * @mixins OO.ui.mixin.IconElement
+        * @mixins OO.ui.mixin.TitledElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
+        * @param {Object} [config] Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        */
+       var SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
+               config = config || {};
+
+               this.model = model;
+
+               // Parent
+               SavedLinksListItemWidget.parent.call( this, $.extend( {
+                       data: this.model.getID()
+               }, config ) );
+
+               // Mixin constructors
+               OO.ui.mixin.LabelElement.call( this, $.extend( {
+                       label: this.model.getLabel()
+               }, config ) );
+               OO.ui.mixin.IconElement.call( this, $.extend( {
+                       icon: ''
+               }, config ) );
+               OO.ui.mixin.TitledElement.call( this, $.extend( {
+                       title: this.model.getLabel()
+               }, config ) );
+
+               this.edit = false;
+               this.$overlay = config.$overlay || this.$element;
+
+               this.popupButton = new OO.ui.ButtonWidget( {
+                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
+                       icon: 'ellipsis',
+                       framed: false
+               } );
+               this.menu = new OO.ui.MenuSelectWidget( {
+                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
+                       widget: this.popupButton,
+                       width: 200,
+                       horizontalPosition: 'end',
+                       $floatableContainer: this.popupButton.$element,
+                       items: [
+                               new OO.ui.MenuOptionWidget( {
+                                       data: 'edit',
+                                       icon: 'edit',
+                                       label: mw.msg( 'rcfilters-savedqueries-rename' )
+                               } ),
+                               new OO.ui.MenuOptionWidget( {
+                                       data: 'delete',
+                                       icon: 'trash',
+                                       label: mw.msg( 'rcfilters-savedqueries-remove' )
+                               } ),
+                               new OO.ui.MenuOptionWidget( {
+                                       data: 'default',
+                                       icon: 'pushPin',
+                                       label: mw.msg( 'rcfilters-savedqueries-setdefault' )
+                               } )
+                       ]
+               } );
+
+               this.editInput = new OO.ui.TextInputWidget( {
+                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
+               } );
+               this.saveButton = new OO.ui.ButtonWidget( {
+                       icon: 'check',
+                       flags: [ 'primary', 'progressive' ]
+               } );
+               this.toggleEdit( false );
+
+               // Events
+               this.model.connect( this, { update: 'onModelUpdate' } );
+               this.popupButton.connect( this, { click: 'onPopupButtonClick' } );
+               this.menu.connect( this, {
+                       choose: 'onMenuChoose'
+               } );
+               this.saveButton.connect( this, { click: 'save' } );
+               this.editInput.connect( this, {
+                       change: 'onInputChange',
+                       enter: 'save'
+               } );
+               this.editInput.$input.on( {
+                       blur: this.onInputBlur.bind( this ),
+                       keyup: this.onInputKeyup.bind( this )
+               } );
+               this.$element.on( { click: this.onClick.bind( this ) } );
+               this.$label.on( { click: this.onClick.bind( this ) } );
+               this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );
+               // Prevent propagation on mousedown for the save button
+               // so the menu doesn't close
+               this.saveButton.$element.on( { mousedown: function () {
+                       return false;
+               } } );
+
+               // Initialize
+               this.toggleDefault( !!this.model.isDefault() );
+               this.$overlay.append( this.menu.$element );
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
+                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
+                                                                       .append(
+                                                                               this.$label
+                                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
+                                                                               this.editInput.$element,
+                                                                               this.saveButton.$element
+                                                                       ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
+                                                                       .append( this.$icon ),
+                                                               this.popupButton.$element
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       )
+                                       )
+                       );
+       };
+
+       /* Initialization */
+       OO.inheritClass( SavedLinksListItemWidget, OO.ui.Widget );
+       OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.LabelElement );
+       OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.IconElement );
+       OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.TitledElement );
+
+       /* Events */
+
+       /**
+        * @event delete
+        *
+        * The delete option was selected for this item
+        */
+
+       /**
+        * @event default
+        * @param {boolean} default Item is default
+        *
+        * The 'make default' option was selected for this item
+        */
+
+       /**
+        * @event edit
+        * @param {string} newLabel New label for the query
+        *
+        * The label has been edited
+        */
+
+       /* Methods */
+
+       /**
+        * Respond to model update event
+        */
+       SavedLinksListItemWidget.prototype.onModelUpdate = function () {
+               this.setLabel( this.model.getLabel() );
+               this.toggleDefault( this.model.isDefault() );
+       };
+
+       /**
+        * Respond to click on the element or label
+        *
+        * @fires click
+        */
+       SavedLinksListItemWidget.prototype.onClick = function () {
+               if ( !this.editing ) {
+                       this.emit( 'click' );
+               }
+       };
+
+       /**
+        * Respond to click on the 'default' icon. Open the submenu where the
+        * default state can be changed.
+        *
+        * @return {boolean} false
+        */
+       SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
+               this.menu.toggle();
+               return false;
+       };
+
+       /**
+        * Respond to popup button click event
+        */
+       SavedLinksListItemWidget.prototype.onPopupButtonClick = function () {
+               this.menu.toggle();
+       };
+
+       /**
+        * Respond to menu choose event
+        *
+        * @param {OO.ui.MenuOptionWidget} item Chosen item
+        * @fires delete
+        * @fires default
+        */
+       SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
+               var action = item.getData();
+
+               if ( action === 'edit' ) {
+                       this.toggleEdit( true );
+               } else if ( action === 'delete' ) {
+                       this.emit( 'delete' );
+               } else if ( action === 'default' ) {
+                       this.emit( 'default', !this.default );
+               }
+               // Reset selected
+               this.menu.selectItem( null );
+               // Close the menu
+               this.menu.toggle( false );
+       };
+
+       /**
+        * Respond to input keyup event, this is the way to intercept 'escape' key
+        *
+        * @param {jQuery.Event} e Event data
+        * @return {boolean} false
+        */
+       SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
+               if ( e.which === OO.ui.Keys.ESCAPE ) {
+                       // Return the input to the original label
+                       this.editInput.setValue( this.getLabel() );
+                       this.toggleEdit( false );
+                       return false;
+               }
+       };
+
+       /**
+        * Respond to blur event on the input
+        */
+       SavedLinksListItemWidget.prototype.onInputBlur = function () {
+               this.save();
+
+               // Whether the save succeeded or not, the input-blur event
+               // means we need to cancel editing mode
+               this.toggleEdit( false );
+       };
+
+       /**
+        * Respond to input change event
+        *
+        * @param {string} value Input value
+        */
+       SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
+               value = value.trim();
+
+               this.saveButton.setDisabled( !value );
+       };
+
+       /**
+        * Save the name of the query
+        *
+        * @param {string} [value] The value to save
+        * @fires edit
+        */
+       SavedLinksListItemWidget.prototype.save = function () {
+               var value = this.editInput.getValue().trim();
+
+               if ( value ) {
+                       this.emit( 'edit', value );
+                       this.toggleEdit( false );
+               }
+       };
+
+       /**
+        * Toggle edit mode on this widget
+        *
+        * @param {boolean} isEdit Widget is in edit mode
+        */
+       SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
+               isEdit = isEdit === undefined ? !this.editing : isEdit;
+
+               if ( this.editing !== isEdit ) {
+                       this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
+                       this.editInput.setValue( this.getLabel() );
+
+                       this.editInput.toggle( isEdit );
+                       this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
+                       this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
+                       this.popupButton.toggle( !isEdit );
+                       this.saveButton.toggle( isEdit );
+
+                       if ( isEdit ) {
+                               this.editInput.$input.trigger( 'focus' );
+                       }
+                       this.editing = isEdit;
+               }
+       };
+
+       /**
+        * Toggle default this widget
+        *
+        * @param {boolean} isDefault This item is default
+        */
+       SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
+               isDefault = isDefault === undefined ? !this.default : isDefault;
+
+               if ( this.default !== isDefault ) {
+                       this.default = isDefault;
+                       this.setIcon( this.default ? 'pushPin' : '' );
+                       this.menu.findItemFromData( 'default' ).setLabel(
+                               this.default ?
+                                       mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
+                                       mw.msg( 'rcfilters-savedqueries-setdefault' )
+                       );
+               }
+       };
+
+       /**
+        * Get item ID
+        *
+        * @return {string} Query identifier
+        */
+       SavedLinksListItemWidget.prototype.getID = function () {
+               return this.model.getID();
+       };
+
+       module.exports = SavedLinksListItemWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js b/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js
new file mode 100644 (file)
index 0000000..5422daf
--- /dev/null
@@ -0,0 +1,159 @@
+( function () {
+       var GroupWidget = require( './GroupWidget.js' ),
+               SavedLinksListItemWidget = require( './SavedLinksListItemWidget.js' ),
+               SavedLinksListWidget;
+
+       /**
+        * Quick links widget
+        *
+        * @class mw.rcfilters.ui.SavedLinksListWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
+        * @param {Object} [config] Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        */
+       SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
+               var $labelNoEntries = $( '<div>' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
+                                       .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
+                                       .text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
+                       );
+
+               config = config || {};
+
+               // Parent
+               SavedLinksListWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = model;
+               this.$overlay = config.$overlay || this.$element;
+
+               this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
+                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
+                       label: $labelNoEntries,
+                       icon: 'bookmark'
+               } );
+
+               this.menu = new GroupWidget( {
+                       events: {
+                               click: 'menuItemClick',
+                               delete: 'menuItemDelete',
+                               default: 'menuItemDefault',
+                               edit: 'menuItemEdit'
+                       },
+                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
+                       items: [ this.placeholderItem ]
+               } );
+               this.button = new OO.ui.PopupButtonWidget( {
+                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
+                       label: mw.msg( 'rcfilters-quickfilters' ),
+                       icon: 'bookmark',
+                       indicator: 'down',
+                       $overlay: this.$overlay,
+                       popup: {
+                               width: 300,
+                               anchor: false,
+                               align: 'backwards',
+                               $autoCloseIgnore: this.$overlay,
+                               $content: this.menu.$element
+                       }
+               } );
+
+               // Events
+               this.model.connect( this, {
+                       add: 'onModelAddItem',
+                       remove: 'onModelRemoveItem'
+               } );
+               this.menu.connect( this, {
+                       menuItemClick: 'onMenuItemClick',
+                       menuItemDelete: 'onMenuItemRemove',
+                       menuItemDefault: 'onMenuItemDefault',
+                       menuItemEdit: 'onMenuItemEdit'
+               } );
+
+               this.placeholderItem.toggle( this.model.isEmpty() );
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget' )
+                       .append( this.button.$element );
+       };
+
+       /* Initialization */
+       OO.inheritClass( SavedLinksListWidget, OO.ui.Widget );
+
+       /* Methods */
+
+       /**
+        * Respond to menu item click event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        */
+       SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) {
+               this.controller.applySavedQuery( item.getID() );
+               this.button.popup.toggle( false );
+       };
+
+       /**
+        * Respond to menu item remove event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        */
+       SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
+               this.controller.removeSavedQuery( item.getID() );
+       };
+
+       /**
+        * Respond to menu item default event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        * @param {boolean} isDefault Item is default
+        */
+       SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
+               this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
+       };
+
+       /**
+        * Respond to menu item edit event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        * @param {string} newLabel New label
+        */
+       SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
+               this.controller.renameSavedQuery( item.getID(), newLabel );
+       };
+
+       /**
+        * Respond to menu add item event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        */
+       SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
+               if ( this.menu.findItemFromData( item.getID() ) ) {
+                       return;
+               }
+
+               this.menu.addItems( [
+                       new SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
+               ] );
+               this.placeholderItem.toggle( this.model.isEmpty() );
+       };
+
+       /**
+        * Respond to menu remove item event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        */
+       SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
+               this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
+               this.placeholderItem.toggle( this.model.isEmpty() );
+       };
+
+       module.exports = SavedLinksListWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js
new file mode 100644 (file)
index 0000000..d66c5b5
--- /dev/null
@@ -0,0 +1,225 @@
+( function () {
+       /**
+        * Extend OOUI's TagItemWidget to also display a popup on hover.
+        *
+        * @class mw.rcfilters.ui.TagItemWidget
+        * @extends OO.ui.TagItemWidget
+        * @mixins OO.ui.mixin.PopupElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+        * @param {mw.rcfilters.dm.FilterItem} invertModel
+        * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
+        * @param {Object} config Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        */
+       var TagItemWidget = function MwRcfiltersUiTagItemWidget(
+               controller, filtersViewModel, invertModel, itemModel, config
+       ) {
+               // Configuration initialization
+               config = config || {};
+
+               this.controller = controller;
+               this.invertModel = invertModel;
+               this.filtersViewModel = filtersViewModel;
+               this.itemModel = itemModel;
+               this.selected = false;
+
+               TagItemWidget.parent.call( this, $.extend( {
+                       data: this.itemModel.getName()
+               }, config ) );
+
+               this.$overlay = config.$overlay || this.$element;
+               this.popupLabel = new OO.ui.LabelWidget();
+
+               // Mixin constructors
+               OO.ui.mixin.PopupElement.call( this, $.extend( {
+                       popup: {
+                               padded: false,
+                               align: 'center',
+                               position: 'above',
+                               $content: $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
+                                       .append( this.popupLabel.$element ),
+                               $floatableContainer: this.$element,
+                               classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
+                       }
+               }, config ) );
+
+               this.popupTimeoutShow = null;
+               this.popupTimeoutHide = null;
+
+               this.$highlight = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );
+
+               // Add title attribute with the item label to 'x' button
+               this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );
+
+               // Events
+               this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
+               this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
+               this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
+
+               // Initialization
+               this.$overlay.append( this.popup.$element );
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-tagItemWidget' )
+                       .prepend( this.$highlight )
+                       .attr( 'aria-haspopup', 'true' )
+                       .on( 'mouseenter', this.onMouseEnter.bind( this ) )
+                       .on( 'mouseleave', this.onMouseLeave.bind( this ) );
+
+               this.updateUiBasedOnState();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( TagItemWidget, OO.ui.TagItemWidget );
+       OO.mixinClass( TagItemWidget, OO.ui.mixin.PopupElement );
+
+       /* Methods */
+
+       /**
+        * Respond to model update event
+        */
+       TagItemWidget.prototype.updateUiBasedOnState = function () {
+               // Update label if needed
+               var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel.isSelected() );
+               if ( labelMsg ) {
+                       this.setLabel( $( '<div>' ).append(
+                               $( '<bdi>' ).html(
+                                       mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
+                               )
+                       ).contents() );
+               } else {
+                       this.setLabel(
+                               $( '<bdi>' ).append(
+                                       this.itemModel.getLabel()
+                               )
+                       );
+               }
+
+               this.setCurrentMuteState();
+               this.setHighlightColor();
+       };
+
+       /**
+        * Set the current highlight color for this item
+        */
+       TagItemWidget.prototype.setHighlightColor = function () {
+               var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
+                       this.itemModel.getHighlightColor() :
+                       null;
+
+               this.$highlight
+                       .attr( 'data-color', selectedColor )
+                       .toggleClass(
+                               'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
+                               !!selectedColor
+                       );
+       };
+
+       /**
+        * Set the current mute state for this item
+        */
+       TagItemWidget.prototype.setCurrentMuteState = function () {};
+
+       /**
+        * Respond to mouse enter event
+        */
+       TagItemWidget.prototype.onMouseEnter = function () {
+               var labelText = this.itemModel.getStateMessage();
+
+               if ( labelText ) {
+                       this.popupLabel.setLabel( labelText );
+
+                       // Set timeout for the popup to show
+                       this.popupTimeoutShow = setTimeout( function () {
+                               this.popup.toggle( true );
+                       }.bind( this ), 500 );
+
+                       // Cancel the hide timeout
+                       clearTimeout( this.popupTimeoutHide );
+                       this.popupTimeoutHide = null;
+               }
+       };
+
+       /**
+        * Respond to mouse leave event
+        */
+       TagItemWidget.prototype.onMouseLeave = function () {
+               this.popupTimeoutHide = setTimeout( function () {
+                       this.popup.toggle( false );
+               }.bind( this ), 250 );
+
+               // Clear the show timeout
+               clearTimeout( this.popupTimeoutShow );
+               this.popupTimeoutShow = null;
+       };
+
+       /**
+        * Set selected state on this widget
+        *
+        * @param {boolean} [isSelected] Widget is selected
+        */
+       TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
+               isSelected = isSelected !== undefined ? isSelected : !this.selected;
+
+               if ( this.selected !== isSelected ) {
+                       this.selected = isSelected;
+
+                       this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
+               }
+       };
+
+       /**
+        * Get the selected state of this widget
+        *
+        * @return {boolean} Tag is selected
+        */
+       TagItemWidget.prototype.isSelected = function () {
+               return this.selected;
+       };
+
+       /**
+        * Get item name
+        *
+        * @return {string} Filter name
+        */
+       TagItemWidget.prototype.getName = function () {
+               return this.itemModel.getName();
+       };
+
+       /**
+        * Get item model
+        *
+        * @return {string} Filter model
+        */
+       TagItemWidget.prototype.getModel = function () {
+               return this.itemModel;
+       };
+
+       /**
+        * Get item view
+        *
+        * @return {string} Filter view
+        */
+       TagItemWidget.prototype.getView = function () {
+               return this.itemModel.getGroupModel().getView();
+       };
+
+       /**
+        * Remove and destroy external elements of this widget
+        */
+       TagItemWidget.prototype.destroy = function () {
+               // Destroy the popup
+               this.popup.$element.detach();
+
+               // Disconnect events
+               this.itemModel.disconnect( this );
+               this.closeButton.disconnect( this );
+       };
+
+       module.exports = TagItemWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js b/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js
new file mode 100644 (file)
index 0000000..ebd81c8
--- /dev/null
@@ -0,0 +1,114 @@
+( function () {
+       /**
+        * Widget defining the behavior used to choose from a set of values
+        * in a single_value group
+        *
+        * @class mw.rcfilters.ui.ValuePickerWidget
+        * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.LabelElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FilterGroup} model Group model
+        * @param {Object} [config] Configuration object
+        * @cfg {Function} [itemFilter] A filter function for the items from the
+        *  model. If not given, all items will be included. The function must
+        *  handle item models and return a boolean whether the item is included
+        *  or not. Example: function ( itemModel ) { return itemModel.isSelected(); }
+        */
+       var ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) {
+               config = config || {};
+
+               // Parent
+               ValuePickerWidget.parent.call( this, config );
+               // Mixin constructors
+               OO.ui.mixin.LabelElement.call( this, config );
+
+               this.model = model;
+               this.itemFilter = config.itemFilter || function () {
+                       return true;
+               };
+
+               // Build the selection from the item models
+               this.selectWidget = new OO.ui.ButtonSelectWidget();
+               this.initializeSelectWidget();
+
+               // Events
+               this.model.connect( this, { update: 'onModelUpdate' } );
+               this.selectWidget.connect( this, { choose: 'onSelectWidgetChoose' } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-valuePickerWidget' )
+                       .append(
+                               this.$label
+                                       .addClass( 'mw-rcfilters-ui-valuePickerWidget-title' ),
+                               this.selectWidget.$element
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( ValuePickerWidget, OO.ui.Widget );
+       OO.mixinClass( ValuePickerWidget, OO.ui.mixin.LabelElement );
+
+       /* Events */
+
+       /**
+        * @event choose
+        * @param {string} name Item name
+        *
+        * An item has been chosen
+        */
+
+       /* Methods */
+
+       /**
+        * Respond to model update event
+        */
+       ValuePickerWidget.prototype.onModelUpdate = function () {
+               this.selectCurrentModelItem();
+       };
+
+       /**
+        * Respond to select widget choose event
+        *
+        * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item
+        * @fires choose
+        */
+       ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) {
+               this.emit( 'choose', chosenItem.getData() );
+       };
+
+       /**
+        * Initialize the select widget
+        */
+       ValuePickerWidget.prototype.initializeSelectWidget = function () {
+               var items = this.model.getItems()
+                       .filter( this.itemFilter )
+                       .map( function ( filterItem ) {
+                               return new OO.ui.ButtonOptionWidget( {
+                                       data: filterItem.getName(),
+                                       label: filterItem.getLabel()
+                               } );
+                       } );
+
+               this.selectWidget.clearItems();
+               this.selectWidget.addItems( items );
+
+               this.selectCurrentModelItem();
+       };
+
+       /**
+        * Select the current item that corresponds with the model item
+        * that is currently selected
+        */
+       ValuePickerWidget.prototype.selectCurrentModelItem = function () {
+               var selectedItem = this.model.findSelectedItems()[ 0 ];
+
+               if ( selectedItem ) {
+                       this.selectWidget.selectItemByData( selectedItem.getName() );
+               }
+       };
+
+       module.exports = ValuePickerWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js b/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js
new file mode 100644 (file)
index 0000000..c00d414
--- /dev/null
@@ -0,0 +1,84 @@
+( function () {
+       var GroupWidget = require( './GroupWidget.js' ),
+               ViewSwitchWidget;
+
+       /**
+        * A widget for the footer for the default view, allowing to switch views
+        *
+        * @class mw.rcfilters.ui.ViewSwitchWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {Object} [config] Configuration object
+        */
+       ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
+               config = config || {};
+
+               // Parent
+               ViewSwitchWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = model;
+
+               this.buttons = new GroupWidget( {
+                       events: {
+                               click: 'buttonClick'
+                       },
+                       items: [
+                               new OO.ui.ButtonWidget( {
+                                       data: 'namespaces',
+                                       icon: 'article',
+                                       label: mw.msg( 'namespaces' )
+                               } ),
+                               new OO.ui.ButtonWidget( {
+                                       data: 'tags',
+                                       icon: 'tag',
+                                       label: mw.msg( 'rcfilters-view-tags' )
+                               } )
+                       ]
+               } );
+
+               // Events
+               this.model.connect( this, { update: 'onModelUpdate' } );
+               this.buttons.connect( this, { buttonClick: 'onButtonClick' } );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
+                       .append(
+                               new OO.ui.LabelWidget( {
+                                       label: mw.msg( 'rcfilters-advancedfilters' )
+                               } ).$element,
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
+                                       .append( this.buttons.$element )
+                       );
+       };
+
+       /* Initialize */
+
+       OO.inheritClass( ViewSwitchWidget, OO.ui.Widget );
+
+       /**
+        * Respond to model update event
+        */
+       ViewSwitchWidget.prototype.onModelUpdate = function () {
+               var currentView = this.model.getCurrentView();
+
+               this.buttons.getItems().forEach( function ( buttonWidget ) {
+                       buttonWidget.setActive( buttonWidget.getData() === currentView );
+               } );
+       };
+
+       /**
+        * Respond to button switch click
+        *
+        * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
+        */
+       ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
+               this.controller.switchView( buttonWidget.getData() );
+       };
+
+       module.exports = ViewSwitchWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js
new file mode 100644 (file)
index 0000000..a1c9776
--- /dev/null
@@ -0,0 +1,88 @@
+( function () {
+       var MarkSeenButtonWidget = require( './MarkSeenButtonWidget.js' ),
+               WatchlistTopSectionWidget;
+       /**
+        * Top section (between page title and filters) on Special:Watchlist
+        *
+        * @class mw.rcfilters.ui.WatchlistTopSectionWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+        * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
+        * @param {Object} [config] Configuration object
+        */
+       WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
+               controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
+       ) {
+               var editWatchlistButton,
+                       markSeenButton,
+                       $topTable,
+                       $bottomTable,
+                       $separator;
+               config = config || {};
+
+               // Parent
+               WatchlistTopSectionWidget.parent.call( this, config );
+
+               editWatchlistButton = new OO.ui.ButtonWidget( {
+                       label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
+                       icon: 'edit',
+                       href: mw.config.get( 'wgStructuredChangeFiltersEditWatchlistUrl' )
+               } );
+               markSeenButton = new MarkSeenButtonWidget( controller, changesListModel );
+
+               $topTable = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-table' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-row' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
+                                                       .append( $watchlistDetails )
+                                       )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
+                                                       .append( editWatchlistButton.$element )
+                                       )
+                       );
+
+               $bottomTable = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-table' )
+                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-row' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       .append( markSeenButton.$element )
+                                       )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
+                                                       .append( savedLinksListWidget.$element )
+                                       )
+                       );
+
+               $separator = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
+                       .append( $topTable, $separator, $bottomTable );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget );
+
+       module.exports = WatchlistTopSectionWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.js
deleted file mode 100644 (file)
index e907a15..0000000
+++ /dev/null
@@ -1,168 +0,0 @@
-( function () {
-       /**
-        * Widget defining the button controlling the popup for the number of results
-        *
-        * @class
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-
-               this.$overlay = config.$overlay || this.$element;
-
-               this.button = null;
-               this.limitGroupModel = null;
-               this.groupByPageItemModel = null;
-               this.daysGroupModel = null;
-
-               this.model.connect( this, {
-                       initialize: 'onModelInitialize'
-               } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.ChangesLimitAndDateButtonWidget, OO.ui.Widget );
-
-       /**
-        * Respond to model initialize event
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
-               var changesLimitPopupWidget, selectedItem, currentValue, datePopupWidget,
-                       displayGroupModel = this.model.getGroup( 'display' );
-
-               this.limitGroupModel = this.model.getGroup( 'limit' );
-               this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
-               this.daysGroupModel = this.model.getGroup( 'days' );
-
-               // HACK: We need the model to be ready before we populate the button
-               // and the widget, because we require the filter items for the
-               // limit and their events. This addition is only done after the
-               // model is initialized.
-               // Note: This will be fixed soon!
-               if ( this.limitGroupModel && this.daysGroupModel ) {
-                       changesLimitPopupWidget = new mw.rcfilters.ui.ChangesLimitPopupWidget(
-                               this.limitGroupModel,
-                               this.groupByPageItemModel
-                       );
-
-                       datePopupWidget = new mw.rcfilters.ui.DatePopupWidget(
-                               this.daysGroupModel,
-                               {
-                                       label: mw.msg( 'rcfilters-date-popup-title' )
-                               }
-                       );
-
-                       selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
-                       currentValue = ( selectedItem && selectedItem.getLabel() ) ||
-                               mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );
-
-                       this.button = new OO.ui.PopupButtonWidget( {
-                               icon: 'settings',
-                               indicator: 'down',
-                               label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
-                               $overlay: this.$overlay,
-                               popup: {
-                                       width: 300,
-                                       padded: false,
-                                       anchor: false,
-                                       align: 'backwards',
-                                       $autoCloseIgnore: this.$overlay,
-                                       $content: $( '<div>' ).append(
-                                               // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
-                                               changesLimitPopupWidget.$element,
-                                               datePopupWidget.$element
-                                       )
-                               }
-                       } );
-                       this.updateButtonLabel();
-
-                       // Events
-                       this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
-                       this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
-                       changesLimitPopupWidget.connect( this, {
-                               limit: 'onPopupLimit',
-                               groupByPage: 'onPopupGroupByPage'
-                       } );
-                       datePopupWidget.connect( this, { days: 'onPopupDays' } );
-
-                       this.$element.append( this.button.$element );
-               }
-       };
-
-       /**
-        * Respond to popup limit change event
-        *
-        * @param {string} filterName Chosen filter name
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
-               var item = this.limitGroupModel.getItemByName( filterName );
-
-               this.controller.toggleFilterSelect( filterName, true );
-               this.controller.updateLimitDefault( item.getParamName() );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to popup limit change event
-        *
-        * @param {boolean} isGrouped The result set is grouped by page
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
-               this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
-               this.controller.updateGroupByPageDefault( isGrouped );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to popup limit change event
-        *
-        * @param {string} filterName Chosen filter name
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
-               var item = this.daysGroupModel.getItemByName( filterName );
-
-               this.controller.toggleFilterSelect( filterName, true );
-               this.controller.updateDaysDefault( item.getParamName() );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to limit choose event
-        *
-        * @param {string} filterName Filter name
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
-               var message,
-                       limit = this.limitGroupModel.findSelectedItems()[ 0 ],
-                       label = limit && limit.getLabel(),
-                       days = this.daysGroupModel.findSelectedItems()[ 0 ],
-                       daysParamName = Number( days.getParamName() ) < 1 ?
-                               'rcfilters-days-show-hours' :
-                               'rcfilters-days-show-days';
-
-               // Update the label
-               if ( label && days ) {
-                       message = mw.msg( 'rcfilters-limit-and-date-label', label,
-                               mw.msg( daysParamName, days.getLabel() )
-                       );
-                       this.button.setLabel( message );
-               }
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js
deleted file mode 100644 (file)
index 8cf9657..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-( function () {
-       /**
-        * Widget defining the popup to choose number of results
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
-        * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.ChangesLimitPopupWidget.parent.call( this, config );
-
-               this.limitModel = limitModel;
-               this.groupByPageItemModel = groupByPageItemModel;
-
-               this.valuePicker = new mw.rcfilters.ui.ValuePickerWidget(
-                       this.limitModel,
-                       {
-                               label: mw.msg( 'rcfilters-limit-title' )
-                       }
-               );
-
-               this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
-                       selected: this.groupByPageItemModel.isSelected()
-               } );
-
-               // Events
-               this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
-               this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
-               this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
-                       .append(
-                               this.valuePicker.$element,
-                               new OO.ui.FieldLayout(
-                                       this.groupByPageCheckbox,
-                                       {
-                                               align: 'inline',
-                                               label: mw.msg( 'rcfilters-group-results-by-page' )
-                                       }
-                               ).$element
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.ChangesLimitPopupWidget, OO.ui.Widget );
-
-       /* Events */
-
-       /**
-        * @event limit
-        * @param {string} name Item name
-        *
-        * A limit item was chosen
-        */
-
-       /**
-        * @event groupByPage
-        * @param {boolean} isGrouped The results are grouped by page
-        *
-        * Results are grouped by page
-        */
-
-       /**
-        * Respond to group by page model update
-        */
-       mw.rcfilters.ui.ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
-               this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js
deleted file mode 100644 (file)
index b76078e..0000000
+++ /dev/null
@@ -1,385 +0,0 @@
-( function () {
-       /**
-        * List of changes
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
-        * @param {mw.rcfilters.Controller} controller
-        * @param {jQuery} $changesListRoot Root element of the changes list to attach to
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
-               filtersViewModel,
-               changesListViewModel,
-               controller,
-               $changesListRoot,
-               config
-       ) {
-               config = $.extend( {}, config, {
-                       $element: $changesListRoot
-               } );
-
-               // Parent
-               mw.rcfilters.ui.ChangesListWrapperWidget.parent.call( this, config );
-
-               this.filtersViewModel = filtersViewModel;
-               this.changesListViewModel = changesListViewModel;
-               this.controller = controller;
-               this.highlightClasses = null;
-
-               // Events
-               this.filtersViewModel.connect( this, {
-                       itemUpdate: 'onItemUpdate',
-                       highlightChange: 'onHighlightChange'
-               } );
-               this.changesListViewModel.connect( this, {
-                       invalidate: 'onModelInvalidate',
-                       update: 'onModelUpdate'
-               } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
-                       // We handle our own display/hide of the empty results message
-                       // We keep the timeout class here and remove it later, since at this
-                       // stage it is still needed to identify that the timeout occurred.
-                       .removeClass( 'mw-changeslist-empty' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.ChangesListWrapperWidget, OO.ui.Widget );
-
-       /**
-        * Get all available highlight classes
-        *
-        * @return {string[]} An array of available highlight class names
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.getHighlightClasses = function () {
-               if ( !this.highlightClasses || !this.highlightClasses.length ) {
-                       this.highlightClasses = this.filtersViewModel.getItemsSupportingHighlights()
-                               .map( function ( filterItem ) {
-                                       return filterItem.getCssClass();
-                               } );
-               }
-
-               return this.highlightClasses;
-       };
-
-       /**
-        * Respond to the highlight feature being toggled on and off
-        *
-        * @param {boolean} highlightEnabled
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
-               if ( highlightEnabled ) {
-                       this.applyHighlight();
-               } else {
-                       this.clearHighlight();
-               }
-       };
-
-       /**
-        * Respond to a filter item model update
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onItemUpdate = function () {
-               if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
-                       // this.controller.isInitialized() is still false during page load,
-                       // we don't want to clear/apply highlights at this stage.
-                       this.clearHighlight();
-                       this.applyHighlight();
-               }
-       };
-
-       /**
-        * Respond to changes list model invalidate
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
-               $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
-       };
-
-       /**
-        * Respond to changes list model update
-        *
-        * @param {jQuery|string} $changesListContent The content of the updated changes list
-        * @param {jQuery} $fieldset The content of the updated fieldset
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
-        * @param {boolean} from Timestamp of the new changes
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelUpdate = function (
-               $changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
-       ) {
-               var conflictItem,
-                       $message = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
-                       isEmpty = $changesListContent === 'NO_RESULTS',
-                       // For enhanced mode, we have to load these modules, which are
-                       // not loaded for the 'regular' mode in the backend
-                       loaderPromise = mw.user.options.get( 'usenewrc' ) ?
-                               mw.loader.using( [ 'mediawiki.special.changeslist.enhanced', 'mediawiki.icon' ] ) :
-                               $.Deferred().resolve(),
-                       widget = this;
-
-               this.$element.toggleClass( 'mw-changeslist', !isEmpty );
-               if ( isEmpty ) {
-                       this.$element.empty();
-
-                       if ( this.filtersViewModel.hasConflict() ) {
-                               conflictItem = this.filtersViewModel.getFirstConflictedItem();
-
-                               $message
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
-                                                       .text( mw.message( 'rcfilters-noresults-conflict' ).text() ),
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
-                                                       .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() )
-                                       );
-                       } else {
-                               $message
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
-                                                       .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
-                                       );
-
-                               // remove all classes matching mw-changeslist-*
-                               this.$element.removeClass( function ( elementIndex, allClasses ) {
-                                       return allClasses
-                                               .split( ' ' )
-                                               .filter( function ( className ) {
-                                                       return className.indexOf( 'mw-changeslist-' ) === 0;
-                                               } )
-                                               .join( ' ' );
-                               } );
-                       }
-
-                       this.$element.append( $message );
-               } else {
-                       if ( !isInitialDOM ) {
-                               this.$element.empty().append( $changesListContent );
-
-                               if ( from ) {
-                                       this.emphasizeNewChanges( from );
-                               }
-                       }
-
-                       // Apply highlight
-                       this.applyHighlight();
-
-               }
-
-               this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
-
-               loaderPromise.done( function () {
-                       if ( !isInitialDOM && !isEmpty ) {
-                               // Make sure enhanced RC re-initializes correctly
-                               mw.hook( 'wikipage.content' ).fire( widget.$element );
-                       }
-
-                       $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
-               } );
-       };
-
-       /** Toggles overlay class on changes list
-        *
-        * @param {boolean} isVisible True if overlay should be visible
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
-               this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
-       };
-
-       /**
-        * Map a reason for having no results to its message key
-        *
-        * @param {string} reason One of the NO_RESULTS_* "constant" that represent
-        *   a reason for having no results
-        * @return {string} Key for the message that explains why there is no results in this case
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
-               var reasonMsgKeyMap = {
-                       NO_RESULTS_NORMAL: 'recentchanges-noresult',
-                       NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
-                       NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
-                       NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
-                       NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
-               };
-               return reasonMsgKeyMap[ reason ];
-       };
-
-       /**
-        * Emphasize the elements (or groups) newer than the 'from' parameter
-        * @param {string} from Anything newer than this is considered 'new'
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
-               var $firstNew,
-                       $indicator,
-                       $newChanges = $( [] ),
-                       selector = this.inEnhancedMode() ?
-                               'table.mw-enhanced-rc[data-mw-ts]' :
-                               'li[data-mw-ts]',
-                       set = this.$element.find( selector ),
-                       length = set.length;
-
-               set.each( function ( index ) {
-                       var $this = $( this ),
-                               ts = $this.data( 'mw-ts' );
-
-                       if ( ts >= from ) {
-                               $newChanges = $newChanges.add( $this );
-                               $firstNew = $this;
-
-                               // guards against putting the marker after the last element
-                               if ( index === ( length - 1 ) ) {
-                                       $firstNew = null;
-                               }
-                       }
-               } );
-
-               if ( $firstNew ) {
-                       $indicator = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
-
-                       $firstNew.after( $indicator );
-               }
-
-               // FIXME: Use CSS transition
-               // eslint-disable-next-line jquery/no-fade
-               $newChanges
-                       .hide()
-                       .fadeIn( 1000 );
-       };
-
-       /**
-        * In enhanced mode, we need to check whether the grouped results all have the
-        * same active highlights in order to see whether the "parent" of the group should
-        * be grey or highlighted normally.
-        *
-        * This is called every time highlights are applied.
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
-               var activeHighlightClasses,
-                       $enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc.mw-collapsible' );
-
-               activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
-                       return 'mw-rcfilters-highlight-color-' + color;
-               } );
-
-               // Go over top pages and their children, and figure out if all sub-pages have the
-               // same highlights between themselves. If they do, the parent should be highlighted
-               // with all colors. If classes are different, the parent should receive a grey
-               // background
-               $enhancedTopPageCell.each( function () {
-                       var firstChildClasses, $rowsWithDifferentHighlights,
-                               $table = $( this );
-
-                       // Collect the relevant classes from the first nested child
-                       firstChildClasses = activeHighlightClasses.filter( function ( className ) {
-                               return $table.find( 'tr:nth-child(2)' ).hasClass( className );
-                       } );
-                       // Filter the non-head rows and see if they all have the same classes
-                       // to the first row
-                       $rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
-                               var classesInThisRow,
-                                       $this = $( this );
-
-                               classesInThisRow = activeHighlightClasses.filter( function ( className ) {
-                                       return $this.hasClass( className );
-                               } );
-
-                               return !OO.compare( firstChildClasses, classesInThisRow );
-                       } );
-
-                       // If classes are different, tag the row for using grey color
-                       $table.find( 'tr:first-child' )
-                               .toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
-               } );
-       };
-
-       /**
-        * @return {boolean} Whether the changes are grouped by page
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
-               var uri = new mw.Uri();
-               return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
-                       ( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
-       };
-
-       /**
-        * Apply color classes based on filters highlight configuration
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.applyHighlight = function () {
-               if ( !this.filtersViewModel.isHighlightEnabled() ) {
-                       return;
-               }
-
-               this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
-                       var $elements = this.$element.find( '.' + filterItem.getCssClass() );
-
-                       // Add highlight class to all highlighted list items
-                       $elements
-                               .addClass(
-                                       'mw-rcfilters-highlighted ' +
-                                       'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
-                               );
-
-                       // Track the filters for each item in .data( 'highlightedFilters' )
-                       $elements.each( function () {
-                               var filters = $( this ).data( 'highlightedFilters' );
-                               if ( !filters ) {
-                                       filters = [];
-                                       $( this ).data( 'highlightedFilters', filters );
-                               }
-                               if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
-                                       filters.push( filterItem.getLabel() );
-                               }
-                       } );
-               }.bind( this ) );
-               // Apply a title to each highlighted item, with a list of filters
-               this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
-                       var filters = $( this ).data( 'highlightedFilters' );
-
-                       if ( filters && filters.length ) {
-                               $( this ).attr( 'title', mw.msg(
-                                       'rcfilters-highlighted-filters-list',
-                                       filters.join( mw.msg( 'comma-separator' ) )
-                               ) );
-                       }
-
-               } );
-               if ( this.inEnhancedMode() ) {
-                       this.updateEnhancedParentHighlight();
-               }
-
-               // Turn on highlights
-               this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
-       };
-
-       /**
-        * Remove all color classes
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.clearHighlight = function () {
-               // Remove highlight classes
-               mw.rcfilters.HighlightColors.forEach( function ( color ) {
-                       this.$element
-                               .find( '.mw-rcfilters-highlight-color-' + color )
-                               .removeClass( 'mw-rcfilters-highlight-color-' + color );
-               }.bind( this ) );
-
-               this.$element.find( '.mw-rcfilters-highlighted' )
-                       .removeAttr( 'title' )
-                       .removeData( 'highlightedFilters' )
-                       .removeClass( 'mw-rcfilters-highlighted' );
-
-               // Remove grey from enhanced rows
-               this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
-                       .removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );
-
-               // Turn off highlights
-               this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js
deleted file mode 100644 (file)
index b273a01..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-( function () {
-       /**
-        * A widget representing a single toggle filter
-        *
-        * @extends OO.ui.CheckboxInputWidget
-        *
-        * @constructor
-        * @param {Object} config Configuration object
-        */
-       mw.rcfilters.ui.CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.CheckboxInputWidget.parent.call( this, config );
-
-               // Event
-               this.$input
-                       // HACK: This widget just pretends to be a checkbox for visual purposes.
-                       // In reality, all actions - setting to true or false, etc - are
-                       // decided by the model, and executed by the controller. This means
-                       // that we want to let the controller and model make the decision
-                       // of whether to check/uncheck this checkboxInputWidget, and for that,
-                       // we have to bypass the browser action that checks/unchecks it during
-                       // click.
-                       .on( 'click', false )
-                       .on( 'change', this.onUserChange.bind( this ) );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.CheckboxInputWidget, OO.ui.CheckboxInputWidget );
-
-       /* Events */
-
-       /**
-        * @event userChange
-        * @param {boolean} Current state of the checkbox
-        *
-        * The user has checked or unchecked this checkbox
-        */
-
-       /* Methods */
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.CheckboxInputWidget.prototype.onEdit = function () {
-               // Similarly to preventing defaults in 'click' event, we want
-               // to prevent this widget from deciding anything about its own
-               // state; it emits a change event and the model and controller
-               // make a decision about what its select state is.
-               // onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
-               // so we really want to prevent that from messing with what
-               // the model decides the state of the widget is.
-       };
-
-       /**
-        * Respond to checkbox change by a user and emit 'userChange'.
-        */
-       mw.rcfilters.ui.CheckboxInputWidget.prototype.onUserChange = function () {
-               this.emit( 'userChange', this.$input.prop( 'checked' ) );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js
deleted file mode 100644 (file)
index 792ea4b..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-( function () {
-       /**
-        * Widget defining the popup to choose date for the results
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days'
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.ChangesLimitPopupWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, config );
-
-               this.model = model;
-
-               this.hoursValuePicker = new mw.rcfilters.ui.ValuePickerWidget(
-                       this.model,
-                       {
-                               classes: [ 'mw-rcfilters-ui-datePopupWidget-hours' ],
-                               label: mw.msg( 'rcfilters-hours-title' ),
-                               itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) < 1; }
-                       }
-               );
-               this.daysValuePicker = new mw.rcfilters.ui.ValuePickerWidget(
-                       this.model,
-                       {
-                               classes: [ 'mw-rcfilters-ui-datePopupWidget-days' ],
-                               label: mw.msg( 'rcfilters-days-title' ),
-                               itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) >= 1; }
-                       }
-               );
-
-               // Events
-               this.hoursValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
-               this.daysValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-datePopupWidget' )
-                       .append(
-                               this.$label
-                                       .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
-                               this.hoursValuePicker.$element,
-                               this.daysValuePicker.$element
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.DatePopupWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.DatePopupWidget, OO.ui.mixin.LabelElement );
-
-       /* Events */
-
-       /**
-        * @event days
-        * @param {string} name Item name
-        *
-        * A days item was chosen
-        */
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js
deleted file mode 100644 (file)
index 289f1ee..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-( function () {
-       /**
-        * A button to configure highlight for a filter item
-        *
-        * @extends OO.ui.PopupButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FilterItem} model Filter item model
-        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
-                       icon: 'highlight',
-                       indicator: 'down'
-               } ) );
-
-               this.controller = controller;
-               this.model = model;
-               this.popup = highlightPopup;
-
-               // Event
-               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
-               // This lives inside a MenuOptionWidget, which intercepts mousedown
-               // to select the item. We want to prevent that when we click the highlight
-               // button
-               this.$element.on( 'mousedown', function ( e ) {
-                       e.stopPropagation();
-               } );
-
-               this.updateUiBasedOnModel();
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterItemHighlightButton, OO.ui.PopupButtonWidget );
-
-       /* Static Properties */
-
-       /**
-        * @static
-        */
-       mw.rcfilters.ui.FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;
-
-       /* Methods */
-
-       mw.rcfilters.ui.FilterItemHighlightButton.prototype.onAction = function () {
-               this.popup.setAssociatedButton( this );
-               this.popup.setFilterItem( this.model );
-
-               // Parent method
-               mw.rcfilters.ui.FilterItemHighlightButton.parent.prototype.onAction.call( this );
-       };
-
-       /**
-        * Respond to item model update event
-        */
-       mw.rcfilters.ui.FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
-               var currentColor = this.model.getHighlightColor(),
-                       widget = this;
-
-               this.$icon.toggleClass(
-                       'mw-rcfilters-ui-filterItemHighlightButton-circle',
-                       currentColor !== null
-               );
-
-               mw.rcfilters.HighlightColors.forEach( function ( c ) {
-                       widget.$icon
-                               .toggleClass(
-                                       'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
-                                       c === currentColor
-                               );
-               } );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js
deleted file mode 100644 (file)
index 1fef7a0..0000000
+++ /dev/null
@@ -1,182 +0,0 @@
-( function () {
-       /**
-        * Menu header for the RCFilters filters menu
-        *
-        * @class
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.$overlay = config.$overlay || this.$element;
-
-               // Parent
-               mw.rcfilters.ui.FilterMenuHeaderWidget.parent.call( this, config );
-               OO.ui.mixin.LabelElement.call( this, $.extend( {
-                       label: mw.msg( 'rcfilters-filterlist-title' ),
-                       $label: $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
-               }, config ) );
-
-               // "Back" to default view button
-               this.backButton = new OO.ui.ButtonWidget( {
-                       icon: 'previous',
-                       framed: false,
-                       title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
-               } );
-               this.backButton.toggle( this.model.getCurrentView() !== 'default' );
-
-               // Help icon for Tagged edits
-               this.helpIcon = new OO.ui.ButtonWidget( {
-                       icon: 'helpNotice',
-                       framed: false,
-                       title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
-                       href: mw.util.getUrl( 'Special:Tags' ),
-                       target: '_blank'
-               } );
-               this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );
-
-               // Highlight button
-               this.highlightButton = new OO.ui.ToggleButtonWidget( {
-                       icon: 'highlight',
-                       label: mw.message( 'rcfilters-highlightbutton-title' ).text(),
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
-               } );
-
-               // Invert namespaces button
-               this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
-                       icon: '',
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
-               } );
-               this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
-
-               // Events
-               this.backButton.connect( this, { click: 'onBackButtonClick' } );
-               this.highlightButton
-                       .connect( this, { click: 'onHighlightButtonClick' } );
-               this.invertNamespacesButton
-                       .connect( this, { click: 'onInvertNamespacesButtonClick' } );
-               this.model.connect( this, {
-                       highlightChange: 'onModelHighlightChange',
-                       searchChange: 'onModelSearchChange',
-                       initialize: 'onModelInitialize'
-               } );
-               this.view = this.model.getCurrentView();
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
-                                                                       .append( this.backButton.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
-                                                                       .append( this.$label, this.helpIcon.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
-                                                                       .append( this.invertNamespacesButton.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
-                                                                       .append( this.highlightButton.$element )
-                                                       )
-                                       )
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterMenuHeaderWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );
-
-       /* Methods */
-
-       /**
-        * Respond to model initialization event
-        *
-        * Note: need to wait for initialization before getting the invertModel
-        * and registering its update event. Creating all the models before the UI
-        * would help with that.
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
-               this.invertModel = this.model.getInvertModel();
-               this.updateInvertButton();
-               this.invertModel.connect( this, { update: 'updateInvertButton' } );
-       };
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
-               var currentView = this.model.getCurrentView();
-
-               if ( this.view !== currentView ) {
-                       this.setLabel( this.model.getViewTitle( currentView ) );
-
-                       this.invertNamespacesButton.toggle( currentView === 'namespaces' );
-                       this.backButton.toggle( currentView !== 'default' );
-                       this.helpIcon.toggle( currentView === 'tags' );
-                       this.view = currentView;
-               }
-       };
-
-       /**
-        * Respond to model highlight change event
-        *
-        * @param {boolean} highlightEnabled Highlight is enabled
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
-               this.highlightButton.setActive( highlightEnabled );
-       };
-
-       /**
-        * Update the state of the invert button
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.updateInvertButton = function () {
-               this.invertNamespacesButton.setActive( this.invertModel.isSelected() );
-               this.invertNamespacesButton.setLabel(
-                       this.invertModel.isSelected() ?
-                               mw.msg( 'rcfilters-exclude-button-on' ) :
-                               mw.msg( 'rcfilters-exclude-button-off' )
-               );
-       };
-
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
-               this.controller.switchView( 'default' );
-       };
-
-       /**
-        * Respond to highlight button click
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
-               this.controller.toggleHighlight();
-       };
-
-       /**
-        * Respond to highlight button click
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
-               this.controller.toggleInvertedNamespaces();
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuOptionWidget.js
deleted file mode 100644 (file)
index 8840155..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-( function () {
-       /**
-        * A widget representing a single toggle filter
-        *
-        * @extends mw.rcfilters.ui.ItemMenuOptionWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.FilterItem} invertModel
-        * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
-        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
-        * @param {Object} config Configuration object
-        */
-       mw.rcfilters.ui.FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
-               controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
-       ) {
-               config = config || {};
-
-               this.controller = controller;
-               this.invertModel = invertModel;
-               this.model = itemModel;
-
-               // Parent
-               mw.rcfilters.ui.FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
-
-               // Event
-               this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
-       };
-
-       /* Initialization */
-       OO.inheritClass( mw.rcfilters.ui.FilterMenuOptionWidget, mw.rcfilters.ui.ItemMenuOptionWidget );
-
-       /* Static properties */
-
-       // We do our own scrolling to top
-       mw.rcfilters.ui.FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
-
-       /* Methods */
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
-               // Parent
-               mw.rcfilters.ui.FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
-
-               this.setCurrentMuteState();
-       };
-
-       /**
-        * Respond to item group model update event
-        */
-       mw.rcfilters.ui.FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
-               this.setCurrentMuteState();
-       };
-
-       /**
-        * Set the current muted view of the widget based on its state
-        */
-       mw.rcfilters.ui.FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
-               if (
-                       this.model.getGroupModel().getView() === 'namespaces' &&
-                       this.invertModel.isSelected()
-               ) {
-                       // This is an inverted behavior than the other rules, specifically
-                       // for inverted namespaces
-                       this.setFlags( {
-                               muted: this.model.isSelected()
-                       } );
-               } else {
-                       this.setFlags( {
-                               muted: (
-                                       this.model.isConflicted() ||
-                                       (
-                                               // Item is also muted when any of the items in its group is active
-                                               this.model.getGroupModel().isActive() &&
-                                               // But it isn't selected
-                                               !this.model.isSelected() &&
-                                               // And also not included
-                                               !this.model.isIncluded()
-                                       )
-                               )
-                       } );
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuSectionOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuSectionOptionWidget.js
deleted file mode 100644 (file)
index 3d598c9..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-( function () {
-       /**
-        * A widget representing a menu section for filter groups
-        *
-        * @class
-        * @extends OO.ui.MenuSectionOptionWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] Overlay
-        */
-       mw.rcfilters.ui.FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
-               var whatsThisMessages,
-                       $header = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
-                       $popupContent = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.$overlay = config.$overlay || this.$element;
-
-               // Parent
-               mw.rcfilters.ui.FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
-                       label: this.model.getTitle(),
-                       $label: $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
-               }, config ) );
-
-               $header.append( this.$label );
-
-               if ( this.model.hasWhatsThis() ) {
-                       whatsThisMessages = this.model.getWhatsThis();
-
-                       // Create popup
-                       if ( whatsThisMessages.header ) {
-                               $popupContent.append(
-                                       ( new OO.ui.LabelWidget( {
-                                               label: mw.msg( whatsThisMessages.header ),
-                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
-                                       } ) ).$element
-                               );
-                       }
-                       if ( whatsThisMessages.body ) {
-                               $popupContent.append(
-                                       ( new OO.ui.LabelWidget( {
-                                               label: mw.msg( whatsThisMessages.body ),
-                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
-                                       } ) ).$element
-                               );
-                       }
-                       if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
-                               $popupContent.append(
-                                       ( new OO.ui.ButtonWidget( {
-                                               framed: false,
-                                               flags: [ 'progressive' ],
-                                               href: whatsThisMessages.url,
-                                               label: mw.msg( whatsThisMessages.linkText ),
-                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
-                                       } ) ).$element
-                               );
-                       }
-
-                       // Add button
-                       this.whatsThisButton = new OO.ui.PopupButtonWidget( {
-                               framed: false,
-                               label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
-                               $overlay: this.$overlay,
-                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
-                               flags: [ 'progressive' ],
-                               popup: {
-                                       padded: false,
-                                       align: 'center',
-                                       position: 'above',
-                                       $content: $popupContent,
-                                       classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
-                               }
-                       } );
-
-                       $header
-                               .append( this.whatsThisButton.$element );
-               }
-
-               // Events
-               this.model.connect( this, { update: 'updateUiBasedOnState' } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
-                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
-                       .append( $header );
-               this.updateUiBasedOnState();
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
-               this.$element.toggleClass(
-                       'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
-                       this.model.isActive()
-               );
-               this.toggle( this.model.isVisible() );
-       };
-
-       /**
-        * Get the group name
-        *
-        * @return {string} Group name
-        */
-       mw.rcfilters.ui.FilterMenuSectionOptionWidget.prototype.getName = function () {
-               return this.model.getName();
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagItemWidget.js
deleted file mode 100644 (file)
index 411ada9..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-( function () {
-       /**
-        * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
-        *
-        * @class
-        * @extends mw.rcfilters.ui.TagItemWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.FilterItem} invertModel
-        * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
-        * @param {Object} config Configuration object
-        */
-       mw.rcfilters.ui.FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
-               controller, filtersViewModel, invertModel, itemModel, config
-       ) {
-               config = config || {};
-
-               mw.rcfilters.ui.FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterTagItemWidget, mw.rcfilters.ui.TagItemWidget );
-
-       /* Methods */
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagItemWidget.prototype.setCurrentMuteState = function () {
-               this.setFlags( {
-                       muted: (
-                               !this.itemModel.isSelected() ||
-                               this.itemModel.isIncluded() ||
-                               this.itemModel.isFullyCovered()
-                       ),
-                       invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
-               } );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js
deleted file mode 100644 (file)
index 6d45144..0000000
+++ /dev/null
@@ -1,770 +0,0 @@
-( function () {
-       /**
-        * List displaying all filter groups
-        *
-        * @class
-        * @extends OO.ui.MenuTagMultiselectWidget
-        * @mixins OO.ui.mixin.PendingElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
-        *  system. If not given, falls back to this widget's $element
-        * @cfg {boolean} [collapsed] Filter area is collapsed
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
-               var rcFiltersRow,
-                       title = new OO.ui.LabelWidget( {
-                               label: mw.msg( 'rcfilters-activefilters' ),
-                               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
-                       } ),
-                       $contentWrapper = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.queriesModel = savedQueriesModel;
-               this.$overlay = config.$overlay || this.$element;
-               this.$wrapper = config.$wrapper || this.$element;
-               this.matchingQuery = null;
-               this.currentView = this.model.getCurrentView();
-               this.collapsed = false;
-
-               // Parent
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
-                       label: mw.msg( 'rcfilters-filterlist-title' ),
-                       placeholder: mw.msg( 'rcfilters-empty-filter' ),
-                       inputPosition: 'outline',
-                       allowArbitrary: false,
-                       allowDisplayInvalidTags: false,
-                       allowReordering: false,
-                       $overlay: this.$overlay,
-                       menu: {
-                               // Our filtering is done through the model
-                               filterFromInput: false,
-                               hideWhenOutOfView: false,
-                               hideOnChoose: false,
-                               width: 650,
-                               footers: [
-                                       {
-                                               name: 'viewSelect',
-                                               sticky: false,
-                                               // View select menu, appears on default view only
-                                               $element: $( '<div>' )
-                                                       .append( new mw.rcfilters.ui.ViewSwitchWidget( this.controller, this.model ).$element ),
-                                               views: [ 'default' ]
-                                       },
-                                       {
-                                               name: 'feedback',
-                                               // Feedback footer, appears on all views
-                                               $element: $( '<div>' )
-                                                       .append(
-                                                               new OO.ui.ButtonWidget( {
-                                                                       framed: false,
-                                                                       icon: 'feedback',
-                                                                       flags: [ 'progressive' ],
-                                                                       label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
-                                                                       href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
-                                                               } ).$element
-                                                       )
-                                       }
-                               ]
-                       },
-                       input: {
-                               icon: 'menu',
-                               placeholder: mw.msg( 'rcfilters-search-placeholder' )
-                       }
-               }, config ) );
-
-               this.savedQueryTitle = new OO.ui.LabelWidget( {
-                       label: '',
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
-               } );
-
-               this.resetButton = new OO.ui.ButtonWidget( {
-                       framed: false,
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
-               } );
-
-               this.hideShowButton = new OO.ui.ButtonWidget( {
-                       framed: false,
-                       flags: [ 'progressive' ],
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
-               } );
-               this.toggleCollapsed( !!config.collapsed );
-
-               if ( !mw.user.isAnon() ) {
-                       this.saveQueryButton = new mw.rcfilters.ui.SaveFiltersPopupButtonWidget(
-                               this.controller,
-                               this.queriesModel,
-                               {
-                                       $overlay: this.$overlay
-                               }
-                       );
-
-                       this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
-                               e.stopPropagation();
-                       } );
-
-                       this.saveQueryButton.connect( this, {
-                               click: 'onSaveQueryButtonClick',
-                               saveCurrent: 'setSavedQueryVisibility'
-                       } );
-                       this.queriesModel.connect( this, {
-                               itemUpdate: 'onSavedQueriesItemUpdate',
-                               initialize: 'onSavedQueriesInitialize',
-                               default: 'reevaluateResetRestoreState'
-                       } );
-               }
-
-               this.emptyFilterMessage = new OO.ui.LabelWidget( {
-                       label: mw.msg( 'rcfilters-empty-filter' ),
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
-               } );
-               this.$content.append( this.emptyFilterMessage.$element );
-
-               // Events
-               this.resetButton.connect( this, { click: 'onResetButtonClick' } );
-               this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
-               // Stop propagation for mousedown, so that the widget doesn't
-               // trigger the focus on the input and scrolls up when we click the reset button
-               this.resetButton.$element.on( 'mousedown', function ( e ) {
-                       e.stopPropagation();
-               } );
-               this.hideShowButton.$element.on( 'mousedown', function ( e ) {
-                       e.stopPropagation();
-               } );
-               this.model.connect( this, {
-                       initialize: 'onModelInitialize',
-                       update: 'onModelUpdate',
-                       searchChange: 'onModelSearchChange',
-                       itemUpdate: 'onModelItemUpdate',
-                       highlightChange: 'onModelHighlightChange'
-               } );
-               this.input.connect( this, { change: 'onInputChange' } );
-
-               // The filter list and button should appear side by side regardless of how
-               // wide the button is; the button also changes its width depending
-               // on language and its state, so the safest way to present both side
-               // by side is with a table layout
-               rcFiltersRow = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-row' )
-                       .append(
-                               this.$content
-                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
-                       );
-
-               if ( !mw.user.isAnon() ) {
-                       rcFiltersRow.append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
-                                       .append( this.saveQueryButton.$element )
-                       );
-               }
-
-               // Add a selector at the right of the input
-               this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
-                       items: [
-                               new OO.ui.ButtonOptionWidget( {
-                                       framed: false,
-                                       data: 'namespaces',
-                                       icon: 'article',
-                                       label: mw.msg( 'namespaces' ),
-                                       title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
-                               } ),
-                               new OO.ui.ButtonOptionWidget( {
-                                       framed: false,
-                                       data: 'tags',
-                                       icon: 'tag',
-                                       label: mw.msg( 'tags-title' ),
-                                       title: mw.msg( 'rcfilters-view-tags-tooltip' )
-                               } )
-                       ]
-               } );
-
-               // Rearrange the UI so the select widget is at the right of the input
-               this.$element.append(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-table' )
-                               .append(
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-row' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
-                                               .append(
-                                                       $( '<div>' )
-                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
-                                                               .append( this.input.$element ),
-                                                       $( '<div>' )
-                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
-                                                               .append( this.viewsSelectWidget.$element )
-                                               )
-                               )
-               );
-
-               // Event
-               this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
-
-               rcFiltersRow.append(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-cell' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
-                               .append( this.resetButton.$element )
-               );
-
-               // Build the content
-               $contentWrapper.append(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
-                               .append(
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
-                                               .append( title.$element ),
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
-                                               .append( this.savedQueryTitle.$element ),
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
-                                               .append(
-                                                       this.hideShowButton.$element
-                                               )
-                               ),
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-table' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
-                               .append( rcFiltersRow )
-               );
-
-               // Initialize
-               this.$handle.append( $contentWrapper );
-               this.emptyFilterMessage.toggle( this.isEmpty() );
-               this.savedQueryTitle.toggle( false );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
-
-               this.reevaluateResetRestoreState();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
-
-       /* Methods */
-
-       /**
-        * Override parent method to avoid unnecessary resize events.
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.updateIfHeightChanged = function () { };
-
-       /**
-        * Respond to view select widget choose event
-        *
-        * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget ) {
-               this.controller.switchView( buttonOptionWidget.getData() );
-               this.viewsSelectWidget.selectItem( null );
-               this.focus();
-       };
-
-       /**
-        * Respond to model search change event
-        *
-        * @param {string} value Search value
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
-               this.input.setValue( value );
-       };
-
-       /**
-        * Respond to input change event
-        *
-        * @param {string} value Value of the input
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
-               this.controller.setSearch( value );
-       };
-
-       /**
-        * Respond to query button click
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
-               this.getMenu().toggle( false );
-       };
-
-       /**
-        * Respond to save query model initialization
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
-               this.setSavedQueryVisibility();
-       };
-
-       /**
-        * Respond to save query item change. Mainly this is done to update the label in case
-        * a query item has been edited
-        *
-        * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
-               if ( this.matchingQuery === item ) {
-                       // This means we just edited the item that is currently matched
-                       this.savedQueryTitle.setLabel( item.getLabel() );
-               }
-       };
-
-       /**
-        * Respond to menu toggle
-        *
-        * @param {boolean} isVisible Menu is visible
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
-               // Parent
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
-
-               if ( isVisible ) {
-                       this.focus();
-
-                       mw.hook( 'RcFilters.popup.open' ).fire();
-
-                       if ( !this.getMenu().findSelectedItem() ) {
-                               // If there are no selected items, scroll menu to top
-                               // This has to be in a setTimeout so the menu has time
-                               // to be positioned and fixed
-                               setTimeout(
-                                       function () {
-                                               this.getMenu().scrollToTop();
-                                       }.bind( this )
-                               );
-                       }
-               } else {
-                       // Clear selection
-                       this.selectTag( null );
-
-                       // Clear the search
-                       this.controller.setSearch( '' );
-
-                       // Log filter grouping
-                       this.controller.trackFilterGroupings( 'filtermenu' );
-
-                       this.blur();
-               }
-
-               this.input.setIcon( isVisible ? 'search' : 'menu' );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onInputFocus = function () {
-               // Parent
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
-
-               // Only scroll to top of the viewport if:
-               // - The widget is more than 20px from the top
-               // - The widget is not above the top of the viewport (do not scroll downwards)
-               //   (This isn't represented because >20 is, anyways and always, bigger than 0)
-               this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.doInputEscape = function () {
-               // Parent
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
-
-               // Blur the input
-               this.input.$input.trigger( 'blur' );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
-               if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
-                       this.menu.toggle();
-
-                       return false;
-               }
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onChangeTags = function () {
-               // If initialized, call parent method.
-               if ( this.controller.isInitialized() ) {
-                       mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
-               }
-
-               this.emptyFilterMessage.toggle( this.isEmpty() );
-       };
-
-       /**
-        * Respond to model initialize event
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
-               this.setSavedQueryVisibility();
-       };
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
-               this.updateElementsForView();
-       };
-
-       /**
-        * Update the elements in the widget to the current view
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
-               var view = this.model.getCurrentView(),
-                       inputValue = this.input.getValue().trim(),
-                       inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
-
-               if ( inputView !== 'default' ) {
-                       // We have a prefix already, remove it
-                       inputValue = inputValue.substr( 1 );
-               }
-
-               if ( inputView !== view ) {
-                       // Add the correct prefix
-                       inputValue = this.model.getViewTrigger( view ) + inputValue;
-               }
-
-               // Update input
-               this.input.setValue( inputValue );
-
-               if ( this.currentView !== view ) {
-                       this.scrollToTop( this.$element );
-                       this.currentView = view;
-               }
-       };
-
-       /**
-        * Set the visibility of the saved query button
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
-               if ( mw.user.isAnon() ) {
-                       return;
-               }
-
-               this.matchingQuery = this.controller.findQueryMatchingCurrentState();
-
-               this.savedQueryTitle.setLabel(
-                       this.matchingQuery ? this.matchingQuery.getLabel() : ''
-               );
-               this.savedQueryTitle.toggle( !!this.matchingQuery );
-               this.saveQueryButton.setDisabled( !!this.matchingQuery );
-               this.saveQueryButton.setTitle( !this.matchingQuery ?
-                       mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
-                       mw.msg( 'rcfilters-savedqueries-already-saved' ) );
-
-               if ( this.matchingQuery ) {
-                       this.emphasize();
-               }
-       };
-
-       /**
-        * Respond to model itemUpdate event
-        * fixme: when a new state is applied to the model this function is called 60+ times in a row
-        *
-        * @param {mw.rcfilters.dm.FilterItem} item Filter item model
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
-               if ( !item.getGroupModel().isHidden() ) {
-                       if (
-                               item.isSelected() ||
-                               (
-                                       this.model.isHighlightEnabled() &&
-                                       item.getHighlightColor()
-                               )
-                       ) {
-                               this.addTag( item.getName(), item.getLabel() );
-                       } else {
-                               // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
-                               if ( this.findItemFromData( item.getName() ) !== null ) {
-                                       this.removeTagByData( item.getName() );
-                               }
-                       }
-               }
-
-               this.setSavedQueryVisibility();
-
-               // Re-evaluate reset state
-               this.reevaluateResetRestoreState();
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
-               return (
-                       this.model.getItemByName( data ) &&
-                       !this.isDuplicateData( data )
-               );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
-               this.controller.toggleFilterSelect( item.model.getName() );
-
-               // Select the tag if it exists, or reset selection otherwise
-               this.selectTag( this.findItemFromData( item.model.getName() ) );
-
-               this.focus();
-       };
-
-       /**
-        * Respond to highlightChange event
-        *
-        * @param {boolean} isHighlightEnabled Highlight is enabled
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
-               var highlightedItems = this.model.getHighlightedItems();
-
-               if ( isHighlightEnabled ) {
-                       // Add capsule widgets
-                       highlightedItems.forEach( function ( filterItem ) {
-                               this.addTag( filterItem.getName(), filterItem.getLabel() );
-                       }.bind( this ) );
-               } else {
-                       // Remove capsule widgets if they're not selected
-                       highlightedItems.forEach( function ( filterItem ) {
-                               if ( !filterItem.isSelected() ) {
-                                       // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
-                                       if ( this.findItemFromData( filterItem.getName() ) !== null ) {
-                                               this.removeTagByData( filterItem.getName() );
-                                       }
-                               }
-                       }.bind( this ) );
-               }
-
-               this.setSavedQueryVisibility();
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
-               var menuOption = this.menu.getItemFromModel( tagItem.getModel() );
-
-               this.menu.setUserSelecting( true );
-               // Parent method
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
-
-               // Switch view
-               this.controller.resetSearchForView( tagItem.getView() );
-
-               this.selectTag( tagItem );
-               this.scrollToTop( menuOption.$element );
-
-               this.menu.setUserSelecting( false );
-       };
-
-       /**
-        * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
-        * If no items are given, reset selection from all.
-        *
-        * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
-        *  omit to deselect all
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
-               var i, len, selected;
-
-               for ( i = 0, len = this.items.length; i < len; i++ ) {
-                       selected = this.items[ i ] === item;
-                       if ( this.items[ i ].isSelected() !== selected ) {
-                               this.items[ i ].toggleSelected( selected );
-                       }
-               }
-       };
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
-               // Parent method
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
-
-               this.controller.clearFilter( tagItem.getName() );
-
-               tagItem.destroy();
-       };
-
-       /**
-        * Respond to click event on the reset button
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
-               if ( this.model.areVisibleFiltersEmpty() ) {
-                       // Reset to default filters
-                       this.controller.resetToDefaults();
-               } else {
-                       // Reset to have no filters
-                       this.controller.emptyFilters();
-               }
-       };
-
-       /**
-        * Respond to hide/show button click
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
-               this.toggleCollapsed();
-       };
-
-       /**
-        * Toggle the collapsed state of the filters widget
-        *
-        * @param {boolean} isCollapsed Widget is collapsed
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
-               isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;
-
-               this.collapsed = isCollapsed;
-
-               if ( isCollapsed ) {
-                       // If we are collapsing, close the menu, in case it was open
-                       // We should make sure the menu closes before the rest of the elements
-                       // are hidden, otherwise there is an unknown error in jQuery as ooui
-                       // sets and unsets properties on the input (which is hidden at that point)
-                       this.menu.toggle( false );
-               }
-               this.input.setDisabled( isCollapsed );
-               this.hideShowButton.setLabel( mw.msg(
-                       isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
-               ) );
-               this.hideShowButton.setTitle( mw.msg(
-                       isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
-               ) );
-
-               // Toggle the wrapper class, so we have min height values correctly throughout
-               this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );
-
-               // Save the state
-               this.controller.updateCollapsedState( isCollapsed );
-       };
-
-       /**
-        * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
-               var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
-                       currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
-                       hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
-
-               this.resetButton.setIcon(
-                       currFiltersAreEmpty ? 'history' : 'trash'
-               );
-
-               this.resetButton.setLabel(
-                       currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
-               );
-               this.resetButton.setTitle(
-                       currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
-               );
-
-               this.resetButton.toggle( !hideResetButton );
-               this.emptyFilterMessage.toggle( currFiltersAreEmpty );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
-               return new mw.rcfilters.ui.MenuSelectWidget(
-                       this.controller,
-                       this.model,
-                       menuConfig
-               );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
-               var filterItem = this.model.getItemByName( data );
-
-               if ( filterItem ) {
-                       return new mw.rcfilters.ui.FilterTagItemWidget(
-                               this.controller,
-                               this.model,
-                               this.model.getInvertModel(),
-                               filterItem,
-                               {
-                                       $overlay: this.$overlay
-                               }
-                       );
-               }
-       };
-
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.emphasize = function () {
-               if (
-                       !this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
-               ) {
-                       this.$handle
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
-
-                       setTimeout( function () {
-                               this.$handle
-                                       .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
-
-                               setTimeout( function () {
-                                       this.$handle
-                                               .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
-                               }.bind( this ), 1000 );
-                       }.bind( this ), 500 );
-
-               }
-       };
-       /**
-        * Scroll the element to top within its container
-        *
-        * @private
-        * @param {jQuery} $element Element to position
-        * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
-        *  much space (in pixels) above the widget.
-        * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
-        * @param {number} [threshold.min] Minimum distance above the element
-        * @param {number} [threshold.max] Minimum distance below the element
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
-               var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
-                       pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
-                       containerScrollTop = $( container ).scrollTop(),
-                       effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
-                       newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );
-
-               // Scroll to item
-               if (
-                       threshold === undefined ||
-                       (
-                               (
-                                       threshold.min === undefined ||
-                                       newScrollTop - containerScrollTop >= threshold.min
-                               ) &&
-                               (
-                                       threshold.max === undefined ||
-                                       newScrollTop - containerScrollTop <= threshold.max
-                               )
-                       )
-               ) {
-                       // eslint-disable-next-line jquery/no-animate
-                       $( container ).animate( {
-                               scrollTop: newScrollTop
-                       } );
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js
deleted file mode 100644 (file)
index 567d86d..0000000
+++ /dev/null
@@ -1,131 +0,0 @@
-( function () {
-       /**
-        * List displaying all filter groups
-        *
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.PendingElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {Object} [config] Configuration object
-        * @cfg {Object} [filters] A definition of the filter groups in this list
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
-        *  system. If not given, falls back to this widget's $element
-        * @cfg {boolean} [collapsed] Filter area is collapsed
-        */
-       mw.rcfilters.ui.FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
-               controller, model, savedQueriesModel, changesListModel, config
-       ) {
-               var $bottom;
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.FilterWrapperWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.PendingElement.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-               this.queriesModel = savedQueriesModel;
-               this.changesListModel = changesListModel;
-               this.$overlay = config.$overlay || this.$element;
-               this.$wrapper = config.$wrapper || this.$element;
-
-               this.filterTagWidget = new mw.rcfilters.ui.FilterTagMultiselectWidget(
-                       this.controller,
-                       this.model,
-                       this.queriesModel,
-                       {
-                               $overlay: this.$overlay,
-                               collapsed: config.collapsed,
-                               $wrapper: this.$wrapper
-                       }
-               );
-
-               this.liveUpdateButton = new mw.rcfilters.ui.LiveUpdateButtonWidget(
-                       this.controller,
-                       this.changesListModel
-               );
-
-               this.numChangesAndDateWidget = new mw.rcfilters.ui.ChangesLimitAndDateButtonWidget(
-                       this.controller,
-                       this.model,
-                       {
-                               $overlay: this.$overlay
-                       }
-               );
-
-               this.showNewChangesLink = new OO.ui.ButtonWidget( {
-                       icon: 'reload',
-                       framed: false,
-                       label: mw.msg( 'rcfilters-show-new-changes' ),
-                       flags: [ 'progressive' ],
-                       classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
-               } );
-
-               // Events
-               this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
-               this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
-               this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
-               this.showNewChangesLink.toggle( false );
-
-               // Initialize
-               this.$top = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
-
-               $bottom = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
-                       .append(
-                               this.showNewChangesLink.$element,
-                               this.numChangesAndDateWidget.$element
-                       );
-
-               if ( mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' ) ) {
-                       $bottom.prepend( this.liveUpdateButton.$element );
-               }
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
-                       .append(
-                               this.$top,
-                               this.filterTagWidget.$element,
-                               $bottom
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.mixin.PendingElement );
-
-       /* Methods */
-
-       /**
-        * Set the content of the top section
-        *
-        * @param {jQuery} $topSectionElement
-        */
-       mw.rcfilters.ui.FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
-               this.$top.append( $topSectionElement );
-       };
-
-       /**
-        * Respond to the user clicking the 'show new changes' button
-        */
-       mw.rcfilters.ui.FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
-               this.controller.showNewChanges();
-       };
-
-       /**
-        * Respond to changes list model newChangesExist
-        *
-        * @param {boolean} newChangesExist Whether new changes exist
-        */
-       mw.rcfilters.ui.FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
-               this.showNewChangesLink.toggle( newChangesExist );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js
deleted file mode 100644 (file)
index a28cde0..0000000
+++ /dev/null
@@ -1,173 +0,0 @@
-( function () {
-       /**
-        * Wrapper for the RC form with hide/show links
-        * Must be constructed after the model is initialized.
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
-        * @param {mw.rcfilters.Controller} controller RCfilters controller
-        * @param {jQuery} $formRoot Root element of the form to attach to
-        * @param {Object} config Configuration object
-        */
-       mw.rcfilters.ui.FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.FormWrapperWidget.parent.call( this, $.extend( {}, config, {
-                       $element: $formRoot
-               } ) );
-
-               this.changeListModel = changeListModel;
-               this.filtersModel = filtersModel;
-               this.controller = controller;
-               this.$submitButton = this.$element.find( 'form input[type=submit]' );
-
-               this.$element
-                       .on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );
-
-               this.$element
-                       .on( 'submit', 'form', this.onFormSubmit.bind( this ) );
-
-               // Events
-               this.changeListModel.connect( this, {
-                       invalidate: 'onChangesModelInvalidate',
-                       update: 'onChangesModelUpdate'
-               } );
-
-               // Initialize
-               this.cleanUpFieldset();
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FormWrapperWidget, OO.ui.Widget );
-
-       /**
-        * Respond to link click
-        *
-        * @param {jQuery.Event} e Event
-        * @return {boolean} false
-        */
-       mw.rcfilters.ui.FormWrapperWidget.prototype.onLinkClick = function ( e ) {
-               this.controller.updateChangesList( $( e.target ).data( 'params' ) );
-               return false;
-       };
-
-       /**
-        * Respond to form submit event
-        *
-        * @param {jQuery.Event} e Event
-        * @return {boolean} false
-        */
-       mw.rcfilters.ui.FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
-               var data = {};
-
-               // Collect all data from form
-               $( e.target ).find( 'input:not([type="hidden"],[type="submit"]), select' ).each( function () {
-                       var value = '';
-
-                       if ( !$( this ).is( ':checkbox' ) || $( this ).is( ':checked' ) ) {
-                               value = $( this ).val();
-                       }
-
-                       data[ $( this ).prop( 'name' ) ] = value;
-               } );
-
-               this.controller.updateChangesList( data );
-               return false;
-       };
-
-       /**
-        * Respond to model invalidate
-        */
-       mw.rcfilters.ui.FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
-               this.$submitButton.prop( 'disabled', true );
-       };
-
-       /**
-        * Respond to model update, replace the show/hide links with the ones from the
-        * server so they feature the correct state.
-        *
-        * @param {jQuery|string} $changesList Updated changes list
-        * @param {jQuery} $fieldset Updated fieldset
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
-        */
-       mw.rcfilters.ui.FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
-               this.$submitButton.prop( 'disabled', false );
-
-               // Replace the entire fieldset
-               this.$element.empty().append( $fieldset.contents() );
-
-               if ( !isInitialDOM ) {
-                       // Make sure enhanced RC re-initializes correctly
-                       mw.hook( 'wikipage.content' ).fire( this.$element );
-               }
-
-               this.cleanUpFieldset();
-       };
-
-       /**
-        * Clean up the old-style show/hide that we have implemented in the filter list
-        */
-       mw.rcfilters.ui.FormWrapperWidget.prototype.cleanUpFieldset = function () {
-               this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
-                       // HACK: Remove the text node after the span.
-                       // If there isn't one, we're at the end, so remove the text node before the span.
-                       // This would be unnecessary if we added separators with CSS.
-                       if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
-                               this.parentNode.removeChild( this.nextSibling );
-                       } else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
-                               this.parentNode.removeChild( this.previousSibling );
-                       }
-                       // Remove the span itself
-                       this.parentNode.removeChild( this );
-               } );
-
-               // Hide namespaces and tags
-               this.$element.find( '.namespaceForm' ).detach();
-               this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
-
-               // Hide Related Changes page name form
-               this.$element.find( '.targetForm' ).detach();
-
-               // misc: limit, days, watchlist info msg
-               this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();
-
-               if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
-                       this.$element.find( '.mw-recentchanges-table' ).detach();
-                       this.$element.find( 'hr' ).detach();
-               }
-
-               // Get rid of all <br>s, which are inside rcshowhide
-               // If we still have content in rcshowhide, the <br>s are
-               // gone. Instead, the CSS now has a rule to mark all <span>s
-               // inside .rcshowhide with display:block; to simulate newlines
-               // where they're actually needed.
-               this.$element.find( 'br' ).detach();
-               if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
-                       this.$element.find( '.rcshowhide' ).detach();
-               }
-
-               if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
-                       this.$element.find( '.cloption-submit' ).detach();
-               }
-
-               this.$element.find(
-                       '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
-               ).detach();
-
-               // Get rid of the legend
-               this.$element.find( 'legend' ).detach();
-
-               // Check if the element is essentially empty, and detach it if it is
-               if ( !this.$element.text().trim().length ) {
-                       this.$element.detach();
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.GroupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.GroupWidget.js
deleted file mode 100644 (file)
index ab49414..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-( function () {
-       /**
-        * A group widget to allow for aggregation of events
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {Object} [config] Configuration object
-        * @param {Object} [events] Events to aggregate. The object represent the
-        *  event name to aggregate and the event value to emit on aggregate for items.
-        */
-       mw.rcfilters.ui.GroupWidget = function MwRcfiltersUiViewSwitchWidget( config ) {
-               var aggregate = {};
-
-               config = config || {};
-
-               // Parent constructor
-               mw.rcfilters.ui.GroupWidget.parent.call( this, config );
-
-               // Mixin constructors
-               OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
-
-               if ( config.events ) {
-                       // Aggregate events
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( config.events, function ( eventName, eventEmit ) {
-                               aggregate[ eventName ] = eventEmit;
-                       } );
-
-                       this.aggregate( aggregate );
-               }
-
-               if ( Array.isArray( config.items ) ) {
-                       this.addItems( config.items );
-               }
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( mw.rcfilters.ui.GroupWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.GroupWidget, OO.ui.mixin.GroupWidget );
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js
deleted file mode 100644 (file)
index a55246f..0000000
+++ /dev/null
@@ -1,122 +0,0 @@
-( function () {
-       /**
-        * A widget representing a filter item highlight color picker
-        *
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.LabelElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
-               var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.HighlightColorPickerWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
-                       label: mw.message( 'rcfilters-highlightmenu-title' ).text()
-               } ) );
-
-               this.controller = controller;
-
-               this.currentSelection = 'none';
-               this.buttonSelect = new OO.ui.ButtonSelectWidget( {
-                       items: colors.map( function ( color ) {
-                               return new OO.ui.ButtonOptionWidget( {
-                                       icon: color === 'none' ? 'check' : null,
-                                       data: color,
-                                       classes: [
-                                               'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
-                                               'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
-                                       ],
-                                       framed: false
-                               } );
-                       } ),
-                       classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
-               } );
-
-               // Event
-               this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
-                       .append(
-                               this.$label
-                                       .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
-                               this.buttonSelect.$element
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.HighlightColorPickerWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.HighlightColorPickerWidget, OO.ui.mixin.LabelElement );
-
-       /* Events */
-
-       /**
-        * @event chooseColor
-        * @param {string} The chosen color
-        *
-        * A color has been chosen
-        */
-
-       /* Methods */
-
-       /**
-        * Bind the color picker to an item
-        * @param {mw.rcfilters.dm.FilterItem} filterItem
-        */
-       mw.rcfilters.ui.HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
-               if ( this.filterItem ) {
-                       this.filterItem.disconnect( this );
-               }
-
-               this.filterItem = filterItem;
-               this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
-               this.updateUiBasedOnModel();
-       };
-
-       /**
-        * Respond to item model update event
-        */
-       mw.rcfilters.ui.HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
-               this.selectColor( this.filterItem.getHighlightColor() || 'none' );
-       };
-
-       /**
-        * Select the color for this widget
-        *
-        * @param {string} color Selected color
-        */
-       mw.rcfilters.ui.HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
-               var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
-                       selectedItem = this.buttonSelect.findItemFromData( color );
-
-               if ( this.currentSelection !== color ) {
-                       this.currentSelection = color;
-
-                       this.buttonSelect.selectItem( selectedItem );
-                       if ( previousItem ) {
-                               previousItem.setIcon( null );
-                       }
-
-                       if ( selectedItem ) {
-                               selectedItem.setIcon( 'check' );
-                       }
-               }
-       };
-
-       mw.rcfilters.ui.HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
-               var color = button.data;
-               if ( color === 'none' ) {
-                       this.controller.clearHighlightColor( this.filterItem.getName() );
-               } else {
-                       this.controller.setHighlightColor( this.filterItem.getName(), color );
-               }
-               this.emit( 'chooseColor', color );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightPopupWidget.js
deleted file mode 100644 (file)
index 2dd0379..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-( function () {
-       /**
-        * A popup containing a color picker, for setting highlight colors.
-        *
-        * @extends OO.ui.PopupWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.HighlightPopupWidget.parent.call( this, $.extend( {
-                       autoClose: true,
-                       anchor: false,
-                       padded: true,
-                       align: 'backwards',
-                       horizontalPosition: 'end',
-                       width: 290
-               }, config ) );
-
-               this.colorPicker = new mw.rcfilters.ui.HighlightColorPickerWidget( controller );
-
-               this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
-
-               this.$body.append( this.colorPicker.$element );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.HighlightPopupWidget, OO.ui.PopupWidget );
-
-       /* Methods */
-
-       /**
-        * Set the button (or other widget) that this popup should hang off.
-        *
-        * @param {OO.ui.Widget} widget Widget the popup should orient itself to
-        */
-       mw.rcfilters.ui.HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
-               this.setFloatableContainer( widget.$element );
-               this.$autoCloseIgnore = widget.$element;
-       };
-
-       /**
-        * Set the filter item that this popup should control the highlight color for.
-        *
-        * @param {mw.rcfilters.dm.FilterItem} item
-        */
-       mw.rcfilters.ui.HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
-               this.colorPicker.setFilterItem( item );
-       };
-
-       /**
-        * When the user chooses a color in the color picker, close the popup.
-        */
-       mw.rcfilters.ui.HighlightPopupWidget.prototype.onChooseColor = function () {
-               this.toggle( false );
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js
deleted file mode 100644 (file)
index cda13eb..0000000
+++ /dev/null
@@ -1,165 +0,0 @@
-( function () {
-       /**
-        * A widget representing a base toggle item
-        *
-        * @extends OO.ui.MenuOptionWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.ItemModel} invertModel
-        * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
-        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
-        * @param {Object} config Configuration object
-        */
-       mw.rcfilters.ui.ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
-               controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
-       ) {
-               var layout,
-                       classes = [],
-                       $label = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.filtersViewModel = filtersViewModel;
-               this.invertModel = invertModel;
-               this.itemModel = itemModel;
-
-               // Parent
-               mw.rcfilters.ui.ItemMenuOptionWidget.parent.call( this, $.extend( {
-                       // Override the 'check' icon that OOUI defines
-                       icon: '',
-                       data: this.itemModel.getName(),
-                       label: this.itemModel.getLabel()
-               }, config ) );
-
-               this.checkboxWidget = new mw.rcfilters.ui.CheckboxInputWidget( {
-                       value: this.itemModel.getName(),
-                       selected: this.itemModel.isSelected()
-               } );
-
-               $label.append(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
-                               .append( $( '<bdi>' ).append( this.$label ) )
-               );
-               if ( this.itemModel.getDescription() ) {
-                       $label.append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
-                                       .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
-                       );
-               }
-
-               this.highlightButton = new mw.rcfilters.ui.FilterItemHighlightButton(
-                       this.controller,
-                       this.itemModel,
-                       highlightPopup,
-                       {
-                               $overlay: config.$overlay || this.$element,
-                               title: mw.msg( 'rcfilters-highlightmenu-help' )
-                       }
-               );
-               this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
-
-               this.excludeLabel = new OO.ui.LabelWidget( {
-                       label: mw.msg( 'rcfilters-filter-excluded' )
-               } );
-               this.excludeLabel.toggle(
-                       this.itemModel.getGroupModel().getView() === 'namespaces' &&
-                       this.itemModel.isSelected() &&
-                       this.invertModel.isSelected()
-               );
-
-               layout = new OO.ui.FieldLayout( this.checkboxWidget, {
-                       label: $label,
-                       align: 'inline'
-               } );
-
-               // Events
-               this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
-               this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
-               this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
-               // HACK: Prevent defaults on 'click' for the label so it
-               // doesn't steal the focus away from the input. This means
-               // we can continue arrow-movement after we click the label
-               // and is consistent with the checkbox *itself* also preventing
-               // defaults on 'click' as well.
-               layout.$label.on( 'click', false );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
-                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView() )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
-                                                                       .append( layout.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
-                                                                       .append( this.excludeLabel.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
-                                                                       .append( this.highlightButton.$element )
-                                                       )
-                                       )
-                       );
-
-               if ( this.itemModel.getIdentifiers() ) {
-                       this.itemModel.getIdentifiers().forEach( function ( ident ) {
-                               classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
-                       } );
-
-                       this.$element.addClass( classes );
-               }
-
-               this.updateUiBasedOnState();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
-
-       /* Static properties */
-
-       // We do our own scrolling to top
-       mw.rcfilters.ui.ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
-
-       /* Methods */
-
-       /**
-        * Respond to item model update event
-        */
-       mw.rcfilters.ui.ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
-               this.checkboxWidget.setSelected( this.itemModel.isSelected() );
-
-               this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
-               this.excludeLabel.toggle(
-                       this.itemModel.getGroupModel().getView() === 'namespaces' &&
-                       this.itemModel.isSelected() &&
-                       this.invertModel.isSelected()
-               );
-               this.toggle( this.itemModel.isVisible() );
-       };
-
-       /**
-        * Get the name of this filter
-        *
-        * @return {string} Filter name
-        */
-       mw.rcfilters.ui.ItemMenuOptionWidget.prototype.getName = function () {
-               return this.itemModel.getName();
-       };
-
-       mw.rcfilters.ui.ItemMenuOptionWidget.prototype.getModel = function () {
-               return this.itemModel;
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js
deleted file mode 100644 (file)
index 926ff4a..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-( function () {
-       /**
-        * Widget for toggling live updates
-        *
-        * @extends OO.ui.ToggleButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.LiveUpdateButtonWidget.parent.call( this, $.extend( {
-                       label: mw.message( 'rcfilters-liveupdates-button' ).text()
-               }, config ) );
-
-               this.controller = controller;
-               this.model = changesListModel;
-
-               // Events
-               this.connect( this, { click: 'onClick' } );
-               this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
-
-               this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
-
-               this.setState( false );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
-
-       /* Methods */
-
-       /**
-        * Respond to the button being clicked
-        */
-       mw.rcfilters.ui.LiveUpdateButtonWidget.prototype.onClick = function () {
-               this.controller.toggleLiveUpdate();
-       };
-
-       /**
-        * Set the button's state and change its appearance
-        *
-        * @param {boolean} enable Whether the 'live update' feature is now on/off
-        */
-       mw.rcfilters.ui.LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
-               this.setValue( enable );
-               this.setIcon( enable ? 'stop' : 'play' );
-               this.setTitle( mw.message(
-                       enable ?
-                               'rcfilters-liveupdates-button-title-on' :
-                               'rcfilters-liveupdates-button-title-off'
-               ).text() );
-       };
-
-       /**
-        * Respond to the 'live update' feature being turned on/off
-        *
-        * @param {boolean} enable Whether the 'live update' feature is now on/off
-        */
-       mw.rcfilters.ui.LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
-               this.setState( enable );
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MainWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MainWrapperWidget.js
deleted file mode 100644 (file)
index b402627..0000000
+++ /dev/null
@@ -1,130 +0,0 @@
-( function () {
-       /**
-        * Wrapper for changes list content
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} $topSection Top section container
-        * @cfg {jQuery} $filtersContainer
-        * @cfg {jQuery} $changesListContainer
-        * @cfg {jQuery} $formContainer
-        * @cfg {boolean} [collapsed] Filter area is collapsed
-        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
-        *  system. If not given, falls back to this widget's $element
-        */
-       mw.rcfilters.ui.MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
-               controller, model, savedQueriesModel, changesListModel, config
-       ) {
-               config = $.extend( {}, config );
-
-               // Parent
-               mw.rcfilters.ui.MainWrapperWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-               this.changesListModel = changesListModel;
-               this.$topSection = config.$topSection;
-               this.$filtersContainer = config.$filtersContainer;
-               this.$changesListContainer = config.$changesListContainer;
-               this.$formContainer = config.$formContainer;
-               this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay' );
-               this.$wrapper = config.$wrapper || this.$element;
-
-               this.savedLinksListWidget = new mw.rcfilters.ui.SavedLinksListWidget(
-                       controller, savedQueriesModel, { $overlay: this.$overlay }
-               );
-
-               this.filtersWidget = new mw.rcfilters.ui.FilterWrapperWidget(
-                       controller,
-                       model,
-                       savedQueriesModel,
-                       changesListModel,
-                       {
-                               $overlay: this.$overlay,
-                               $wrapper: this.$wrapper,
-                               collapsed: config.collapsed
-                       }
-               );
-
-               this.changesListWidget = new mw.rcfilters.ui.ChangesListWrapperWidget(
-                       model, changesListModel, controller, this.$changesListContainer );
-
-               /* Events */
-
-               // Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
-               // to prevent users from accidentally clicking on links in results, while menu is opened.
-               // Overlay on changes list is not the same as this.$overlay
-               this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );
-
-               // Initialize
-               this.$filtersContainer.append( this.filtersWidget.$element );
-               $( 'body' )
-                       .append( this.$overlay )
-                       .addClass( 'mw-rcfilters-ui-initialized' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.MainWrapperWidget, OO.ui.Widget );
-
-       /* Methods */
-
-       /**
-        * Set the content of the top section, depending on the type of special page.
-        *
-        * @param {string} specialPage
-        */
-       mw.rcfilters.ui.MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
-               var topSection;
-
-               if ( specialPage === 'Recentchanges' ) {
-                       topSection = new mw.rcfilters.ui.RcTopSectionWidget(
-                               this.savedLinksListWidget, this.$topSection
-                       );
-                       this.filtersWidget.setTopSection( topSection.$element );
-               }
-
-               if ( specialPage === 'Recentchangeslinked' ) {
-                       topSection = new mw.rcfilters.ui.RclTopSectionWidget(
-                               this.savedLinksListWidget, this.controller,
-                               this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
-                               this.model.getGroup( 'page' ).getItemByParamName( 'target' )
-                       );
-
-                       this.filtersWidget.setTopSection( topSection.$element );
-               }
-
-               if ( specialPage === 'Watchlist' ) {
-                       topSection = new mw.rcfilters.ui.WatchlistTopSectionWidget(
-                               this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
-                       );
-
-                       this.filtersWidget.setTopSection( topSection.$element );
-               }
-       };
-
-       /**
-        * Filter menu toggle event listener
-        *
-        * @param {boolean} isVisible
-        */
-       mw.rcfilters.ui.MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
-               this.changesListWidget.toggleOverlay( isVisible );
-       };
-
-       /**
-        * Initialize FormWrapperWidget
-        *
-        * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
-        */
-       mw.rcfilters.ui.MainWrapperWidget.prototype.initFormWidget = function () {
-               return new mw.rcfilters.ui.FormWrapperWidget(
-                       this.model, this.changesListModel, this.controller, this.$formContainer );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js
deleted file mode 100644 (file)
index 328be8c..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-( function () {
-       /**
-        * Button for marking all changes as seen on the Watchlist
-        *
-        * @extends OO.ui.ButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.MarkSeenButtonWidget.parent.call( this, $.extend( {
-                       label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
-                       icon: 'checkAll'
-               }, config ) );
-
-               this.controller = controller;
-               this.model = model;
-
-               // Events
-               this.connect( this, { click: 'onClick' } );
-               this.model.connect( this, { update: 'onModelUpdate' } );
-
-               this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );
-
-               this.onModelUpdate();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.MarkSeenButtonWidget, OO.ui.ButtonWidget );
-
-       /* Methods */
-
-       /**
-        * Respond to the button being clicked
-        */
-       mw.rcfilters.ui.MarkSeenButtonWidget.prototype.onClick = function () {
-               this.controller.markAllChangesAsSeen();
-               // assume there's no more unseen changes until the next model update
-               this.setDisabled( true );
-       };
-
-       /**
-        * Respond to the model being updated with new changes
-        */
-       mw.rcfilters.ui.MarkSeenButtonWidget.prototype.onModelUpdate = function () {
-               this.setDisabled( !this.model.hasUnseenWatchedChanges() );
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js
deleted file mode 100644 (file)
index 49f980c..0000000
+++ /dev/null
@@ -1,359 +0,0 @@
-( function () {
-       /**
-        * A floating menu widget for the filter list
-        *
-        * @extends OO.ui.MenuSelectWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        * @cfg {Object[]} [footers] An array of objects defining the footers for
-        *  this menu, with a definition whether they appear per specific views.
-        *  The expected structure is:
-        *  [
-        *     {
-        *        name: {string} A unique name for the footer object
-        *        $element: {jQuery} A jQuery object for the content of the footer
-        *        views: {string[]} Optional. An array stating which views this footer is
-        *               active on. Use null or omit to display this on all views.
-        *     }
-        *  ]
-        */
-       mw.rcfilters.ui.MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
-               var header;
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.currentView = '';
-               this.views = {};
-               this.userSelecting = false;
-
-               this.menuInitialized = false;
-               this.$overlay = config.$overlay || this.$element;
-               this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
-               this.footers = [];
-
-               // Parent
-               mw.rcfilters.ui.MenuSelectWidget.parent.call( this, $.extend( config, {
-                       $autoCloseIgnore: this.$overlay,
-                       width: 650,
-                       // Our filtering is done through the model
-                       filterFromInput: false
-               } ) );
-               this.setGroupElement(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
-               );
-               this.setClippableElement( this.$body );
-               this.setClippableContainer( this.$element );
-
-               header = new mw.rcfilters.ui.FilterMenuHeaderWidget(
-                       this.controller,
-                       this.model,
-                       {
-                               $overlay: this.$overlay
-                       }
-               );
-
-               this.noResults = new OO.ui.LabelWidget( {
-                       label: mw.msg( 'rcfilters-filterlist-noresults' ),
-                       classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
-               } );
-
-               // Events
-               this.model.connect( this, {
-                       initialize: 'onModelInitialize',
-                       searchChange: 'onModelSearchChange'
-               } );
-
-               // Initialization
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
-                       .append( header.$element )
-                       .append(
-                               this.$body
-                                       .append( this.$group, this.noResults.$element )
-                       );
-
-               // Append all footers; we will control their visibility
-               // based on view
-               config.footers = config.footers || [];
-               config.footers.forEach( function ( footerData ) {
-                       var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
-                               adjustedData = {
-                                       // Wrap the element with our own footer wrapper
-                                       $element: $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
-                                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
-                                               .append( footerData.$element ),
-                                       views: footerData.views
-                               };
-
-                       if ( !footerData.disabled ) {
-                               this.footers.push( adjustedData );
-
-                               if ( isSticky ) {
-                                       this.$element.append( adjustedData.$element );
-                               } else {
-                                       this.$body.append( adjustedData.$element );
-                               }
-                       }
-               }.bind( this ) );
-
-               // Switch to the correct view
-               this.updateView();
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( mw.rcfilters.ui.MenuSelectWidget, OO.ui.MenuSelectWidget );
-
-       /* Events */
-
-       /* Methods */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.onModelSearchChange = function () {
-               this.updateView();
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.toggle = function ( show ) {
-               this.lazyMenuCreation();
-               mw.rcfilters.ui.MenuSelectWidget.parent.prototype.toggle.call( this, show );
-               // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
-               this.setVerticalPosition( 'below' );
-       };
-
-       /**
-        * lazy creation of the menu
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.lazyMenuCreation = function () {
-               var widget = this,
-                       items = [],
-                       viewGroupCount = {},
-                       groups = this.model.getFilterGroups();
-
-               if ( this.menuInitialized ) {
-                       return;
-               }
-
-               this.menuInitialized = true;
-
-               // Create shared popup for highlight buttons
-               this.highlightPopup = new mw.rcfilters.ui.HighlightPopupWidget( this.controller );
-               this.$overlay.append( this.highlightPopup.$element );
-
-               // Count groups per view
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( groups, function ( groupName, groupModel ) {
-                       if ( !groupModel.isHidden() ) {
-                               viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
-                               viewGroupCount[ groupModel.getView() ]++;
-                       }
-               } );
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( groups, function ( groupName, groupModel ) {
-                       var currentItems = [],
-                               view = groupModel.getView();
-
-                       if ( !groupModel.isHidden() ) {
-                               if ( viewGroupCount[ view ] > 1 ) {
-                                       // Only add a section header if there is more than
-                                       // one group
-                                       currentItems.push(
-                                               // Group section
-                                               new mw.rcfilters.ui.FilterMenuSectionOptionWidget(
-                                                       widget.controller,
-                                                       groupModel,
-                                                       {
-                                                               $overlay: widget.$overlay
-                                                       }
-                                               )
-                                       );
-                               }
-
-                               // Add items
-                               widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
-                                       currentItems.push(
-                                               new mw.rcfilters.ui.FilterMenuOptionWidget(
-                                                       widget.controller,
-                                                       widget.model,
-                                                       widget.model.getInvertModel(),
-                                                       filterItem,
-                                                       widget.highlightPopup,
-                                                       {
-                                                               $overlay: widget.$overlay
-                                                       }
-                                               )
-                                       );
-                               } );
-
-                               // Cache the items per view, so we can switch between them
-                               // without rebuilding the widgets each time
-                               widget.views[ view ] = widget.views[ view ] || [];
-                               widget.views[ view ] = widget.views[ view ].concat( currentItems );
-                               items = items.concat( currentItems );
-                       }
-               } );
-
-               this.addItems( items );
-               this.updateView();
-       };
-
-       /**
-        * Respond to model initialize event. Populate the menu from the model
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.onModelInitialize = function () {
-               this.menuInitialized = false;
-               // Set timeout for the menu to lazy build.
-               setTimeout( this.lazyMenuCreation.bind( this ) );
-       };
-
-       /**
-        * Update view
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.updateView = function () {
-               var viewName = this.model.getCurrentView();
-
-               if ( this.views[ viewName ] && this.currentView !== viewName ) {
-                       this.updateFooterVisibility( viewName );
-
-                       this.$element
-                               .data( 'view', viewName )
-                               .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
-                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
-
-                       this.currentView = viewName;
-                       this.scrollToTop();
-               }
-
-               this.postProcessItems();
-               this.clip();
-       };
-
-       /**
-        * Go over the available footers and decide which should be visible
-        * for this view
-        *
-        * @param {string} [currentView] Current view
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
-               currentView = currentView || this.model.getCurrentView();
-
-               this.footers.forEach( function ( data ) {
-                       data.$element.toggle(
-                               // This footer should only be shown if it is configured
-                               // for all views or for this specific view
-                               !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
-                       );
-               } );
-       };
-
-       /**
-        * Post-process items after the visibility changed. Make sure
-        * that we always have an item selected, and that the no-results
-        * widget appears if the menu is empty.
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.postProcessItems = function () {
-               var i,
-                       itemWasSelected = false,
-                       items = this.getItems();
-
-               // If we are not already selecting an item, always make sure
-               // that the top item is selected
-               if ( !this.userSelecting ) {
-                       // Select the first item in the list
-                       for ( i = 0; i < items.length; i++ ) {
-                               if (
-                                       !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
-                                       items[ i ].isVisible()
-                               ) {
-                                       itemWasSelected = true;
-                                       this.selectItem( items[ i ] );
-                                       break;
-                               }
-                       }
-
-                       if ( !itemWasSelected ) {
-                               this.selectItem( null );
-                       }
-               }
-
-               this.noResults.toggle( !this.getItems().some( function ( item ) {
-                       return item.isVisible();
-               } ) );
-       };
-
-       /**
-        * Get the option widget that matches the model given
-        *
-        * @param {mw.rcfilters.dm.ItemModel} model Item model
-        * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
-               this.lazyMenuCreation();
-               return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
-                       return item.getName() === model.getName();
-               } )[ 0 ];
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
-               var nextItem,
-                       currentItem = this.findHighlightedItem() || this.findSelectedItem();
-
-               // Call parent
-               mw.rcfilters.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
-
-               // We want to select the item on arrow movement
-               // rather than just highlight it, like the menu
-               // does by default
-               if ( !this.isDisabled() && this.isVisible() ) {
-                       switch ( e.keyCode ) {
-                               case OO.ui.Keys.UP:
-                               case OO.ui.Keys.LEFT:
-                                       // Get the next item
-                                       nextItem = this.findRelativeSelectableItem( currentItem, -1 );
-                                       break;
-                               case OO.ui.Keys.DOWN:
-                               case OO.ui.Keys.RIGHT:
-                                       // Get the next item
-                                       nextItem = this.findRelativeSelectableItem( currentItem, 1 );
-                                       break;
-                       }
-
-                       nextItem = nextItem && nextItem.constructor.static.selectable ?
-                               nextItem : null;
-
-                       // Select the next item
-                       this.selectItem( nextItem );
-               }
-       };
-
-       /**
-        * Scroll to the top of the menu
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.scrollToTop = function () {
-               this.$body.scrollTop( 0 );
-       };
-
-       /**
-        * Set whether the user is currently selecting an item.
-        * This is important when the user selects an item that is in between
-        * different views, and makes sure we do not re-select a different
-        * item (like the item on top) when this is happening.
-        *
-        * @param {boolean} isSelecting User is selecting
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
-               this.userSelecting = !!isSelecting;
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js
deleted file mode 100644 (file)
index e3d5575..0000000
+++ /dev/null
@@ -1,113 +0,0 @@
-( function () {
-       /**
-        * Top section (between page title and filters) on Special:Recentchanges
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
-        * @param {jQuery} $topLinks Content of the community-defined links
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
-               savedLinksListWidget, $topLinks, config
-       ) {
-               var toplinksTitle,
-                       topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
-                       topLinksCookie = mw.cookie.get( topLinksCookieName ),
-                       topLinksCookieValue = topLinksCookie || 'collapsed',
-                       widget = this;
-
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.RcTopSectionWidget.parent.call( this, config );
-
-               this.$topLinks = $topLinks;
-
-               toplinksTitle = new OO.ui.ButtonWidget( {
-                       framed: false,
-                       indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
-                       flags: [ 'progressive' ],
-                       label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
-               } );
-
-               this.$topLinks
-                       .makeCollapsible( {
-                               collapsed: topLinksCookieValue === 'collapsed',
-                               $customTogglers: toplinksTitle.$element
-                       } )
-                       .on( 'beforeExpand.mw-collapsible', function () {
-                               mw.cookie.set( topLinksCookieName, 'expanded' );
-                               toplinksTitle.setIndicator( 'up' );
-                               widget.switchTopLinks( 'expanded' );
-                       } )
-                       .on( 'beforeCollapse.mw-collapsible', function () {
-                               mw.cookie.set( topLinksCookieName, 'collapsed' );
-                               toplinksTitle.setIndicator( 'down' );
-                               widget.switchTopLinks( 'collapsed' );
-                       } );
-
-               this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
-                       .replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );
-
-               // Create two positions for the toplinks to toggle between
-               // in the table (first cell) or up above it
-               this.$top = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
-               this.$tableTopLinks = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-cell' )
-                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               this.$tableTopLinks,
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-table-placeholder' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' ),
-                                                               !mw.user.isAnon() ?
-                                                                       $( '<div>' )
-                                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                                               .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
-                                                                               .append( savedLinksListWidget.$element ) :
-                                                                       null
-                                                       )
-                                       )
-                       );
-
-               // Hack: For jumpiness reasons, this should be a sibling of -head
-               $( '.rcfilters-head' ).before( this.$top );
-
-               // Initialize top links position
-               widget.switchTopLinks( topLinksCookieValue );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.RcTopSectionWidget, OO.ui.Widget );
-
-       /**
-        * Switch the top links widget from inside the table (when collapsed)
-        * to the 'top' (when open)
-        *
-        * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
-        */
-       mw.rcfilters.ui.RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
-               state = state || 'expanded';
-
-               if ( state === 'expanded' ) {
-                       this.$top.append( this.$topLinks );
-               } else {
-                       this.$tableTopLinks.append( this.$topLinks );
-               }
-               this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js
deleted file mode 100644 (file)
index dc76085..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-( function () {
-       /**
-        * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FilterItem} targetPageModel
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
-               controller, targetPageModel, config
-       ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.RclTargetPageWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = targetPageModel;
-
-               this.titleSearch = new mw.widgets.TitleInputWidget( {
-                       validate: false,
-                       placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
-                       showImages: true,
-                       showDescriptions: true,
-                       addQueryInput: false
-               } );
-
-               // Events
-               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
-
-               this.titleSearch.$input.on( {
-                       blur: this.onLookupInputBlur.bind( this )
-               } );
-
-               this.titleSearch.lookupMenu.connect( this, {
-                       choose: 'onLookupMenuItemChoose'
-               } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
-                       .append( this.titleSearch.$element );
-
-               this.updateUiBasedOnModel();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.RclTargetPageWidget, OO.ui.Widget );
-
-       /* Methods */
-
-       /**
-        * Respond to the user choosing a title
-        */
-       mw.rcfilters.ui.RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
-               this.titleSearch.$input.trigger( 'blur' );
-       };
-
-       /**
-        * Respond to titleSearch $input blur
-        */
-       mw.rcfilters.ui.RclTargetPageWidget.prototype.onLookupInputBlur = function () {
-               this.controller.setTargetPage( this.titleSearch.getQueryValue() );
-       };
-
-       /**
-        * Respond to the model being updated
-        */
-       mw.rcfilters.ui.RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
-               var title = mw.Title.newFromText( this.model.getValue() ),
-                       text = title ? title.toText() : this.model.getValue();
-               this.titleSearch.setValue( text );
-               this.titleSearch.setTitle( text );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js
deleted file mode 100644 (file)
index 8925dcf..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-( function () {
-       /**
-        * Widget to select to view changes that link TO or FROM the target page
-        * on Special:RecentChangesLinked (AKA Related Changes)
-        *
-        * @extends OO.ui.DropdownWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
-               controller, showLinkedToModel, config
-       ) {
-               config = config || {};
-
-               this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
-                       data: 'from', // default (showlinkedto=0)
-                       label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
-               } );
-               this.showLinkedTo = new OO.ui.MenuOptionWidget( {
-                       data: 'to', // showlinkedto=1
-                       label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
-               } );
-
-               // Parent
-               mw.rcfilters.ui.RclToOrFromWidget.parent.call( this, $.extend( {
-                       classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
-                       menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
-               }, config ) );
-
-               this.controller = controller;
-               this.model = showLinkedToModel;
-
-               this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
-               this.model.connect( this, { update: 'onModelUpdate' } );
-
-               // force an initial update of the component based on the state
-               this.onModelUpdate();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.RclToOrFromWidget, OO.ui.DropdownWidget );
-
-       /* Methods */
-
-       /**
-        * Respond to the user choosing an item in the menu
-        *
-        * @param {OO.ui.MenuOptionWidget} chosenItem
-        */
-       mw.rcfilters.ui.RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
-               this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
-       };
-
-       /**
-        * Respond to model update
-        */
-       mw.rcfilters.ui.RclToOrFromWidget.prototype.onModelUpdate = function () {
-               this.getMenu().selectItem(
-                       this.model.isSelected() ?
-                               this.showLinkedTo :
-                               this.showLinkedFrom
-               );
-               this.setLabel( mw.msg(
-                       this.model.isSelected() ?
-                               'rcfilters-filter-showlinkedto-label' :
-                               'rcfilters-filter-showlinkedfrom-label'
-               ) );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js
deleted file mode 100644 (file)
index 7488254..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-( function () {
-       /**
-        * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
-        * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
-               savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
-       ) {
-               var toOrFromWidget,
-                       targetPage;
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.RclTopSectionWidget.parent.call( this, config );
-
-               this.controller = controller;
-
-               toOrFromWidget = new mw.rcfilters.ui.RclToOrFromWidget( controller, showLinkedToModel );
-               targetPage = new mw.rcfilters.ui.RclTargetPageWidget( controller, targetPageModel );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .append( toOrFromWidget.$element )
-                                                       ),
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .append( targetPage.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-table-placeholder' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' ),
-                                                               !mw.user.isAnon() ?
-                                                                       $( '<div>' )
-                                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                                               .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
-                                                                               .append( savedLinksListWidget.$element ) :
-                                                                       null
-                                                       )
-                                       )
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.RclTopSectionWidget, OO.ui.Widget );
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js
deleted file mode 100644 (file)
index ae1ec90..0000000
+++ /dev/null
@@ -1,188 +0,0 @@
-( function () {
-       /**
-        * Save filters widget. This widget is displayed in the tag area
-        * and allows the user to save the current state of the system
-        * as a new saved filter query they can later load or set as
-        * default.
-        *
-        * @extends OO.ui.PopupButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
-               var layout,
-                       checkBoxLayout,
-                       $popupContent = $( '<div>' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-
-               // Parent
-               mw.rcfilters.ui.SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
-                       framed: false,
-                       icon: 'bookmark',
-                       title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
-                       popup: {
-                               classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
-                               padded: true,
-                               head: true,
-                               label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
-                               $content: $popupContent
-                       }
-               }, config ) );
-               // // HACK: Add an icon to the popup head label
-               this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
-
-               this.input = new OO.ui.TextInputWidget( {
-                       placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
-               } );
-               layout = new OO.ui.FieldLayout( this.input, {
-                       label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
-                       align: 'top'
-               } );
-
-               this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
-               checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
-                       label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
-                       align: 'inline'
-               } );
-
-               this.applyButton = new OO.ui.ButtonWidget( {
-                       label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
-                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
-                       flags: [ 'primary', 'progressive' ]
-               } );
-               this.cancelButton = new OO.ui.ButtonWidget( {
-                       label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
-                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
-               } );
-
-               $popupContent
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
-                                       .append( layout.$element ),
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
-                                       .append( checkBoxLayout.$element ),
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
-                                       .append(
-                                               this.cancelButton.$element,
-                                               this.applyButton.$element
-                                       )
-                       );
-
-               // Events
-               this.popup.connect( this, {
-                       ready: 'onPopupReady'
-               } );
-               this.input.connect( this, {
-                       change: 'onInputChange',
-                       enter: 'onInputEnter'
-               } );
-               this.input.$input.on( {
-                       keyup: this.onInputKeyup.bind( this )
-               } );
-               this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
-               this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
-               this.applyButton.connect( this, { click: 'onApplyButtonClick' } );
-
-               // Initialize
-               this.applyButton.setDisabled( !this.input.getValue() );
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
-       };
-
-       /* Initialization */
-       OO.inheritClass( mw.rcfilters.ui.SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );
-
-       /**
-        * Respond to input enter event
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
-               this.apply();
-       };
-
-       /**
-        * Respond to input change event
-        *
-        * @param {string} value Input value
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
-               value = value.trim();
-
-               this.applyButton.setDisabled( !value );
-       };
-
-       /**
-        * Respond to input keyup event, this is the way to intercept 'escape' key
-        *
-        * @param {jQuery.Event} e Event data
-        * @return {boolean} false
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
-               if ( e.which === OO.ui.Keys.ESCAPE ) {
-                       this.popup.toggle( false );
-                       return false;
-               }
-       };
-
-       /**
-        * Respond to popup ready event
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
-               this.input.focus();
-       };
-
-       /**
-        * Respond to "set as default" checkbox change
-        * @param {boolean} checked State of the checkbox
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
-               var messageKey = checked ?
-                       'rcfilters-savedqueries-apply-and-setdefault-label' :
-                       'rcfilters-savedqueries-apply-label';
-
-               this.applyButton
-                       .setIcon( checked ? 'pushPin' : null )
-                       .setLabel( mw.msg( messageKey ) );
-       };
-
-       /**
-        * Respond to cancel button click event
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
-               this.popup.toggle( false );
-       };
-
-       /**
-        * Respond to apply button click event
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
-               this.apply();
-       };
-
-       /**
-        * Apply and add the new quick link
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.apply = function () {
-               var label = this.input.getValue().trim();
-
-               // This condition is more for sanity-check, since the
-               // apply button should be disabled if the label is empty
-               if ( label ) {
-                       this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
-                       this.input.setValue( '' );
-                       this.setAsDefaultCheckbox.setSelected( false );
-                       this.popup.toggle( false );
-
-                       this.emit( 'saveCurrent' );
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js
deleted file mode 100644 (file)
index f1364d1..0000000
+++ /dev/null
@@ -1,331 +0,0 @@
-( function () {
-       /**
-        * Quick links menu option widget
-        *
-        * @class
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.LabelElement
-        * @mixins OO.ui.mixin.IconElement
-        * @mixins OO.ui.mixin.TitledElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
-               config = config || {};
-
-               this.model = model;
-
-               // Parent
-               mw.rcfilters.ui.SavedLinksListItemWidget.parent.call( this, $.extend( {
-                       data: this.model.getID()
-               }, config ) );
-
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, $.extend( {
-                       label: this.model.getLabel()
-               }, config ) );
-               OO.ui.mixin.IconElement.call( this, $.extend( {
-                       icon: ''
-               }, config ) );
-               OO.ui.mixin.TitledElement.call( this, $.extend( {
-                       title: this.model.getLabel()
-               }, config ) );
-
-               this.edit = false;
-               this.$overlay = config.$overlay || this.$element;
-
-               this.popupButton = new OO.ui.ButtonWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
-                       icon: 'ellipsis',
-                       framed: false
-               } );
-               this.menu = new OO.ui.MenuSelectWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
-                       widget: this.popupButton,
-                       width: 200,
-                       horizontalPosition: 'end',
-                       $floatableContainer: this.popupButton.$element,
-                       items: [
-                               new OO.ui.MenuOptionWidget( {
-                                       data: 'edit',
-                                       icon: 'edit',
-                                       label: mw.msg( 'rcfilters-savedqueries-rename' )
-                               } ),
-                               new OO.ui.MenuOptionWidget( {
-                                       data: 'delete',
-                                       icon: 'trash',
-                                       label: mw.msg( 'rcfilters-savedqueries-remove' )
-                               } ),
-                               new OO.ui.MenuOptionWidget( {
-                                       data: 'default',
-                                       icon: 'pushPin',
-                                       label: mw.msg( 'rcfilters-savedqueries-setdefault' )
-                               } )
-                       ]
-               } );
-
-               this.editInput = new OO.ui.TextInputWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
-               } );
-               this.saveButton = new OO.ui.ButtonWidget( {
-                       icon: 'check',
-                       flags: [ 'primary', 'progressive' ]
-               } );
-               this.toggleEdit( false );
-
-               // Events
-               this.model.connect( this, { update: 'onModelUpdate' } );
-               this.popupButton.connect( this, { click: 'onPopupButtonClick' } );
-               this.menu.connect( this, {
-                       choose: 'onMenuChoose'
-               } );
-               this.saveButton.connect( this, { click: 'save' } );
-               this.editInput.connect( this, {
-                       change: 'onInputChange',
-                       enter: 'save'
-               } );
-               this.editInput.$input.on( {
-                       blur: this.onInputBlur.bind( this ),
-                       keyup: this.onInputKeyup.bind( this )
-               } );
-               this.$element.on( { click: this.onClick.bind( this ) } );
-               this.$label.on( { click: this.onClick.bind( this ) } );
-               this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );
-               // Prevent propagation on mousedown for the save button
-               // so the menu doesn't close
-               this.saveButton.$element.on( { mousedown: function () {
-                       return false;
-               } } );
-
-               // Initialize
-               this.toggleDefault( !!this.model.isDefault() );
-               this.$overlay.append( this.menu.$element );
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
-                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
-                                                                       .append(
-                                                                               this.$label
-                                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
-                                                                               this.editInput.$element,
-                                                                               this.saveButton.$element
-                                                                       ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
-                                                                       .append( this.$icon ),
-                                                               this.popupButton.$element
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       )
-                                       )
-                       );
-       };
-
-       /* Initialization */
-       OO.inheritClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.LabelElement );
-       OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.IconElement );
-       OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.TitledElement );
-
-       /* Events */
-
-       /**
-        * @event delete
-        *
-        * The delete option was selected for this item
-        */
-
-       /**
-        * @event default
-        * @param {boolean} default Item is default
-        *
-        * The 'make default' option was selected for this item
-        */
-
-       /**
-        * @event edit
-        * @param {string} newLabel New label for the query
-        *
-        * The label has been edited
-        */
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onModelUpdate = function () {
-               this.setLabel( this.model.getLabel() );
-               this.toggleDefault( this.model.isDefault() );
-       };
-
-       /**
-        * Respond to click on the element or label
-        *
-        * @fires click
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onClick = function () {
-               if ( !this.editing ) {
-                       this.emit( 'click' );
-               }
-       };
-
-       /**
-        * Respond to click on the 'default' icon. Open the submenu where the
-        * default state can be changed.
-        *
-        * @return {boolean} false
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
-               this.menu.toggle();
-               return false;
-       };
-
-       /**
-        * Respond to popup button click event
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onPopupButtonClick = function () {
-               this.menu.toggle();
-       };
-
-       /**
-        * Respond to menu choose event
-        *
-        * @param {OO.ui.MenuOptionWidget} item Chosen item
-        * @fires delete
-        * @fires default
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
-               var action = item.getData();
-
-               if ( action === 'edit' ) {
-                       this.toggleEdit( true );
-               } else if ( action === 'delete' ) {
-                       this.emit( 'delete' );
-               } else if ( action === 'default' ) {
-                       this.emit( 'default', !this.default );
-               }
-               // Reset selected
-               this.menu.selectItem( null );
-               // Close the menu
-               this.menu.toggle( false );
-       };
-
-       /**
-        * Respond to input keyup event, this is the way to intercept 'escape' key
-        *
-        * @param {jQuery.Event} e Event data
-        * @return {boolean} false
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
-               if ( e.which === OO.ui.Keys.ESCAPE ) {
-                       // Return the input to the original label
-                       this.editInput.setValue( this.getLabel() );
-                       this.toggleEdit( false );
-                       return false;
-               }
-       };
-
-       /**
-        * Respond to blur event on the input
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputBlur = function () {
-               this.save();
-
-               // Whether the save succeeded or not, the input-blur event
-               // means we need to cancel editing mode
-               this.toggleEdit( false );
-       };
-
-       /**
-        * Respond to input change event
-        *
-        * @param {string} value Input value
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
-               value = value.trim();
-
-               this.saveButton.setDisabled( !value );
-       };
-
-       /**
-        * Save the name of the query
-        *
-        * @param {string} [value] The value to save
-        * @fires edit
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.save = function () {
-               var value = this.editInput.getValue().trim();
-
-               if ( value ) {
-                       this.emit( 'edit', value );
-                       this.toggleEdit( false );
-               }
-       };
-
-       /**
-        * Toggle edit mode on this widget
-        *
-        * @param {boolean} isEdit Widget is in edit mode
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
-               isEdit = isEdit === undefined ? !this.editing : isEdit;
-
-               if ( this.editing !== isEdit ) {
-                       this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
-                       this.editInput.setValue( this.getLabel() );
-
-                       this.editInput.toggle( isEdit );
-                       this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
-                       this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
-                       this.popupButton.toggle( !isEdit );
-                       this.saveButton.toggle( isEdit );
-
-                       if ( isEdit ) {
-                               this.editInput.$input.trigger( 'focus' );
-                       }
-                       this.editing = isEdit;
-               }
-       };
-
-       /**
-        * Toggle default this widget
-        *
-        * @param {boolean} isDefault This item is default
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
-               isDefault = isDefault === undefined ? !this.default : isDefault;
-
-               if ( this.default !== isDefault ) {
-                       this.default = isDefault;
-                       this.setIcon( this.default ? 'pushPin' : '' );
-                       this.menu.findItemFromData( 'default' ).setLabel(
-                               this.default ?
-                                       mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
-                                       mw.msg( 'rcfilters-savedqueries-setdefault' )
-                       );
-               }
-       };
-
-       /**
-        * Get item ID
-        *
-        * @return {string} Query identifier
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.getID = function () {
-               return this.model.getID();
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js
deleted file mode 100644 (file)
index b4ec781..0000000
+++ /dev/null
@@ -1,153 +0,0 @@
-( function () {
-       /**
-        * Quick links widget
-        *
-        * @class
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       mw.rcfilters.ui.SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
-               var $labelNoEntries = $( '<div>' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
-                                       .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
-                                       .text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
-                       );
-
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.SavedLinksListWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-               this.$overlay = config.$overlay || this.$element;
-
-               this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
-                       label: $labelNoEntries,
-                       icon: 'bookmark'
-               } );
-
-               this.menu = new mw.rcfilters.ui.GroupWidget( {
-                       events: {
-                               click: 'menuItemClick',
-                               delete: 'menuItemDelete',
-                               default: 'menuItemDefault',
-                               edit: 'menuItemEdit'
-                       },
-                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
-                       items: [ this.placeholderItem ]
-               } );
-               this.button = new OO.ui.PopupButtonWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
-                       label: mw.msg( 'rcfilters-quickfilters' ),
-                       icon: 'bookmark',
-                       indicator: 'down',
-                       $overlay: this.$overlay,
-                       popup: {
-                               width: 300,
-                               anchor: false,
-                               align: 'backwards',
-                               $autoCloseIgnore: this.$overlay,
-                               $content: this.menu.$element
-                       }
-               } );
-
-               // Events
-               this.model.connect( this, {
-                       add: 'onModelAddItem',
-                       remove: 'onModelRemoveItem'
-               } );
-               this.menu.connect( this, {
-                       menuItemClick: 'onMenuItemClick',
-                       menuItemDelete: 'onMenuItemRemove',
-                       menuItemDefault: 'onMenuItemDefault',
-                       menuItemEdit: 'onMenuItemEdit'
-               } );
-
-               this.placeholderItem.toggle( this.model.isEmpty() );
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget' )
-                       .append( this.button.$element );
-       };
-
-       /* Initialization */
-       OO.inheritClass( mw.rcfilters.ui.SavedLinksListWidget, OO.ui.Widget );
-
-       /* Methods */
-
-       /**
-        * Respond to menu item click event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) {
-               this.controller.applySavedQuery( item.getID() );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to menu item remove event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
-               this.controller.removeSavedQuery( item.getID() );
-       };
-
-       /**
-        * Respond to menu item default event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        * @param {boolean} isDefault Item is default
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
-               this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
-       };
-
-       /**
-        * Respond to menu item edit event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        * @param {string} newLabel New label
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
-               this.controller.renameSavedQuery( item.getID(), newLabel );
-       };
-
-       /**
-        * Respond to menu add item event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
-               if ( this.menu.findItemFromData( item.getID() ) ) {
-                       return;
-               }
-
-               this.menu.addItems( [
-                       new mw.rcfilters.ui.SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
-               ] );
-               this.placeholderItem.toggle( this.model.isEmpty() );
-       };
-
-       /**
-        * Respond to menu remove item event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
-               this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
-               this.placeholderItem.toggle( this.model.isEmpty() );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js
deleted file mode 100644 (file)
index 88117e7..0000000
+++ /dev/null
@@ -1,223 +0,0 @@
-( function () {
-       /**
-        * Extend OOUI's TagItemWidget to also display a popup on hover.
-        *
-        * @class
-        * @extends OO.ui.TagItemWidget
-        * @mixins OO.ui.mixin.PopupElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.FilterItem} invertModel
-        * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       mw.rcfilters.ui.TagItemWidget = function MwRcfiltersUiTagItemWidget(
-               controller, filtersViewModel, invertModel, itemModel, config
-       ) {
-               // Configuration initialization
-               config = config || {};
-
-               this.controller = controller;
-               this.invertModel = invertModel;
-               this.filtersViewModel = filtersViewModel;
-               this.itemModel = itemModel;
-               this.selected = false;
-
-               mw.rcfilters.ui.TagItemWidget.parent.call( this, $.extend( {
-                       data: this.itemModel.getName()
-               }, config ) );
-
-               this.$overlay = config.$overlay || this.$element;
-               this.popupLabel = new OO.ui.LabelWidget();
-
-               // Mixin constructors
-               OO.ui.mixin.PopupElement.call( this, $.extend( {
-                       popup: {
-                               padded: false,
-                               align: 'center',
-                               position: 'above',
-                               $content: $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
-                                       .append( this.popupLabel.$element ),
-                               $floatableContainer: this.$element,
-                               classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
-                       }
-               }, config ) );
-
-               this.popupTimeoutShow = null;
-               this.popupTimeoutHide = null;
-
-               this.$highlight = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );
-
-               // Add title attribute with the item label to 'x' button
-               this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );
-
-               // Events
-               this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
-               this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
-               this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
-
-               // Initialization
-               this.$overlay.append( this.popup.$element );
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-tagItemWidget' )
-                       .prepend( this.$highlight )
-                       .attr( 'aria-haspopup', 'true' )
-                       .on( 'mouseenter', this.onMouseEnter.bind( this ) )
-                       .on( 'mouseleave', this.onMouseLeave.bind( this ) );
-
-               this.updateUiBasedOnState();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.TagItemWidget, OO.ui.TagItemWidget );
-       OO.mixinClass( mw.rcfilters.ui.TagItemWidget, OO.ui.mixin.PopupElement );
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.updateUiBasedOnState = function () {
-               // Update label if needed
-               var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel.isSelected() );
-               if ( labelMsg ) {
-                       this.setLabel( $( '<div>' ).append(
-                               $( '<bdi>' ).html(
-                                       mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
-                               )
-                       ).contents() );
-               } else {
-                       this.setLabel(
-                               $( '<bdi>' ).append(
-                                       this.itemModel.getLabel()
-                               )
-                       );
-               }
-
-               this.setCurrentMuteState();
-               this.setHighlightColor();
-       };
-
-       /**
-        * Set the current highlight color for this item
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.setHighlightColor = function () {
-               var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
-                       this.itemModel.getHighlightColor() :
-                       null;
-
-               this.$highlight
-                       .attr( 'data-color', selectedColor )
-                       .toggleClass(
-                               'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
-                               !!selectedColor
-                       );
-       };
-
-       /**
-        * Set the current mute state for this item
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.setCurrentMuteState = function () {};
-
-       /**
-        * Respond to mouse enter event
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.onMouseEnter = function () {
-               var labelText = this.itemModel.getStateMessage();
-
-               if ( labelText ) {
-                       this.popupLabel.setLabel( labelText );
-
-                       // Set timeout for the popup to show
-                       this.popupTimeoutShow = setTimeout( function () {
-                               this.popup.toggle( true );
-                       }.bind( this ), 500 );
-
-                       // Cancel the hide timeout
-                       clearTimeout( this.popupTimeoutHide );
-                       this.popupTimeoutHide = null;
-               }
-       };
-
-       /**
-        * Respond to mouse leave event
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.onMouseLeave = function () {
-               this.popupTimeoutHide = setTimeout( function () {
-                       this.popup.toggle( false );
-               }.bind( this ), 250 );
-
-               // Clear the show timeout
-               clearTimeout( this.popupTimeoutShow );
-               this.popupTimeoutShow = null;
-       };
-
-       /**
-        * Set selected state on this widget
-        *
-        * @param {boolean} [isSelected] Widget is selected
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
-               isSelected = isSelected !== undefined ? isSelected : !this.selected;
-
-               if ( this.selected !== isSelected ) {
-                       this.selected = isSelected;
-
-                       this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
-               }
-       };
-
-       /**
-        * Get the selected state of this widget
-        *
-        * @return {boolean} Tag is selected
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.isSelected = function () {
-               return this.selected;
-       };
-
-       /**
-        * Get item name
-        *
-        * @return {string} Filter name
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.getName = function () {
-               return this.itemModel.getName();
-       };
-
-       /**
-        * Get item model
-        *
-        * @return {string} Filter model
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.getModel = function () {
-               return this.itemModel;
-       };
-
-       /**
-        * Get item view
-        *
-        * @return {string} Filter view
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.getView = function () {
-               return this.itemModel.getGroupModel().getView();
-       };
-
-       /**
-        * Remove and destroy external elements of this widget
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.destroy = function () {
-               // Destroy the popup
-               this.popup.$element.detach();
-
-               // Disconnect events
-               this.itemModel.disconnect( this );
-               this.closeButton.disconnect( this );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js
deleted file mode 100644 (file)
index e65abf2..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-( function () {
-       /**
-        * Widget defining the behavior used to choose from a set of values
-        * in a single_value group
-        *
-        * @class
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.LabelElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FilterGroup} model Group model
-        * @param {Object} [config] Configuration object
-        * @cfg {Function} [itemFilter] A filter function for the items from the
-        *  model. If not given, all items will be included. The function must
-        *  handle item models and return a boolean whether the item is included
-        *  or not. Example: function ( itemModel ) { return itemModel.isSelected(); }
-        */
-       mw.rcfilters.ui.ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.ValuePickerWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, config );
-
-               this.model = model;
-               this.itemFilter = config.itemFilter || function () {
-                       return true;
-               };
-
-               // Build the selection from the item models
-               this.selectWidget = new OO.ui.ButtonSelectWidget();
-               this.initializeSelectWidget();
-
-               // Events
-               this.model.connect( this, { update: 'onModelUpdate' } );
-               this.selectWidget.connect( this, { choose: 'onSelectWidgetChoose' } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-valuePickerWidget' )
-                       .append(
-                               this.$label
-                                       .addClass( 'mw-rcfilters-ui-valuePickerWidget-title' ),
-                               this.selectWidget.$element
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.ValuePickerWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.ValuePickerWidget, OO.ui.mixin.LabelElement );
-
-       /* Events */
-
-       /**
-        * @event choose
-        * @param {string} name Item name
-        *
-        * An item has been chosen
-        */
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.ValuePickerWidget.prototype.onModelUpdate = function () {
-               this.selectCurrentModelItem();
-       };
-
-       /**
-        * Respond to select widget choose event
-        *
-        * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item
-        * @fires choose
-        */
-       mw.rcfilters.ui.ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) {
-               this.emit( 'choose', chosenItem.getData() );
-       };
-
-       /**
-        * Initialize the select widget
-        */
-       mw.rcfilters.ui.ValuePickerWidget.prototype.initializeSelectWidget = function () {
-               var items = this.model.getItems()
-                       .filter( this.itemFilter )
-                       .map( function ( filterItem ) {
-                               return new OO.ui.ButtonOptionWidget( {
-                                       data: filterItem.getName(),
-                                       label: filterItem.getLabel()
-                               } );
-                       } );
-
-               this.selectWidget.clearItems();
-               this.selectWidget.addItems( items );
-
-               this.selectCurrentModelItem();
-       };
-
-       /**
-        * Select the current item that corresponds with the model item
-        * that is currently selected
-        */
-       mw.rcfilters.ui.ValuePickerWidget.prototype.selectCurrentModelItem = function () {
-               var selectedItem = this.model.findSelectedItems()[ 0 ];
-
-               if ( selectedItem ) {
-                       this.selectWidget.selectItemByData( selectedItem.getName() );
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ViewSwitchWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ViewSwitchWidget.js
deleted file mode 100644 (file)
index 72d2203..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-( function () {
-       /**
-        * A widget for the footer for the default view, allowing to switch views
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.ViewSwitchWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-
-               this.buttons = new mw.rcfilters.ui.GroupWidget( {
-                       events: {
-                               click: 'buttonClick'
-                       },
-                       items: [
-                               new OO.ui.ButtonWidget( {
-                                       data: 'namespaces',
-                                       icon: 'article',
-                                       label: mw.msg( 'namespaces' )
-                               } ),
-                               new OO.ui.ButtonWidget( {
-                                       data: 'tags',
-                                       icon: 'tag',
-                                       label: mw.msg( 'rcfilters-view-tags' )
-                               } )
-                       ]
-               } );
-
-               // Events
-               this.model.connect( this, { update: 'onModelUpdate' } );
-               this.buttons.connect( this, { buttonClick: 'onButtonClick' } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
-                       .append(
-                               new OO.ui.LabelWidget( {
-                                       label: mw.msg( 'rcfilters-advancedfilters' )
-                               } ).$element,
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
-                                       .append( this.buttons.$element )
-                       );
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( mw.rcfilters.ui.ViewSwitchWidget, OO.ui.Widget );
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.ViewSwitchWidget.prototype.onModelUpdate = function () {
-               var currentView = this.model.getCurrentView();
-
-               this.buttons.getItems().forEach( function ( buttonWidget ) {
-                       buttonWidget.setActive( buttonWidget.getData() === currentView );
-               } );
-       };
-
-       /**
-        * Respond to button switch click
-        *
-        * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
-        */
-       mw.rcfilters.ui.ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
-               this.controller.switchView( buttonWidget.getData() );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js
deleted file mode 100644 (file)
index 423c105..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-( function () {
-       /**
-        * Top section (between page title and filters) on Special:Watchlist
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
-        * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
-               controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
-       ) {
-               var editWatchlistButton,
-                       markSeenButton,
-                       $topTable,
-                       $bottomTable,
-                       $separator;
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.WatchlistTopSectionWidget.parent.call( this, config );
-
-               editWatchlistButton = new OO.ui.ButtonWidget( {
-                       label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
-                       icon: 'edit',
-                       href: mw.config.get( 'wgStructuredChangeFiltersEditWatchlistUrl' )
-               } );
-               markSeenButton = new mw.rcfilters.ui.MarkSeenButtonWidget( controller, changesListModel );
-
-               $topTable = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-table' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-row' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
-                                                       .append( $watchlistDetails )
-                                       )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
-                                                       .append( editWatchlistButton.$element )
-                                       )
-                       );
-
-               $bottomTable = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-table' )
-                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-row' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .append( markSeenButton.$element )
-                                       )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
-                                                       .append( savedLinksListWidget.$element )
-                                       )
-                       );
-
-               $separator = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
-                       .append( $topTable, $separator, $bottomTable );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.WatchlistTopSectionWidget, OO.ui.Widget );
-}() );