From 873d3c9ffc398f05575f42f3614418bc6aed3b41 Mon Sep 17 00:00:00 2001 From: Moriel Schottlender Date: Mon, 7 Nov 2016 17:14:13 -0800 Subject: [PATCH] Adding new interface for review filters to RecentChanges Add a new filter experience to Special:RecentChanges with a drop-down filter menu. Put it behind the rcenhancedfilters preference, which is hidden for now. Bug: T149435 Bug: T149452 Bug: T144448 Change-Id: Ic545ff1462998b610d7edae59472ecce2e7d51ea --- includes/DefaultSettings.php | 5 +- includes/specials/SpecialRecentchanges.php | 8 + languages/i18n/en.json | 10 + languages/i18n/qqq.json | 10 + resources/Resources.php | 38 ++ .../dm/mw.rcfilters.dm.FilterItem.js | 103 ++++ .../dm/mw.rcfilters.dm.FiltersViewModel.js | 267 ++++++++++ .../mw.rcfilters.Controller.js | 57 +++ .../mediawiki.rcfilters/mw.rcfilters.init.js | 81 +++ .../src/mediawiki.rcfilters/mw.rcfilters.js | 3 + .../styles/mw.rcfilters.less | 5 + ...ers.ui.FilterCapsuleMultiselectWidget.less | 11 + .../mw.rcfilters.ui.FilterGroupWidget.less | 20 + .../mw.rcfilters.ui.FilterItemWidget.less | 18 + .../mw.rcfilters.ui.FilterWrapperWidget.less | 33 ++ .../mw.rcfilters.ui.FiltersListWidget.less | 16 + ...lters.ui.FilterCapsuleMultiselectWidget.js | 91 ++++ .../ui/mw.rcfilters.ui.FilterGroupWidget.js | 51 ++ .../ui/mw.rcfilters.ui.FilterItemWidget.js | 92 ++++ .../ui/mw.rcfilters.ui.FilterWrapperWidget.js | 130 +++++ .../ui/mw.rcfilters.ui.FiltersListWidget.js | 154 ++++++ tests/qunit/QUnitTestResources.php | 2 + .../dm.FiltersViewModel.test.js | 468 ++++++++++++++++++ 23 files changed, 1672 insertions(+), 1 deletion(-) create mode 100644 resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js create mode 100644 resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js create mode 100644 resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js create mode 100644 resources/src/mediawiki.rcfilters/mw.rcfilters.init.js create mode 100644 resources/src/mediawiki.rcfilters/mw.rcfilters.js create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterGroupWidget.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FiltersListWidget.less create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js create mode 100644 tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 449e1c2ae5..77061dfcc8 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -4817,6 +4817,7 @@ $wgDefaultUserOptions = [ 'previewonfirst' => 0, 'previewontop' => 1, 'rcdays' => 7, + 'rcenhancedfilters' => 0, 'rclimit' => 50, 'rows' => 25, 'showhiddencats' => 0, @@ -4851,7 +4852,9 @@ $wgDefaultUserOptions = [ /** * An array of preferences to not show for the user */ -$wgHiddenPrefs = []; +$wgHiddenPrefs = [ + 'rcenhancedfilters', +]; /** * Characters to prevent during new account creations. diff --git a/includes/specials/SpecialRecentchanges.php b/includes/specials/SpecialRecentchanges.php index 1ce61e307c..b2e56742f3 100644 --- a/includes/specials/SpecialRecentchanges.php +++ b/includes/specials/SpecialRecentchanges.php @@ -526,6 +526,14 @@ class SpecialRecentChanges extends ChangesListSpecialPage { parent::addModules(); $out = $this->getOutput(); $out->addModules( 'mediawiki.special.recentchanges' ); + if ( $this->getUser()->getOption( + 'rcenhancedfilters', + /*default=*/ null, + /*ignoreHidden=*/ true + ) + ) { + $out->addModules( 'mediawiki.rcfilters.filters' ); + } } /** diff --git a/languages/i18n/en.json b/languages/i18n/en.json index f2b27fc7fb..2d78a6bccf 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1360,6 +1360,16 @@ "recentchanges-legend-unpatrolled": "{{int:recentchanges-label-unpatrolled}}", "recentchanges-legend-plusminus": "(±123)", "recentchanges-submit": "Show", + "rcfilters-activefilters": "Active filters", + "rcfilters-search-placeholder": "Filter recent changes (browse or start typing)", + "rcfilters-invalid-filter": "Invalid filter", + "rcfilters-filterlist-title": "Filters", + "rcfilters-filterlist-noresults": "No filters found", + "rcfilters-filtergroup-authorship": "Edit authorship", + "rcfilters-filter-editsbyself-label": "Your own edits", + "rcfilters-filter-editsbyself-description": "Edits by you.", + "rcfilters-filter-editsbyother-label": "Edits by others", + "rcfilters-filter-editsbyother-description": "Edits created by other users (not you.)", "rcnotefrom": "Below {{PLURAL:$5|is the change|are the changes}} since $3, $4 (up to $1 shown).", "rclistfrom": "Show new changes starting from $2, $3", "rcshowhideminor": "$1 minor edits", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 99f7679e55..31f8b9b802 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1544,6 +1544,16 @@ "recentchanges-legend-unpatrolled": "Used as legend on [[Special:RecentChanges]] and [[Special:Watchlist]].\n\nRefers to {{msg-mw|Recentchanges-label-unpatrolled}}.", "recentchanges-legend-plusminus": "{{optional}}\nA plus/minus sign with a number for the legend.", "recentchanges-submit": "Label for submit button in [[Special:RecentChanges]]\n{{Identical|Show}}", + "rcfilters-activefilters": "Title for the filters selection showing the active filters.", + "rcfilters-search-placeholder": "Placeholder for the filter search input.", + "rcfilters-invalid-filter": "A label for an ivalid filter.", + "rcfilters-filterlist-title": "Title for the filters list.", + "rcfilters-filterlist-noresults": "Message showing no results found for searching a filter.", + "rcfilters-filtergroup-authorship": "Title for the filter group for edit authorship.", + "rcfilters-filter-editsbyself-label": "Label for the filter for showing edits made by the current user.", + "rcfilters-filter-editsbyself-description": "Description for the filter for showing edits made by the current user.", + "rcfilters-filter-editsbyother-label": "Label for the filter for showing edits made by anyone other than the current user.", + "rcfilters-filter-editsbyother-description": "Description for the filter for showing edits made by anyone other than the current user.", "rcnotefrom": "This message is displayed at [[Special:RecentChanges]] when viewing recentchanges from some specific time.\n\nThe corresponding message is {{msg-mw|Rclistfrom}}.\n\nParameters:\n* $1 - the maximum number of changes that are displayed\n* $2 - (Optional) a date and time\n* $3 - a date\n* $4 - a time\n* $5 - Number of changes are displayed, for use with PLURAL", "rclistfrom": "Used on [[Special:RecentChanges]]. Parameters:\n* $1 - (Currently not use) date and time. The date and the time adds to the rclistfrom description.\n* $2 - time. The time adds to the rclistfrom link description (with split of date and time).\n* $3 - date. The date adds to the rclistfrom link description (with split of date and time).\n\nThe corresponding message is {{msg-mw|Rcnotefrom}}.", "rcshowhideminor": "Option text in [[Special:RecentChanges]]. Parameters:\n* $1 - the \"show/hide\" command, with the text taken from either {{msg-mw|rcshowhideminor-show}} or {{msg-mw|rcshowhideminor-hide}}\n{{Identical|Minor edit}}", diff --git a/resources/Resources.php b/resources/Resources.php index 92013ecd8c..c784f15d6e 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1775,6 +1775,44 @@ return [ /* MediaWiki Special pages */ + 'mediawiki.rcfilters.filters' => [ + 'scripts' => [ + 'resources/src/mediawiki.rcfilters/mw.rcfilters.js', + 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js', + 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js', + 'resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js', + 'resources/src/mediawiki.rcfilters/mw.rcfilters.init.js', + ], + 'styles' => [ + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterGroupWidget.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FiltersListWidget.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less', + ], + 'messages' => [ + 'rcfilters-activefilters', + 'rcfilters-search-placeholder', + 'rcfilters-invalid-filter', + 'rcfilters-filterlist-title', + 'rcfilters-filterlist-noresults', + 'rcfilters-filtergroup-authorship', + 'rcfilters-filter-editsbyself-label', + 'rcfilters-filter-editsbyself-description', + 'rcfilters-filter-editsbyother-label', + 'rcfilters-filter-editsbyother-description', + ], + 'dependencies' => [ + 'oojs-ui', + 'mediawiki.Uri', + ], + ], 'mediawiki.special' => [ 'styles' => 'resources/src/mediawiki.special/mediawiki.special.css', 'targets' => [ 'desktop', 'mobile' ], diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js new file mode 100644 index 0000000000..63db0ea68d --- /dev/null +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js @@ -0,0 +1,103 @@ +( function ( mw ) { + /** + * Filter item model + * + * @mixins OO.EventEmitter + * + * @constructor + * @param {string} name Filter name + * @param {Object} config Configuration object + * @cfg {string} [group] The group this item belongs to + * @cfg {string} [label] The label for the filter + * @cfg {string} [description] The description of the filter + * @cfg {boolean} [selected] Filter is selected + */ + mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( name, config ) { + config = config || {}; + + // Mixin constructor + OO.EventEmitter.call( this ); + + this.name = name; + this.group = config.group || ''; + this.label = config.label || this.name; + this.description = config.description; + + this.selected = !!config.selected; + }; + + /* Initialization */ + + OO.initClass( mw.rcfilters.dm.FilterItem ); + OO.mixinClass( mw.rcfilters.dm.FilterItem, OO.EventEmitter ); + + /* Events */ + + /** + * @event update + * + * The state of this filter has changed + */ + + /* Methods */ + + /** + * Get the name of this filter + * + * @return {string} Filter name + */ + mw.rcfilters.dm.FilterItem.prototype.getName = function () { + return this.name; + }; + + /** + * Get the group name this filter belongs to + * + * @return {string} Filter group name + */ + mw.rcfilters.dm.FilterItem.prototype.getGroup = function () { + return this.group; + }; + + /** + * Get the label of this filter + * + * @return {string} Filter label + */ + mw.rcfilters.dm.FilterItem.prototype.getLabel = function () { + return this.label; + }; + + /** + * Get the description of this filter + * + * @return {string} Filter description + */ + mw.rcfilters.dm.FilterItem.prototype.getDescription = function () { + return this.description; + }; + + /** + * Get the selected state of this filter + * + * @return {boolean} Filter is selected + */ + mw.rcfilters.dm.FilterItem.prototype.isSelected = function () { + return this.selected; + }; + + /** + * Toggle the selected state of the item + * + * @param {boolean} [isSelected] Filter is selected + * @fires update + */ + mw.rcfilters.dm.FilterItem.prototype.toggleSelected = function ( isSelected ) { + isSelected = isSelected === undefined ? !this.selected : isSelected; + + if ( this.selected !== isSelected ) { + this.selected = isSelected; + this.emit( 'update' ); + } + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js new file mode 100644 index 0000000000..1d0f45f9a8 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js @@ -0,0 +1,267 @@ +( function ( mw, $ ) { + /** + * 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 = {}; + + // Events + this.aggregate( { update: '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 itemUpdate + * @param {mw.rcfilters.dm.FilterItem} item Filter item updated + * + * Filter item has changed + */ + + /* Methods */ + + /** + * Set filters and preserve a group relationship based on + * the definition given by an object + * + * @param {Object} filters Filter group definition + */ + mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) { + var i, filterItem, + model = this, + items = []; + + // Reset + this.clearItems(); + this.groups = {}; + + $.each( filters, function ( group, data ) { + model.groups[ group ] = model.groups[ group ] || {}; + model.groups[ group ].filters = model.groups[ group ].filters || []; + + model.groups[ group ].title = data.title; + model.groups[ group ].type = data.type; + + for ( i = 0; i < data.filters.length; i++ ) { + filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, { + group: group, + label: data.filters[ i ].label, + description: data.filters[ i ].description, + selected: data.filters[ i ].selected + } ); + + model.groups[ group ].filters.push( filterItem ); + items.push( filterItem ); + } + } ); + + this.addItems( items ); + this.emit( 'initialize' ); + }; + + /** + * Get the names of all available filters + * + * @return {string[]} An array of filter names + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getFilterNames = function () { + return this.getItems().map( function ( item ) { return item.getName(); } ); + }; + + /** + * Get the object that defines groups and their filter items. + * The structure of this response: + * { + * groupName: { + * title: {string} Group title + * type: {string} Group type + * filters: {string[]} Filters in the group + * } + * } + * + * @return {Object} Filter groups + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () { + return this.groups; + }; + + /** + * Get the current state of the filters + * + * @return {Object} Filters current state + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getState = function () { + var i, + items = this.getItems(), + result = {}; + + for ( i = 0; i < items.length; i++ ) { + result[ items[ i ].getName() ] = items[ i ].isSelected(); + } + + return result; + }; + + /** + * Analyze the groups and their filters and output an object representing + * the state of the parameters they represent. + * + * @return {Object} Parameter state object + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function () { + var i, filterItems, anySelected, + result = {}, + groupItems = this.getFilterGroups(); + + $.each( groupItems, function ( group, data ) { + if ( data.type === 'send_unselected_if_any' ) { + filterItems = data.filters; + + // 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 + anySelected = filterItems.some( function ( filterItem ) { + return filterItem.isSelected(); + } ); + + // Go over the items and define the correct values + for ( i = 0; i < filterItems.length; i++ ) { + result[ filterItems[ i ].getName() ] = anySelected ? + Number( !filterItems[ i ].isSelected() ) : 0; + } + } + } ); + + return result; + }; + + /** + * This is the opposite of the #getParametersFromFilters method; this goes over + * the 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 i, filterItem, allItemsInGroup, + groupMap = {}, + model = this, + base = this.getParametersFromFilters(), + // Start with current state + result = this.getState(); + + params = $.extend( {}, base, params ); + + $.each( params, function ( paramName, paramValue ) { + // Find the filter item + filterItem = model.getItemByName( paramName ); + + // Ignore if no filter item exists + if ( filterItem ) { + groupMap[ filterItem.getGroup() ] = groupMap[ filterItem.getGroup() ] || {}; + + // Mark the group if it has any items that are selected + groupMap[ filterItem.getGroup() ].hasSelected = ( + groupMap[ filterItem.getGroup() ].hasSelected || + !!Number( paramValue ) + ); + + // Add the relevant filter into the group map + groupMap[ filterItem.getGroup() ].filters = groupMap[ filterItem.getGroup() ].filters || []; + groupMap[ filterItem.getGroup() ].filters.push( filterItem ); + } + } ); + + // Now that we know the groups' selection states, we need to go over + // the filters in the groups and mark their selected states appropriately + $.each( groupMap, function ( group, data ) { + if ( model.groups[ group ].type === 'send_unselected_if_any' ) { + allItemsInGroup = model.groups[ group ].filters; + + for ( i = 0; i < allItemsInGroup.length; i++ ) { + filterItem = allItemsInGroup[ i ]; + + result[ filterItem.getName() ] = data.hasSelected ? + // Flip the definition between the parameter + // state and the filter state + // This is what the 'toggleSelected' value of the filter is + !Number( params[ filterItem.getName() ] ) : + // Otherwise, there are no selected items in the + // group, which means the state is false + false; + } + } + } ); + return result; + }; + + /** + * 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 ]; + }; + + /** + * Toggle selected state of items by their names + * + * @param {Object} filterDef Filter definitions + */ + mw.rcfilters.dm.FiltersViewModel.prototype.updateFilters = function ( filterDef ) { + var name, filterItem; + + for ( name in filterDef ) { + filterItem = this.getItemByName( name ); + filterItem.toggleSelected( filterDef[ name ] ); + } + }; + + /** + * Find items whose labels match the given string + * + * @param {string} str Search string + * @return {Object} An object of items to show + * arranged by their group names + */ + mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( str ) { + var i, + result = {}, + items = this.getItems(); + + // Normalize so we can search strings regardless of case + str = str.toLowerCase(); + for ( i = 0; i < items.length; i++ ) { + if ( items[ i ].getLabel().toLowerCase().indexOf( str ) > -1 ) { + result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || []; + result[ items[ i ].getGroup() ].push( items[ i ] ); + } + } + return result; + }; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js new file mode 100644 index 0000000000..ea44b8b962 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -0,0 +1,57 @@ +( function ( mw ) { + /** + * Controller for the filters in Recent Changes + * + * @param {mw.rcfilters.dm.FiltersViewModel} model View model + */ + mw.rcfilters.Controller = function MwRcfiltersController( model ) { + this.model = model; + + // TODO: When we are ready, update the URL when a filter is updated + // this.model.connect( this, { itemUpdate: 'updateURL' } ); + }; + + /* Initialization */ + OO.initClass( mw.rcfilters.Controller ); + + /** + * Initialize the filter and parameter states + */ + mw.rcfilters.Controller.prototype.initialize = function () { + var uri = new mw.Uri(); + + this.model.updateFilters( + // Translate the url params to filter select states + this.model.getFiltersFromParameters( uri.query ) + ); + }; + + /** + * Update the state of a filter + * + * @param {string} filterName Filter name + * @param {boolean} isSelected Filter selected state + */ + mw.rcfilters.Controller.prototype.updateFilter = function ( filterName, isSelected ) { + var obj = {}; + + obj[ filterName ] = isSelected; + this.model.updateFilters( obj ); + }; + + /** + * Update the URL of the page to reflect current filters + */ + mw.rcfilters.Controller.prototype.updateURL = function () { + var uri = new mw.Uri(); + + // Add to existing queries in URL + // TODO: Clean up the list of filters; perhaps 'falsy' filters + // shouldn't appear at all? Or compare to existing query string + // and see if current state of a specific filter is needed? + uri.extend( this.model.getParametersFromFilters() ); + + // Update the URL itself + window.history.pushState( { tag: 'rcfilters' }, document.title, uri.toString() ); + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js new file mode 100644 index 0000000000..8764e0ac84 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -0,0 +1,81 @@ +/*! + * JavaScript for Special:RecentChanges + */ +( function ( mw, $ ) { + /** + * @class mw.rcfilters + * @singleton + */ + var rcfilters = { + /** */ + init: function () { + var model = new mw.rcfilters.dm.FiltersViewModel(), + controller = new mw.rcfilters.Controller( model ), + widget = new mw.rcfilters.ui.FilterWrapperWidget( controller, model ); + + model.initializeFilters( { + authorship: { + title: mw.msg( 'rcfilters-filtergroup-authorship' ), + // Type 'send_unselected_if_any' means that the controller will go over + // all unselected filters in the group and use their parameters + // as truthy in the query string. + // This is to handle the "negative" filters. We are showing users + // a positive message ("Show xxx") but the filters themselves are + // based on "hide YYY". The purpose of this is to correctly map + // the functionality to the UI, whether we are dealing with 2 + // parameters in the group or more. + type: 'send_unselected_if_any', + filters: [ + { + name: 'hidemyself', + label: mw.msg( 'rcfilters-filter-editsbyself-label' ), + description: mw.msg( 'rcfilters-filter-editsbyself-description' ) + }, + { + name: 'hidebyothers', + label: mw.msg( 'rcfilters-filter-editsbyother-label' ), + description: mw.msg( 'rcfilters-filter-editsbyother-description' ) + } + ] + } + } ); + + $( '.mw-specialpage-summary' ).after( widget.$element ); + + // Initialize values + controller.initialize(); + + $( '.rcoptions form' ).submit( function () { + var $form = $( this ); + + // Get current filter values + $.each( model.getParametersFromFilters(), function ( paramName, paramValue ) { + var $existingInput = $form.find( 'input[name=' + paramName + ']' ); + // Check if the hidden input already exists + // This happens if the parameter was already given + // on load + if ( $existingInput.length ) { + // Update the value + $existingInput.val( paramValue ); + } else { + // Append hidden fields with filter values + $form.append( + $( '' ) + .attr( 'type', 'hidden' ) + .attr( 'name', paramName ) + .val( paramValue ) + ); + } + } ); + + // Continue the submission process + return true; + } ); + } + }; + + $( rcfilters.init ); + + module.exports = rcfilters; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.js new file mode 100644 index 0000000000..3ddb5a04b9 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.js @@ -0,0 +1,3 @@ +( function ( mw ) { + mw.rcfilters = { dm: {}, ui: {} }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.less new file mode 100644 index 0000000000..7f71c0cb31 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.less @@ -0,0 +1,5 @@ +.rcshowhidemine { + // HACK: Hide this filter since it already appears in + // the new filter drop-down. + display: none; +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less new file mode 100644 index 0000000000..4e55add938 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less @@ -0,0 +1,11 @@ +.mw-rcfilters-ui-filterCapsuleMultiselectWidget { + &-content-title { + font-weight: bold; + color: #54595d; + } + + .oo-ui-capsuleItemWidget { + color: #222; + background-color: #fff; + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterGroupWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterGroupWidget.less new file mode 100644 index 0000000000..70982d446e --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterGroupWidget.less @@ -0,0 +1,20 @@ +.mw-rcfilters-ui-filterGroupWidget { + padding-bottom: 0.5em; + + &-title { + // TODO: Unify colors with official design palette + background: #eaecf0; + padding: 0.5em 0.75em; + color: #555a5d; + } + + &-invalid-notice { + padding: 0.5em; + font-style: italic; + display: none; + + .mw-rcfilters-ui-filterGroupWidget-invalid & { + display: block; + } + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less new file mode 100644 index 0000000000..ad0b816008 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less @@ -0,0 +1,18 @@ +.mw-rcfilters-ui-filterItemWidget { + padding-left: 0.5em; + + &-label { + &-title { + font-weight: bold; + font-size: 1.2em; + color: #222; + } + &-desc { + color: #464a4f; + } + } + + .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline { + margin-bottom: 0 !important; + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less new file mode 100644 index 0000000000..a610e8f956 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less @@ -0,0 +1,33 @@ +.mw-rcfilters-ui-filterWrapperWidget { + width: 100%; + + .oo-ui-capsuleMultiselectWidget { + max-width: none; + + &.oo-ui-widget-enabled .oo-ui-capsuleMultiselectWidget-handle { + // TODO: Unify colors with official design palette + background-color: #f8f9fa; + border: 1px solid #a2a9b1; + min-height: 5.5em; + padding: 0.75em; + + } + } + + &-popup { + // We have to override OOUI's definition, which is set + // on the inline style of the popup + margin-top: 2em !important; + max-width: 650px; + } + + &-search { + max-width: none; + margin-top: -0.5em; + } + + &-capsule-invalid-filter { + // TODO: Unify colors with official design palette + background: red; + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FiltersListWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FiltersListWidget.less new file mode 100644 index 0000000000..b874e0f9c1 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FiltersListWidget.less @@ -0,0 +1,16 @@ +.mw-rcfilters-ui-filtersListWidget { + &-title { + font-size: 1.2em; + padding: 0.75em; + // TODO: Unify colors with official design palette + color: #54595d; + border-bottom: 1px solid #c8ccd1; + background: #f8f9fa; + } + + &-noresults { + padding: 0.5em; + // TODO: Unify colors with official design palette + color: #666; + } +} diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js new file mode 100644 index 0000000000..df6cf8b7d5 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js @@ -0,0 +1,91 @@ +( function ( mw, $ ) { + /** + * Filter-specific CapsuleMultiselectWidget + * + * @extends OO.ui.CapsuleMultiselectWidget + * + * @constructor + * @param {OO.ui.InputWidget} filterInput A filter input that focuses the capsule widget + * @param {Object} config Configuration object + */ + mw.rcfilters.ui.FilterCapsuleMultiselectWidget = function MwRcfiltersUiFilterCapsuleMultiselectWidget( filterInput, config ) { + // Parent + mw.rcfilters.ui.FilterCapsuleMultiselectWidget.parent.call( this, $.extend( { + $autoCloseIgnore: filterInput.$element + }, config ) ); + + this.filterInput = filterInput; + + this.$content.prepend( + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterCapsuleMultiselectWidget-content-title' ) + .text( mw.msg( 'rcfilters-activefilters' ) ) + ); + + // Events + // Add the filterInput as trigger + this.filterInput.$input + .on( 'focus', this.onFocusForPopup.bind( this ) ); + + this.$element + .addClass( 'mw-rcfilters-ui-filterCapsuleMultiselectWidget' ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.FilterCapsuleMultiselectWidget, OO.ui.CapsuleMultiselectWidget ); + + /* Events */ + + /** + * @event remove + * @param {string[]} filters Array of names of removed filters + * + * Filters were removed + */ + + /* Methods */ + + /** + * @inheritdoc + */ + mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.onFocusForPopup = function () { + // Override this method; we don't want to focus on the popup, and we + // don't want to bind the size to the handle. + if ( !this.isDisabled() ) { + this.popup.toggle( true ); + } + }; + + /** + * @inheritdoc + */ + mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.removeItems = function ( items ) { + // Parent + mw.rcfilters.ui.FilterCapsuleMultiselectWidget.parent.prototype.removeItems.call( this, items ); + + this.emit( 'remove', items.map( function ( item ) { return item.getData(); } ) ); + }; + + /** + * @inheritdoc + */ + mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.onKeyDown = function () {}; + + /** + * @inheritdoc + */ + mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.onPopupFocusOut = function () {}; + + /** + * @inheritdoc + */ + mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.clearInput = function () { + if ( this.filterInput ) { + this.filterInput.setValue( '' ); + } + this.menu.toggle( false ); + this.menu.selectItem(); + this.menu.highlightItem(); + }; +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js new file mode 100644 index 0000000000..92ae4d194f --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js @@ -0,0 +1,51 @@ +( function ( mw, $ ) { + /** + * A group of filters + * + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.GroupWidget + * @mixins OO.ui.mixin.LabelElement + * + * @constructor + * @param {string} name Group name + * @param {Object} config Configuration object + */ + mw.rcfilters.ui.FilterGroupWidget = function MwRcfiltersUiFilterGroupWidget( name, config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.FilterGroupWidget.parent.call( this, config ); + // Mixin constructors + OO.ui.mixin.GroupWidget.call( this, config ); + OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { + $label: $( '
' ) + .addClass( 'mw-rcfilters-ui-filterGroupWidget-title' ) + } ) ); + + this.name = name; + + this.$element + .addClass( 'mw-rcfilters-ui-filterGroupWidget' ) + .append( + this.$label, + this.$group + .addClass( 'mw-rcfilters-ui-filterGroupWidget-group' ) + ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.FilterGroupWidget, OO.ui.Widget ); + OO.mixinClass( mw.rcfilters.ui.FilterGroupWidget, OO.ui.mixin.GroupWidget ); + OO.mixinClass( mw.rcfilters.ui.FilterGroupWidget, OO.ui.mixin.LabelElement ); + + /** + * Get the group name + * + * @return {string} Group name + */ + mw.rcfilters.ui.FilterGroupWidget.prototype.getName = function () { + return this.name; + }; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js new file mode 100644 index 0000000000..b77df3ba74 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js @@ -0,0 +1,92 @@ +( function ( mw, $ ) { + /** + * A widget representing a single toggle filter + * + * @extends OO.ui.Widget + * + * @constructor + * @param {mw.rcfilters.Controller} controller RCFilters controller + * @param {mw.rcfilters.dm.FilterItem} model Filter item model + * @param {Object} config Configuration object + */ + mw.rcfilters.ui.FilterItemWidget = function MwRcfiltersUiFilterItemWidget( controller, model, config ) { + var layout, + $label = $( '
' ) + .addClass( 'mw-rcfilters-ui-filterItemWidget-label' ); + + config = config || {}; + + // Parent + mw.rcfilters.ui.FilterItemWidget.parent.call( this, config ); + + this.controller = controller; + this.model = model; + + this.checkboxWidget = new OO.ui.CheckboxInputWidget( { + value: this.model.getName(), + selected: this.model.isSelected() + } ); + + $label.append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterItemWidget-label-title' ) + .text( this.model.getLabel() ) + ); + if ( this.model.getDescription() ) { + $label.append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterItemWidget-label-desc' ) + .text( this.model.getDescription() ) + ); + } + + layout = new OO.ui.FieldLayout( this.checkboxWidget, { + label: $label, + align: 'inline' + } ); + + // Event + this.checkboxWidget.connect( this, { change: 'onCheckboxChange' } ); + this.model.connect( this, { update: 'onModelUpdate' } ); + + this.$element + .addClass( 'mw-rcfilters-ui-filterItemWidget' ) + .append( + layout.$element + ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.FilterItemWidget, OO.ui.Widget ); + + /* Methods */ + + /** + * Respond to checkbox change. + * NOTE: This event is emitted both for deliberate user action and for + * a change that the code requests ('setSelected') + * + * @param {boolean} isSelected The checkbox is selected + */ + mw.rcfilters.ui.FilterItemWidget.prototype.onCheckboxChange = function ( isSelected ) { + this.controller.updateFilter( this.model.getName(), isSelected ); + }; + + /** + * Respond to item model update event + */ + mw.rcfilters.ui.FilterItemWidget.prototype.onModelUpdate = function () { + this.checkboxWidget.setSelected( this.model.isSelected() ); + }; + + /** + * Get the name of this filter + * + * @return {string} Filter name + */ + mw.rcfilters.ui.FilterItemWidget.prototype.getName = function () { + return this.model.getName(); + }; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js new file mode 100644 index 0000000000..3fcfc47c59 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js @@ -0,0 +1,130 @@ +( function ( mw ) { + /** + * 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 {Object} config Configuration object + * @cfg {Object} [filters] A definition of the filter groups in this list + */ + mw.rcfilters.ui.FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget( controller, model, config ) { + 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.filtersInCapsule = []; + + this.filterPopup = new mw.rcfilters.ui.FiltersListWidget( + this.controller, + this.model, + { + label: mw.msg( 'rcfilters-filterlist-title' ) + } + ); + + this.textInput = new OO.ui.TextInputWidget( { + classes: [ 'mw-rcfilters-ui-filterWrapperWidget-search' ], + icon: 'search', + placeholder: mw.msg( 'rcfilters-search-placeholder' ) + } ); + + this.capsule = new mw.rcfilters.ui.FilterCapsuleMultiselectWidget( this.textInput, { + popup: { + $content: this.filterPopup.$element, + classes: [ 'mw-rcfilters-ui-filterWrapperWidget-popup' ] + } + } ); + + // Events + this.model.connect( this, { + initialize: 'onModelInitialize', + itemUpdate: 'onModelItemUpdate' + } ); + this.textInput.connect( this, { + change: 'onTextInputChange' + } ); + this.capsule.connect( this, { + remove: 'onCapsuleRemoveItem' + } ); + + this.$element + .addClass( 'mw-rcfilters-ui-filterWrapperWidget' ) + .append( this.capsule.$element, this.textInput.$element ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.Widget ); + OO.mixinClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.mixin.PendingElement ); + + /** + * Respond to text input change + * + * @param {string} newValue Current value + */ + mw.rcfilters.ui.FilterWrapperWidget.prototype.onTextInputChange = function ( newValue ) { + // Filter the results + this.filterPopup.filter( this.model.findMatches( newValue ) ); + }; + + /** + * Respond to an event where an item is removed from the capsule. + * This is the case where a user actively removes a filter box from the capsule widget. + * + * @param {string[]} filterNames An array of filter names that were removed + */ + mw.rcfilters.ui.FilterWrapperWidget.prototype.onCapsuleRemoveItem = function ( filterNames ) { + var filterItem, + widget = this; + + filterNames.forEach( function ( filterName ) { + // Go over filters + filterItem = widget.model.getItemByName( filterName ); + filterItem.toggleSelected( false ); + } ); + }; + + /** + * Respond to model update event and set up the available filters to choose + * from. + */ + mw.rcfilters.ui.FilterWrapperWidget.prototype.onModelInitialize = function () { + var items, + filters = this.model.getItems(); + + // Reset + this.capsule.getMenu().clearItems(); + + // Insert hidden options for the capsule to get its item data from + items = filters.map( function ( filterItem ) { + return new OO.ui.MenuOptionWidget( { + data: filterItem.getName(), + label: filterItem.getLabel() + } ); + } ); + + this.capsule.getMenu().addItems( items ); + }; + + /** + * Respond to model item update + * + * @param {mw.rcfilters.dm.FilterItem} item Filter item that was updated + */ + mw.rcfilters.ui.FilterWrapperWidget.prototype.onModelItemUpdate = function ( item ) { + if ( item.isSelected() ) { + this.capsule.addItemsFromData( [ item.getName() ] ); + } else { + this.capsule.removeItemsFromData( [ item.getName() ] ); + } + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js new file mode 100644 index 0000000000..f5ec1fca6b --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js @@ -0,0 +1,154 @@ +( function ( mw, $ ) { + /** + * List displaying all filter groups + * + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.GroupWidget + * @mixins OO.ui.mixin.LabelElement + * + * @constructor + * @param {mw.rcfilters.Controller} controller Controller + * @param {mw.rcfilters.dm.FiltersViewModel} model View model + * @param {Object} config Configuration object + */ + mw.rcfilters.ui.FiltersListWidget = function MwRcfiltersUiFiltersListWidget( controller, model, config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.FiltersListWidget.parent.call( this, config ); + // Mixin constructors + OO.ui.mixin.GroupWidget.call( this, config ); + OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { + $label: $( '
' ) + .addClass( 'mw-rcfilters-ui-filtersListWidget-title' ) + } ) ); + + this.controller = controller; + this.model = model; + + this.noResultsLabel = new OO.ui.LabelWidget( { + label: mw.msg( 'rcfilters-filterlist-noresults' ), + classes: [ 'mw-rcfilters-ui-filtersListWidget-noresults' ] + } ); + + // Events + this.model.connect( this, { + initialize: 'onModelInitialize' + } ); + + // Initialize + this.showNoResultsMessage( false ); + this.$element + .addClass( 'mw-rcfilters-ui-filtersListWidget' ) + .append( + this.$label, + this.$group + .addClass( 'mw-rcfilters-ui-filtersListWidget-group' ), + this.noResultsLabel.$element + ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.FiltersListWidget, OO.ui.Widget ); + OO.mixinClass( mw.rcfilters.ui.FiltersListWidget, OO.ui.mixin.GroupWidget ); + OO.mixinClass( mw.rcfilters.ui.FiltersListWidget, OO.ui.mixin.LabelElement ); + + /* Methods */ + + /** + * Respond to initialize event from the model + */ + mw.rcfilters.ui.FiltersListWidget.prototype.onModelInitialize = function () { + var i, group, groupWidget, + itemWidgets = [], + groupWidgets = [], + groups = this.model.getFilterGroups(); + + // Reset + this.clearItems(); + + for ( group in groups ) { + groupWidget = new mw.rcfilters.ui.FilterGroupWidget( group, { + label: groups[ group ].title + } ); + groupWidgets.push( groupWidget ); + + itemWidgets = []; + if ( groups[ group ].filters ) { + for ( i = 0; i < groups[ group ].filters.length; i++ ) { + itemWidgets.push( + new mw.rcfilters.ui.FilterItemWidget( + this.controller, + groups[ group ].filters[ i ], + { + label: groups[ group ].filters[ i ].getLabel(), + description: groups[ group ].filters[ i ].getDescription() + } + ) + ); + } + + groupWidget.addItems( itemWidgets ); + } + } + + this.addItems( groupWidgets ); + }; + + /** + * Switch between showing the 'no results' message for filtering results or the result list. + * + * @param {boolean} showNoResults Show no results message + */ + mw.rcfilters.ui.FiltersListWidget.prototype.showNoResultsMessage = function ( showNoResults ) { + this.noResultsLabel.toggle( !!showNoResults ); + this.$group.toggleClass( 'oo-ui-element-hidden', !!showNoResults ); + }; + + /** + * Show only the items matching with the models in the given list + * + * @param {Object} groupItems An object of items to show + * arranged by their group names + */ + mw.rcfilters.ui.FiltersListWidget.prototype.filter = function ( groupItems ) { + var i, j, groupName, itemWidgets, + groupWidgets = this.getItems(), + hasItemWithName = function ( itemArr, name ) { + return !!itemArr.filter( function ( item ) { + return item.getName() === name; + } ).length; + }; + + if ( $.isEmptyObject( groupItems ) ) { + // No results. Hide everything, show only 'no results' + // message + this.showNoResultsMessage( true ); + return; + } + + this.showNoResultsMessage( false ); + for ( i = 0; i < groupWidgets.length; i++ ) { + groupName = groupWidgets[ i ].getName(); + + // If this group widget is in the filtered results, + // show it - otherwise, hide it + groupWidgets[ i ].toggle( !!groupItems[ groupName ] ); + + if ( !groupItems[ groupName ] ) { + // Continue to next group + continue; + } + + // We have items to show + itemWidgets = groupWidgets[ i ].getItems(); + for ( j = 0; j < itemWidgets.length; j++ ) { + // Only show items that are in the filtered list + itemWidgets[ j ].toggle( + hasItemWithName( groupItems[ groupName ], itemWidgets[ j ].getName() ) + ); + } + } + }; +}( mediaWiki, jQuery ) ); diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index e30088dbc8..f31a646902 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -93,6 +93,7 @@ return [ 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js', 'tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js', 'tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js', + 'tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js', @@ -136,6 +137,7 @@ return [ 'mediawiki.util', 'mediawiki.viewport', 'mediawiki.special.recentchanges', + 'mediawiki.rcfilters.filters', 'mediawiki.language', 'mediawiki.cldr', 'mediawiki.cookie', diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js new file mode 100644 index 0000000000..aa490a6cb9 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js @@ -0,0 +1,468 @@ +( function ( mw, $ ) { + QUnit.module( 'mediawiki.rcfilters - FiltersViewModel' ); + + QUnit.test( 'Setting up filters', function ( assert ) { + var definition = { + group1: { + title: 'Group 1', + type: 'send_unselected_if_any', + filters: [ + { + name: 'group1filter1', + label: 'Group 1: Filter 1', + description: 'Description of Filter 1 in Group 1' + }, + { + name: 'group1filter2', + label: 'Group 1: Filter 2', + description: 'Description of Filter 2 in Group 1' + } + ] + }, + group2: { + title: 'Group 2', + type: 'send_unselected_if_any', + filters: [ + { + name: 'group2filter1', + label: 'Group 2: Filter 1', + description: 'Description of Filter 1 in Group 2' + }, + { + name: 'group2filter2', + label: 'Group 2: Filter 2', + description: 'Description of Filter 2 in Group 2' + } + ] + } + }, + model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( definition ); + + assert.ok( + model.getItemByName( 'group1filter1' ) instanceof mw.rcfilters.dm.FilterItem && + model.getItemByName( 'group1filter2' ) instanceof mw.rcfilters.dm.FilterItem && + model.getItemByName( 'group2filter1' ) instanceof mw.rcfilters.dm.FilterItem && + model.getItemByName( 'group2filter2' ) instanceof mw.rcfilters.dm.FilterItem, + 'Filters instantiated and stored correctly' + ); + + assert.deepEqual( + model.getState(), + { + group1filter1: false, + group1filter2: false, + group2filter1: false, + group2filter2: false + }, + 'Initial state of filters' + ); + + model.updateFilters( { + group1filter1: true, + group2filter2: true + } ); + assert.deepEqual( + model.getState(), + { + group1filter1: true, + group1filter2: false, + group2filter1: false, + group2filter2: true + }, + 'Updating filter states correctly' + ); + } ); + + QUnit.test( 'Finding matching filters', function ( assert ) { + var matches, + definition = { + group1: { + title: 'Group 1', + type: 'send_unselected_if_any', + filters: [ + { + name: 'group1filter1', + label: 'Group 1: Filter 1', + description: 'Description of Filter 1 in Group 1' + }, + { + name: 'group1filter2', + label: 'Group 1: Filter 2', + description: 'Description of Filter 2 in Group 1' + } + ] + }, + group2: { + title: 'Group 2', + type: 'send_unselected_if_any', + filters: [ + { + name: 'group2filter1', + label: 'Group 2: Filter 1', + description: 'Description of Filter 1 in Group 2' + }, + { + name: 'group2filter2', + label: 'Group 2: Filter 2', + description: 'Description of Filter 2 in Group 2' + } + ] + } + }, + model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( definition ); + + matches = model.findMatches( 'group 1' ); + assert.equal( + matches.group1.length, + 2, + 'findMatches finds correct group with correct number of results' + ); + + assert.deepEqual( + matches.group1.map( function ( item ) { return item.getName(); } ), + [ 'group1filter1', 'group1filter2' ], + 'findMatches finds the correct items within a single group' + ); + + matches = model.findMatches( 'filter 1' ); + assert.ok( + matches.group1.length === 1 && matches.group2.length === 1, + 'findMatches finds correct number of results in multiple groups' + ); + + assert.deepEqual( + [ + matches.group1.map( function ( item ) { return item.getName(); } ), + matches.group2.map( function ( item ) { return item.getName(); } ) + ], + [ + [ 'group1filter1' ], + [ 'group2filter1' ] + ], + 'findMatches finds the correct items within multiple groups' + ); + + matches = model.findMatches( 'foo' ); + assert.ok( + $.isEmptyObject( matches ), + 'findMatches returns an empty object when no results found' + ); + } ); + + QUnit.test( 'getParametersFromFilters', function ( assert ) { + var definition = { + group1: { + title: 'Group 1', + type: 'send_unselected_if_any', + filters: [ + { + name: 'hidefilter1', + label: 'Group 1: Filter 1', + description: 'Description of Filter 1 in Group 1' + }, + { + name: 'hidefilter2', + label: 'Group 1: Filter 2', + description: 'Description of Filter 2 in Group 1' + }, + { + name: 'hidefilter3', + label: 'Group 1: Filter 3', + description: 'Description of Filter 3 in Group 1' + } + ] + }, + group2: { + title: 'Group 2', + type: 'send_unselected_if_any', + filters: [ + { + name: 'hidefilter4', + label: 'Group 2: Filter 1', + description: 'Description of Filter 1 in Group 2' + }, + { + name: 'hidefilter5', + label: 'Group 2: Filter 2', + description: 'Description of Filter 2 in Group 2' + }, + { + name: 'hidefilter6', + label: 'Group 2: Filter 3', + description: 'Description of Filter 3 in Group 2' + } + ] + } + }, + model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( definition ); + + // Starting with all filters unselected + assert.deepEqual( + model.getParametersFromFilters(), + { + hidefilter1: 0, + hidefilter2: 0, + hidefilter3: 0, + hidefilter4: 0, + hidefilter5: 0, + hidefilter6: 0 + }, + 'Unselected filters return all parameters falsey.' + ); + + // Select 1 filter + model.updateFilters( { + hidefilter1: true, + hidefilter2: false, + hidefilter3: false, + hidefilter4: false, + hidefilter5: false, + hidefilter6: false + } ); + // Only one filter in one group + assert.deepEqual( + model.getParametersFromFilters(), + { + // Group 1 (one selected, the others are true) + hidefilter1: 0, + hidefilter2: 1, + hidefilter3: 1, + // Group 2 (nothing is selected, all false) + hidefilter4: 0, + hidefilter5: 0, + hidefilter6: 0 + }, + 'One filters in one "send_unselected_if_any" group returns the other parameters truthy.' + ); + + // Select 2 filters + model.updateFilters( { + hidefilter1: true, + hidefilter2: true, + hidefilter3: false, + hidefilter4: false, + hidefilter5: false, + hidefilter6: false + } ); + // Two selected filters in one group + assert.deepEqual( + model.getParametersFromFilters(), + { + // Group 1 (two selected, the others are true) + hidefilter1: 0, + hidefilter2: 0, + hidefilter3: 1, + // Group 2 (nothing is selected, all false) + hidefilter4: 0, + hidefilter5: 0, + hidefilter6: 0 + }, + 'One filters in one "send_unselected_if_any" group returns the other parameters truthy.' + ); + + // Select 3 filters + model.updateFilters( { + hidefilter1: true, + hidefilter2: true, + hidefilter3: true, + hidefilter4: false, + hidefilter5: false, + hidefilter6: false + } ); + // All filters of the group are selected == this is the same as not selecting any + assert.deepEqual( + model.getParametersFromFilters(), + { + // Group 1 (all selected, all false) + hidefilter1: 0, + hidefilter2: 0, + hidefilter3: 0, + // Group 2 (nothing is selected, all false) + hidefilter4: 0, + hidefilter5: 0, + hidefilter6: 0 + }, + 'All filters selected in one "send_unselected_if_any" group returns all parameters falsy.' + ); + } ); + + QUnit.test( 'getFiltersFromParameters', function ( assert ) { + var definition = { + group1: { + title: 'Group 1', + type: 'send_unselected_if_any', + filters: [ + { + name: 'hidefilter1', + label: 'Show filter 1', + description: 'Description of Filter 1 in Group 1' + }, + { + name: 'hidefilter2', + label: 'Show filter 2', + description: 'Description of Filter 2 in Group 1' + }, + { + name: 'hidefilter3', + label: 'Show filter 3', + description: 'Description of Filter 3 in Group 1' + } + ] + }, + group2: { + title: 'Group 2', + type: 'send_unselected_if_any', + filters: [ + { + name: 'hidefilter4', + label: 'Show filter 4', + description: 'Description of Filter 1 in Group 2' + }, + { + name: 'hidefilter5', + label: 'Show filter 5', + description: 'Description of Filter 2 in Group 2' + }, + { + name: 'hidefilter6', + label: 'Show filter 6', + description: 'Description of Filter 3 in Group 2' + } + ] + } + }, + model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( definition ); + + // Empty query = empty filter definition + assert.deepEqual( + model.getFiltersFromParameters( {} ), + { + hidefilter1: false, // The text is "show filter 1" + hidefilter2: false, // The text is "show filter 2" + hidefilter3: false, // The text is "show filter 3" + hidefilter4: false, // The text is "show filter 4" + hidefilter5: false, // The text is "show filter 5" + hidefilter6: false // The text is "show filter 6" + }, + 'Empty parameter query results in filters in initial state' + ); + + assert.deepEqual( + model.getFiltersFromParameters( { + hidefilter1: '1' + } ), + { + hidefilter1: false, // The text is "show filter 1" + hidefilter2: true, // The text is "show filter 2" + hidefilter3: true, // The text is "show filter 3" + hidefilter4: false, // The text is "show filter 4" + hidefilter5: false, // The text is "show filter 5" + hidefilter6: false // The text is "show filter 6" + }, + 'One falsey parameter in a group makes the rest of the filters in the group truthy (checked) in the interface' + ); + + assert.deepEqual( + model.getFiltersFromParameters( { + hidefilter1: '1', + hidefilter2: '1' + } ), + { + hidefilter1: false, // The text is "show filter 1" + hidefilter2: false, // The text is "show filter 2" + hidefilter3: true, // The text is "show filter 3" + hidefilter4: false, // The text is "show filter 4" + hidefilter5: false, // The text is "show filter 5" + hidefilter6: false // The text is "show filter 6" + }, + 'Two falsey parameters in a group makes the rest of the filters in the group truthy (checked) in the interface' + ); + + assert.deepEqual( + model.getFiltersFromParameters( { + hidefilter1: '1', + hidefilter2: '1', + hidefilter3: '1' + } ), + { + // TODO: This will have to be represented as a different state, though. + hidefilter1: false, // The text is "show filter 1" + hidefilter2: false, // The text is "show filter 2" + hidefilter3: false, // The text is "show filter 3" + hidefilter4: false, // The text is "show filter 4" + hidefilter5: false, // The text is "show filter 5" + hidefilter6: false // The text is "show filter 6" + }, + 'All paremeters in the same group false is equivalent to none are truthy (checked) in the interface' + ); + + // The ones above don't update the model, so we have a clean state. + + model.updateFilters( + model.getFiltersFromParameters( { + hidefilter1: '1' + } ) + ); + + model.updateFilters( + model.getFiltersFromParameters( { + hidefilter3: '1' + } ) + ); + + // 1 and 3 are separately unchecked via hide parameters, 2 should still be + // checked. + // This can simulate separate filters in the same group being hidden different + // ways (e.g. preferences and URL). + assert.deepEqual( + model.getState(), + { + hidefilter1: false, // The text is "show filter 1" + hidefilter2: true, // The text is "show filter 2" + hidefilter3: false, // The text is "show filter 3" + hidefilter4: false, // The text is "show filter 4" + hidefilter5: false, // The text is "show filter 5" + hidefilter6: false // The text is "show filter 6" + }, + 'After unchecking 2 of 3 filters via separate updateFilters calls, only the remaining one is still checked.' + ); + + // Reset + model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( definition ); + + model.updateFilters( + model.getFiltersFromParameters( { + hidefilter1: '1' + } ) + ); + model.updateFilters( + model.getFiltersFromParameters( { + hidefilter1: '0' + } ) + ); + + // Simulates minor edits being hidden in preferences, then unhidden via URL + // override. + assert.deepEqual( + model.getState(), + { + hidefilter1: false, // The text is "show filter 1" + hidefilter2: false, // The text is "show filter 2" + hidefilter3: false, // The text is "show filter 3" + hidefilter4: false, // The text is "show filter 4" + hidefilter5: false, // The text is "show filter 5" + hidefilter6: false // The text is "show filter 6" + }, + 'After unchecking then checking a filter (without touching other filters in that group), all are checked' + ); + } ); +}( mediaWiki, jQuery ) ); -- 2.20.1