From 09e441003bab2d61b019967e88c0b2eaea2bfaa1 Mon Sep 17 00:00:00 2001 From: Stephane Bisson Date: Fri, 10 Feb 2017 09:18:02 -0500 Subject: [PATCH] RCFilters UI: Highlight behavior Let there be highlight! and there were highlights And RCFilters separated the highlight from the darkness And it defined highlights as five colors The lights are called yellow and green, and the darks red and blue And there were colors and there were circles; one highlight. This is the commit that adds highlight support for filters both in the backend and the UI. The backend tags results based on which filter they fit and the front end paints those results according to the color chosen by the user. Highlights can be toggled off and on. Also added circle indicators to the capsule items and each line of results to indicate whether the line has more than one color affecting it. Bug: T149467 Bug: T156164 Change-Id: I341c3f7c224271a18d455b9e5f5457ec43de802d --- includes/changes/ChangesList.php | 32 +++- includes/changes/EnhancedChangesList.php | 6 +- includes/user/User.php | 36 ++++ languages/i18n/en.json | 2 + languages/i18n/qqq.json | 2 + resources/Resources.php | 14 +- .../dm/mw.rcfilters.dm.FilterItem.js | 83 ++++++++++ .../dm/mw.rcfilters.dm.FiltersViewModel.js | 107 +++++++++++- .../mw.rcfilters.Controller.js | 87 ++++++++-- .../mw.rcfilters.HighlightColors.js | 9 + .../mediawiki.rcfilters/mw.rcfilters.init.js | 48 ++++-- .../styles/mw.rcfilters.mixins.less | 63 +++++++ .../mw.rcfilters.ui.CapsuleItemWidget.less | 41 ++++- ...rcfilters.ui.ChangesListWrapperWidget.less | 125 ++++++++++++++ ...cfilters.ui.FilterItemHighlightButton.less | 31 ++++ .../mw.rcfilters.ui.FilterItemWidget.less | 39 +++-- .../mw.rcfilters.ui.FiltersListWidget.less | 5 + ...filters.ui.HighlightColorPickerWidget.less | 73 ++++++++ .../styles/mw.rcfilters.ui.less | 16 ++ .../styles/mw.rcfilters.variables.less | 19 +++ .../ui/mw.rcfilters.ui.CapsuleItemWidget.js | 20 +++ ...w.rcfilters.ui.ChangesListWrapperWidget.js | 156 ++++++++++++++++-- ...lters.ui.FilterCapsuleMultiselectWidget.js | 44 ++++- .../ui/mw.rcfilters.ui.FilterGroupWidget.js | 4 +- ....rcfilters.ui.FilterItemHighlightButton.js | 71 ++++++++ .../ui/mw.rcfilters.ui.FilterItemWidget.js | 28 +++- .../ui/mw.rcfilters.ui.FilterWrapperWidget.js | 3 +- .../ui/mw.rcfilters.ui.FiltersListWidget.js | 28 +++- ...rcfilters.ui.HighlightColorPickerWidget.js | 112 +++++++++++++ tests/phpunit/includes/user/UserTest.php | 49 ++++++ .../dm.FiltersViewModel.test.js | 112 +++++++++++++ 31 files changed, 1381 insertions(+), 84 deletions(-) create mode 100644 resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.mixins.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemHighlightButton.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.HighlightColorPickerWidget.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js diff --git a/includes/changes/ChangesList.php b/includes/changes/ChangesList.php index 1e88e13636..9a1f775f49 100644 --- a/includes/changes/ChangesList.php +++ b/includes/changes/ChangesList.php @@ -158,19 +158,43 @@ class ChangesList extends ContextSource { protected function getHTMLClasses( $rc, $watched ) { $classes = []; $logType = $rc->mAttribs['rc_log_type']; + $prefix = 'mw-changeslist-'; if ( $logType ) { - $classes[] = Sanitizer::escapeClass( 'mw-changeslist-log-' . $logType ); + $classes[] = Sanitizer::escapeClass( $prefix . 'log-' . $logType ); } else { - $classes[] = Sanitizer::escapeClass( 'mw-changeslist-ns' . + $classes[] = Sanitizer::escapeClass( $prefix . 'ns' . $rc->mAttribs['rc_namespace'] . '-' . $rc->mAttribs['rc_title'] ); } // Indicate watched status on the line to allow for more // comprehensive styling. $classes[] = $watched && $rc->mAttribs['rc_timestamp'] >= $watched - ? 'mw-changeslist-line-watched' - : 'mw-changeslist-line-not-watched'; + ? $prefix . 'line-watched' + : $prefix . 'line-not-watched'; + + $classes = array_merge( $classes, $this->getHTMLClassesForFilters( $rc ) ); + + return $classes; + } + + protected function getHTMLClassesForFilters( $rc ) { + $classes = []; + $prefix = 'mw-changeslist-'; + + $classes[] = $prefix . ( $rc->getAttribute( 'rc_bot' ) ? 'bot' : 'human' ); + $classes[] = $prefix . ( $rc->getAttribute( 'rc_user' ) ? 'liu' : 'anon' ); + $classes[] = $prefix . ( $rc->getAttribute( 'rc_minor' ) ? 'minor' : 'major' ); + $classes[] = $prefix . + ( $rc->getAttribute( 'rc_patrolled' ) ? 'patrolled' : 'unpatrolled' ); + $classes[] = $prefix . + ( $this->getUser()->equals( $rc->getPerformer() ) ? 'self' : 'others' ); + $classes[] = $prefix . 'src-' . str_replace( '.', '-', $rc->getAttribute( 'rc_source' ) ); + + $performer = $rc->getPerformer(); + if ( $performer && $performer->isLoggedIn() ) { + $classes[] = $prefix . 'user-' . $performer->getExperienceLevel(); + } return $classes; } diff --git a/includes/changes/EnhancedChangesList.php b/includes/changes/EnhancedChangesList.php index d3a414b5be..3c76f32284 100644 --- a/includes/changes/EnhancedChangesList.php +++ b/includes/changes/EnhancedChangesList.php @@ -177,6 +177,7 @@ class EnhancedChangesList extends ChangesList { && $block[0]->mAttribs['rc_timestamp'] >= $block[0]->watched ) { $tableClasses[] = 'mw-changeslist-line-watched'; + $tableClasses = array_merge( $tableClasses, $this->getHTMLClassesForFilters( $block[0] ) ); } else { $tableClasses[] = 'mw-changeslist-line-not-watched'; } @@ -358,16 +359,17 @@ class EnhancedChangesList extends ChangesList { protected function getLineData( array $block, RCCacheEntry $rcObj, array $queryParams = [] ) { $RCShowChangedSize = $this->getConfig()->get( 'RCShowChangedSize' ); - $classes = [ 'mw-enhanced-rc' ]; $type = $rcObj->mAttribs['rc_type']; $data = []; $lineParams = []; + $classes = [ 'mw-enhanced-rc' ]; if ( $rcObj->watched && $rcObj->mAttribs['rc_timestamp'] >= $rcObj->watched ) { - $classes = [ 'mw-enhanced-watched' ]; + $classes[] = [ 'mw-enhanced-watched' ]; } + $classes = array_merge( $classes, $this->getHTMLClassesForFilters( $rcObj ) ); $separator = ' . . '; diff --git a/includes/user/User.php b/includes/user/User.php index 1b32503a7a..d9c2a58320 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -3770,6 +3770,42 @@ class User implements IDBAccessObject { // user_talk page; it's cleared one page view later in WikiPage::doViewUpdates(). } + /** + * Compute experienced level based on edit count and registration date. + * + * @return string 'newcomer', 'learner', or 'experienced' + */ + public function getExperienceLevel() { + global $wgLearnerEdits, + $wgExperiencedUserEdits, + $wgLearnerMemberSince, + $wgExperiencedUserMemberSince; + + if ( $this->isAnon() ) { + return false; + } + + $editCount = $this->getEditCount(); + $registration = $this->getRegistration(); + $now = time(); + $learnerRegistration = wfTimestamp( TS_MW, $now - $wgLearnerMemberSince * 86400 ); + $experiencedRegistration = wfTimestamp( TS_MW, $now - $wgExperiencedUserMemberSince * 86400 ); + + if ( + $editCount < $wgLearnerEdits || + $registration > $learnerRegistration + ) { + return 'newcomer'; + } elseif ( + $editCount > $wgExperiencedUserEdits && + $registration <= $experiencedRegistration + ) { + return 'experienced'; + } else { + return 'learner'; + } + } + /** * Set a cookie on the user's client. Wrapper for * WebResponse::setCookie diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 8de97e9963..abee4a9528 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1378,6 +1378,8 @@ "rcfilters-invalid-filter": "Invalid filter", "rcfilters-empty-filter": "No active filters. All contributions are shown.", "rcfilters-filterlist-title": "Filters", + "rcfilters-highlightbutton-title": "Highlight results", + "rcfilters-highlightmenu-title": "Select a color", "rcfilters-filterlist-noresults": "No filters found", "rcfilters-filtergroup-registration": "User registration", "rcfilters-filter-registered-label": "Registered", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 6ffc851e49..7665f80ff8 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1564,6 +1564,8 @@ "rcfilters-invalid-filter": "A label for an invalid filter.", "rcfilters-empty-filter": "Placeholder for the filter list when no filters were chosen.", "rcfilters-filterlist-title": "Title for the filters list.\n{{Identical|Filter}}", + "rcfilters-highlightbutton-title": "Title for the highlight button used to toggle the highlight feature on and off.", + "rcfilters-highlightmenu-title": "Title for the highlight menu used to select the highlight color for an individual filter.", "rcfilters-filterlist-noresults": "Message showing no results found for searching a filter.", "rcfilters-filtergroup-registration": "Title for the filter group for editor registration type.", "rcfilters-filter-registered-label": "Label for the filter for showing edits made by logged-in users.\n{{Identical|Registered}}", diff --git a/resources/Resources.php b/resources/Resources.php index 2f0311fe18..0b24b710ab 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1776,9 +1776,15 @@ return [ '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.FormWrapperWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js', + 'resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js', 'resources/src/mediawiki.rcfilters/mw.rcfilters.init.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.FilterItemWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.less', @@ -1786,6 +1792,9 @@ return [ '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', + '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', ], 'messages' => [ 'rcfilters-activefilters', @@ -1832,12 +1841,15 @@ return [ 'rcfilters-filter-categorization-description', 'rcfilters-filter-logactions-label', 'rcfilters-filter-logactions-description', + 'rcfilters-highlightbutton-title', + 'rcfilters-highlightmenu-title', 'recentchanges-noresult', ], 'dependencies' => [ 'oojs-ui', 'mediawiki.rcfilters.filters.dm', - 'oojs-ui.styles.icons-moderation' + 'oojs-ui.styles.icons-moderation', + 'oojs-ui.styles.icons-editing-core', ], ], 'mediawiki.special' => [ diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js index 39c7667f9e..675f4b59fe 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js @@ -17,6 +17,7 @@ * @cfg {boolean} [selected] The item is selected * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter * @cfg {string[]} [conflictsWith] Defining the names of filters that conflict with this item + * @cfg {string} [cssClass] The class identifying the results that match this filter */ mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( name, groupModel, config ) { config = config || {}; @@ -40,6 +41,11 @@ this.included = false; this.conflicted = false; this.fullyCovered = false; + + // Highlight + this.cssClass = config.cssClass; + this.highlightColor = null; + this.highlightEnabled = false; }; /* Initialization */ @@ -281,4 +287,81 @@ this.emit( 'update' ); } }; + + /** + * Set the highlight color + * + * @param {string|null} highlightColor + */ + mw.rcfilters.dm.FilterItem.prototype.setHighlightColor = function ( highlightColor ) { + if ( this.highlightColor !== highlightColor ) { + this.highlightColor = highlightColor; + this.emit( 'update' ); + } + }; + + /** + * Clear the highlight color + */ + mw.rcfilters.dm.FilterItem.prototype.clearHighlightColor = function () { + this.setHighlightColor( null ); + }; + + /** + * Get the highlight color, or null if none is configured + * + * @return {string|null} + */ + mw.rcfilters.dm.FilterItem.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.FilterItem.prototype.getCssClass = function () { + return this.cssClass; + }; + + /** + * Toggle the highlight feature on and off for this filter. + * It only works if highlight is supported for this filter. + * + * @param {boolean} enable Highlight should be enabled + */ + mw.rcfilters.dm.FilterItem.prototype.toggleHighlight = function ( enable ) { + enable = enable === undefined ? !this.highlightEnabled : enable; + + if ( !this.isHighlightSupported() ) { + return; + } + + if ( enable === this.highlightEnabled ) { + return; + } + + this.highlightEnabled = enable; + this.emit( 'update' ); + }; + + /** + * Check if the highlight feature is currently enabled for this filter + * + * @return {boolean} + */ + mw.rcfilters.dm.FilterItem.prototype.isHighlightEnabled = function () { + return !!this.highlightEnabled; + }; + + /** + * Check if the highlight feature is supported for this filter + * + * @return {boolean} + */ + mw.rcfilters.dm.FilterItem.prototype.isHighlightSupported = function () { + return !!this.getCssClass(); + }; }( 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 index 13f7d31292..d58eb2ed23 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js @@ -15,6 +15,7 @@ this.groups = {}; this.defaultParams = {}; this.defaultFiltersEmpty = null; + this.highlightEnabled = false; // Events this.aggregate( { update: 'filterItemUpdate' } ); @@ -41,6 +42,13 @@ * Filter item has changed */ + /** + * @event highlightChange + * @param {boolean} Highlight feature is enabled + * + * Highlight feature has been toggled enabled or disabled + */ + /* Methods */ /** @@ -191,7 +199,8 @@ group: group, label: data.filters[ i ].label, description: data.filters[ i ].description, - subset: data.filters[ i ].subset + subset: data.filters[ i ].subset, + cssClass: data.filters[ i ].class } ); // For convenience, we should store each filter's "supersets" -- these are @@ -402,6 +411,21 @@ return result; }; + /** + * Get the highlight parameters based on current filter configuration + * + * @return {object} Object where keys are "_color" and values + * are the selected highlight colors. + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () { + var result = { highlight: this.isHighlightEnabled() }; + + this.getItems().forEach( function ( filterItem ) { + result[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor(); + } ); + return result; + }; + /** * Sanitize value group of a string_option groups type * Remove duplicates and make sure to only use valid @@ -448,10 +472,15 @@ * @return {boolean} Current filters are all empty */ mw.rcfilters.dm.FiltersViewModel.prototype.areCurrentFiltersEmpty = function () { - var currFilters = this.getSelectedState(); - - return Object.keys( currFilters ).every( function ( filterName ) { - return !currFilters[ filterName ]; + var model = this; + + // Check if there are either any selected items or any items + // that have highlight enabled + return !this.getItems().some( function ( filterItem ) { + return ( + filterItem.isSelected() || + ( model.isHighlightEnabled() && filterItem.getHighlightColor() ) + ); } ); }; @@ -659,4 +688,72 @@ return 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(); + } ); + }; + + /** + * 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.getItems().forEach( function ( filterItem ) { + filterItem.toggleHighlight( this.highlightEnabled ); + }.bind( this ) ); + + this.emit( 'highlightChange', this.highlightEnabled ); + } + }; + + /** + * Check if the highlight feature is enabled + * @return {boolean} + */ + mw.rcfilters.dm.FiltersViewModel.prototype.isHighlightEnabled = function () { + return this.highlightEnabled; + }; + + /** + * 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(); + }; + + /** + * Clear highlight for all filter items + */ + mw.rcfilters.dm.FiltersViewModel.prototype.clearAllHighlightColors = function () { + this.getItems().forEach( function ( filterItem ) { + filterItem.clearHighlightColor(); + } ); + }; }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js index ff34bb8c5d..3ba4dc0703 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -39,6 +39,17 @@ ) ); + // Initialize highlights + this.filtersModel.toggleHighlight( !!uri.query.highlight ); + this.filtersModel.getItems().forEach( function ( filterItem ) { + var color = uri.query[ filterItem.getName() + '_color' ]; + if ( !color ) { + return; + } + + filterItem.setHighlightColor( color ); + } ); + // Check all filter interactions this.filtersModel.reassessFilterInteractions(); }; @@ -57,6 +68,7 @@ */ mw.rcfilters.Controller.prototype.emptyFilters = function () { this.filtersModel.emptyAllFilters(); + this.filtersModel.clearAllHighlightColors(); this.updateURL(); this.updateChangesList(); }; @@ -68,23 +80,37 @@ * @param {boolean} isSelected Filter selected state */ mw.rcfilters.Controller.prototype.updateFilter = function ( filterName, isSelected ) { - var obj = {}; + var obj = {}, + filterItem = this.filtersModel.getItemByName( filterName ); - obj[ filterName ] = isSelected; + if ( filterItem.isSelected() !== isSelected ) { + obj[ filterName ] = isSelected; + this.filtersModel.updateFilters( obj ); - this.filtersModel.updateFilters( obj ); - this.updateURL(); - this.updateChangesList(); + this.updateURL(); + this.updateChangesList(); - // Check filter interactions - this.filtersModel.reassessFilterInteractions( this.filtersModel.getItemByName( filterName ) ); + // Check filter interactions + this.filtersModel.reassessFilterInteractions( this.filtersModel.getItemByName( filterName ) ); + } }; /** * Update the URL of the page to reflect current filters */ mw.rcfilters.Controller.prototype.updateURL = function () { - var uri = new mw.Uri(); + var uri = this.getUpdatedUri(); + window.history.pushState( { tag: 'rcfilters' }, document.title, uri.toString() ); + }; + + /** + * Get an updated mw.Uri object based on the model state + * + * @return {mw.Uri} Updated Uri + */ + mw.rcfilters.Controller.prototype.getUpdatedUri = function () { + var uri = new mw.Uri(), + highlightParams = this.filtersModel.getHighlightParameters(); // Add to existing queries in URL // TODO: Clean up the list of filters; perhaps 'falsy' filters @@ -92,17 +118,25 @@ // and see if current state of a specific filter is needed? uri.extend( this.filtersModel.getParametersFromFilters() ); - // Update the URL itself - window.history.pushState( { tag: 'rcfilters' }, document.title, uri.toString() ); + // highlight params + Object.keys( highlightParams ).forEach( function ( paramName ) { + if ( highlightParams[ paramName ] ) { + uri.query[ paramName ] = highlightParams[ paramName ]; + } else { + delete uri.query[ paramName ]; + } + } ); + + return uri; }; /** * Fetch the list of changes from the server for the current filters * - * @returns {jQuery.Promise} Promise object that will resolve with the changes list + * @return {jQuery.Promise} Promise object that will resolve with the changes list */ mw.rcfilters.Controller.prototype.fetchChangesList = function () { - var uri = new mw.Uri(), + var uri = this.getUpdatedUri(), requestId = ++this.requestCounter, latestRequest = function () { return requestId === this.requestCounter; @@ -130,4 +164,33 @@ } }.bind( this ) ); }; + + /** + * Toggle the highlight feature on and off + */ + mw.rcfilters.Controller.prototype.toggleHighlight = function () { + this.filtersModel.toggleHighlight(); + this.updateURL(); + }; + + /** + * 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.updateURL(); + }; + + /** + * 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.updateURL(); + }; }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js new file mode 100644 index 0000000000..ebeaad6ed5 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js @@ -0,0 +1,9 @@ +( function ( mw ) { + /** + * Supported highlight colors. + * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less" + * + * @type {string[]} + */ + mw.rcfilters.HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ]; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index 61df2e80ec..33e9f57359 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -19,7 +19,7 @@ // eslint-disable-next-line no-new new mw.rcfilters.ui.ChangesListWrapperWidget( - changesListModel, $( '.mw-changeslist, .mw-changeslist-empty' ) ); + filtersModel, changesListModel, $( '.mw-changeslist, .mw-changeslist-empty' ) ); // eslint-disable-next-line no-new new mw.rcfilters.ui.FormWrapperWidget( @@ -34,12 +34,14 @@ { name: 'hideliu', label: mw.msg( 'rcfilters-filter-registered-label' ), - description: mw.msg( 'rcfilters-filter-registered-description' ) + description: mw.msg( 'rcfilters-filter-registered-description' ), + 'class': 'mw-changeslist-liu' }, { name: 'hideanons', label: mw.msg( 'rcfilters-filter-unregistered-label' ), - description: mw.msg( 'rcfilters-filter-unregistered-description' ) + description: mw.msg( 'rcfilters-filter-unregistered-description' ), + 'class': 'mw-changeslist-anon' } ] }, @@ -58,19 +60,22 @@ name: 'newcomer', label: mw.msg( 'rcfilters-filter-userExpLevel-newcomer-label' ), description: mw.msg( 'rcfilters-filter-userExpLevel-newcomer-description' ), - conflicts: [ 'hideanons' ] + conflicts: [ 'hideanons' ], + 'class': 'mw-changeslist-user-newcomer' }, { name: 'learner', label: mw.msg( 'rcfilters-filter-userExpLevel-learner-label' ), description: mw.msg( 'rcfilters-filter-userExpLevel-learner-description' ), - conflicts: [ 'hideanons' ] + conflicts: [ 'hideanons' ], + 'class': 'mw-changeslist-user-learner' }, { name: 'experienced', label: mw.msg( 'rcfilters-filter-userExpLevel-experienced-label' ), description: mw.msg( 'rcfilters-filter-userExpLevel-experienced-description' ), - conflicts: [ 'hideanons' ] + conflicts: [ 'hideanons' ], + 'class': 'mw-changeslist-user-experienced' } ] }, @@ -90,12 +95,14 @@ { name: 'hidemyself', label: mw.msg( 'rcfilters-filter-editsbyself-label' ), - description: mw.msg( 'rcfilters-filter-editsbyself-description' ) + description: mw.msg( 'rcfilters-filter-editsbyself-description' ), + 'class': 'mw-changeslist-self' }, { name: 'hidebyothers', label: mw.msg( 'rcfilters-filter-editsbyother-label' ), - description: mw.msg( 'rcfilters-filter-editsbyother-description' ) + description: mw.msg( 'rcfilters-filter-editsbyother-description' ), + 'class': 'mw-changeslist-others' } ] }, @@ -108,13 +115,15 @@ name: 'hidebots', label: mw.msg( 'rcfilters-filter-bots-label' ), description: mw.msg( 'rcfilters-filter-bots-description' ), - 'default': true + 'default': true, + 'class': 'mw-changeslist-bot' }, { name: 'hidehumans', label: mw.msg( 'rcfilters-filter-humans-label' ), description: mw.msg( 'rcfilters-filter-humans-description' ), - 'default': false + 'default': false, + 'class': 'mw-changeslist-human' } ] }, @@ -126,12 +135,14 @@ { name: 'hideminor', label: mw.msg( 'rcfilters-filter-minor-label' ), - description: mw.msg( 'rcfilters-filter-minor-description' ) + description: mw.msg( 'rcfilters-filter-minor-description' ), + 'class': 'mw-changeslist-minor' }, { name: 'hidemajor', label: mw.msg( 'rcfilters-filter-major-label' ), - description: mw.msg( 'rcfilters-filter-major-description' ) + description: mw.msg( 'rcfilters-filter-major-description' ), + 'class': 'mw-changeslist-major' } ] }, @@ -144,25 +155,30 @@ name: 'hidepageedits', label: mw.msg( 'rcfilters-filter-pageedits-label' ), description: mw.msg( 'rcfilters-filter-pageedits-description' ), - 'default': false + 'default': false, + 'class': 'mw-changeslist-src-mw-edit' + }, { name: 'hidenewpages', label: mw.msg( 'rcfilters-filter-newpages-label' ), description: mw.msg( 'rcfilters-filter-newpages-description' ), - 'default': false + 'default': false, + 'class': 'mw-changeslist-src-mw-new' }, { name: 'hidecategorization', label: mw.msg( 'rcfilters-filter-categorization-label' ), description: mw.msg( 'rcfilters-filter-categorization-description' ), - 'default': true + 'default': true, + 'class': 'mw-changeslist-src-mw-categorize' }, { name: 'hidelog', label: mw.msg( 'rcfilters-filter-logactions-label' ), description: mw.msg( 'rcfilters-filter-logactions-description' ), - 'default': false + 'default': false, + 'class': 'mw-changeslist-src-mw-log' } ] } diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.mixins.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.mixins.less new file mode 100644 index 0000000000..5c31b5d95b --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.mixins.less @@ -0,0 +1,63 @@ +@import "mediawiki.mixins"; +@import "mw.rcfilters.variables"; + +// This is a general mixin for a color circle +.mw-rcfilters-mixin-circle( @color: white, @diameter: 2em, @padding: 0.5em, @border: false ) { + border-radius: 50%; + min-width: @diameter; + width: @diameter; + min-height: @diameter; + height: @diameter; + margin: @padding; + .box-sizing( border-box ); + + background-color: @color; + + & when (@border = true) { + border: 1px solid #565656; + } +} + +// This is the circle that appears next to the results +// Its visibility is directly dependent on whether there is +// a color class on its parent element +.result-circle( @colorName: 'none' ) { + &-@{colorName} { + .mw-rcfilters-mixin-circle( ~"@{highlight-@{colorName}}", @result-circle-diameter, 0 ); + display: none; + + .mw-rcfilters-highlight-color-@{colorName} & { + display: inline-block; + } + } +} + +// This mixin produces color mixes for two, three and four colors +.highlight-color-mix( @color1, @color2, @color3: false, @color4: false ) { + @highlight-color-class-var: ~".mw-rcfilters-highlight-color-@{color1}.mw-rcfilters-highlight-color-@{color2}"; + + // The nature of these variables and them being inside + // a 'tint' and 'average' LESS functions is such where + // the parsing is failing if it is done inside those functions. + // Instead, we first construct their LESS variable names, + // and then we call them inside those functions by calling @@var + @c1var: ~"highlight-@{color1}"; + @c2var: ~"highlight-@{color2}"; + + // Two colors + @{highlight-color-class-var} when ( @color3 = false ) and ( @color4 = false ) and not ( @color1 = false ), ( @color2 = false ) { + background-color: tint( average( @@c1var, @@c2var ), 50% ); + } + // Three colors + @{highlight-color-class-var}.mw-rcfilters-highlight-color-@{color3} when ( @color4 = false ) and not ( @color3 = false ) { + @c3var: ~"highlight-@{color3}"; + background-color: tint( mix( @@c1var, average( @@c2var, @@c3var ), 33% ), 30% ); + } + + // Four colors + @{highlight-color-class-var}.mw-rcfilters-highlight-color-@{color3}.mw-rcfilters-highlight-color-@{color4} when not ( @color4 = false ) { + @c3var: ~"highlight-@{color3}"; + @c4var: ~"highlight-@{color4}"; + background-color: tint( mix( @@c1var, mix( @@c2var, average( @@c3var, @@c4var ), 25% ), 25% ), 25% ); + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.less index 8a9ad54eff..0bf6f583e8 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.less @@ -1,3 +1,5 @@ +@import "mw.rcfilters.mixins"; + .mw-rcfilters-ui-capsuleItemWidget { &-popup { padding: 1em; @@ -8,7 +10,44 @@ margin-top: 1em; } + .oo-ui-labelElement-label { + vertical-align: middle; + } + &-muted { - opacity: 0.5; + // Muted state + // We want everything muted except the circle + background-color: rgba( 255, 255, 255, @muted-opacity ); + + .oo-ui-labelElement-label, + .oo-ui-buttonWidget { + opacity: @muted-opacity; + } + } + + &-highlight { + display: none; + padding-right: 0.5em; + + &-highlighted { + display: inline-block; + + } + + &[data-color="c1"] { + .mw-rcfilters-mixin-circle( @highlight-c1, 0.7em, ~"0 0.5em 0 0" ); + } + &[data-color="c2"] { + .mw-rcfilters-mixin-circle( @highlight-c2, 0.7em, ~"0 0.5em 0 0" ); + } + &[data-color="c3"] { + .mw-rcfilters-mixin-circle( @highlight-c3, 0.7em, ~"0 0.5em 0 0" ); + } + &[data-color="c4"] { + .mw-rcfilters-mixin-circle( @highlight-c4, 0.7em, ~"0 0.5em 0 0" ); + } + &[data-color="c5"] { + .mw-rcfilters-mixin-circle( @highlight-c5, 0.7em, ~"0 0.5em 0 0" ); + } } } diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less new file mode 100644 index 0000000000..5ad2a19b61 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less @@ -0,0 +1,125 @@ +@import 'mw.rcfilters.mixins'; + +.mw-rcfilters-ui-changesListWrapperWidget { + &-highlighted { + ul { + list-style: none; + // Each li's margin-left should be the width of the highlights + // element + the margin + margin-left: ~"calc( ( @{result-circle-diameter} + @{result-circle-margin} ) * 5 + @{result-circle-general-margin} )"; + } + } + + // Correction for Enhanced RC + // This is outside the scope of the 'highlights' wrapper + table.mw-enhanced-rc { + margin-left: ~"calc( ( @{result-circle-diameter} + @{result-circle-margin} ) * 5 + @{result-circle-general-margin} )"; + + td:last-child { + width: 100%; + } + } + + &-highlights { + display: none; + padding: 0 @result-circle-general-margin 0 0; + text-align: right; + // The width is 5 circles times their diameter + individual margin + // and then plus the general margin + width: ~"calc( ( @{result-circle-diameter} + @{result-circle-margin} ) * 5 )"; + // And we want to shift the entire block to the left of the li + position: absolute; + left: 0; + + .mw-rcfilters-ui-changesListWrapperWidget-highlighted & { + display: inline-block; + } + + div { + .box-sizing( border-box ); + margin-right: @result-circle-margin; + vertical-align: middle; + // This is to make the dots appear at the center of the + // text itself; it's a horrendous hack and blame JamesF for it. + margin-top: -2px; + } + + &-color { + + &-none { + .mw-rcfilters-mixin-circle( @highlight-none, @result-circle-diameter, 0, true ); + display: inline-block; + + .mw-rcfilters-highlight-color-c1 &, + .mw-rcfilters-highlight-color-c2 &, + .mw-rcfilters-highlight-color-c3 &, + .mw-rcfilters-highlight-color-c4 &, + .mw-rcfilters-highlight-color-c5 & { + display: none; + } + } + .result-circle( c1 ); + .result-circle( c2 ); + .result-circle( c3 ); + .result-circle( c4 ); + .result-circle( c5 ); + } + } + + // One color + .mw-rcfilters-highlight-color-c1 { + background-color: tint( @highlight-c1, 70% ); + } + + .mw-rcfilters-highlight-color-c2 { + background-color: tint( @highlight-c2, 70% ); + } + + .mw-rcfilters-highlight-color-c3 { + background-color: tint( @highlight-c3, 70% ); + } + + .mw-rcfilters-highlight-color-c4 { + background-color: tint( @highlight-c4, 70% ); + } + + .mw-rcfilters-highlight-color-c5 { + background-color: tint( @highlight-c5, 70% ); + } + + // Two colors + .highlight-color-mix( c1, c2 ); + .highlight-color-mix( c1, c3 ); + .highlight-color-mix( c1, c4 ); + .highlight-color-mix( c1, c5 ); + .highlight-color-mix( c2, c3 ); + .highlight-color-mix( c2, c4 ); + .highlight-color-mix( c2, c5 ); + .highlight-color-mix( c3, c4 ); + .highlight-color-mix( c3, c5 ); + .highlight-color-mix( c4, c5 ); + + // Three colors + .highlight-color-mix( c1, c2, c3 ); + .highlight-color-mix( c1, c2, c5 ); + .highlight-color-mix( c1, c2, c4 ); + .highlight-color-mix( c1, c3, c4 ); + .highlight-color-mix( c1, c3, c5 ); + .highlight-color-mix( c1, c4, c5 ); + .highlight-color-mix( c2, c3, c4 ); + .highlight-color-mix( c2, c3, c5 ); + .highlight-color-mix( c2, c4, c5 ); + .highlight-color-mix( c3, c4, c5 ); + + // Four colors + .highlight-color-mix( c1, c2, c3, c4 ); + .highlight-color-mix( c1, c2, c3, c5 ); + .highlight-color-mix( c1, c2, c4, c5 ); + .highlight-color-mix( c1, c3, c4, c5 ); + .highlight-color-mix( c2, c3, c4, c5 ); + + // Five colors: + .mw-rcfilters-highlight-color-c1.mw-rcfilters-highlight-color-c2.mw-rcfilters-highlight-color-c3.mw-rcfilters-highlight-color-c4.mw-rcfilters-highlight-color-c5 { + background-color: tint( mix( @highlight-c1, mix( @highlight-c2, mix( @highlight-c3, average( @highlight-c4, @highlight-c5 ), 20% ), 20% ), 20% ), 15% ); + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemHighlightButton.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemHighlightButton.less new file mode 100644 index 0000000000..4619b6bbdb --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemHighlightButton.less @@ -0,0 +1,31 @@ +@import "mw.rcfilters.mixins"; + +.mw-rcfilters-ui-filterItemHighlightButton { + + &-circle { + display: inline-block; + vertical-align: middle; + background-image: none; + margin-right: 0.2em; + + &-color { + &-c1 { + // These values duplicate the sizing of the icon + // width/height 1.875em + .mw-rcfilters-mixin-circle( @highlight-c1, 1.875em, 0 ); + } + &-c2 { + .mw-rcfilters-mixin-circle( @highlight-c2, 1.875em, 0 ); + } + &-c3 { + .mw-rcfilters-mixin-circle( @highlight-c3, 1.875em, 0 ); + } + &-c4 { + .mw-rcfilters-mixin-circle( @highlight-c4, 1.875em, 0 ); + } + &-c5 { + .mw-rcfilters-mixin-circle( @highlight-c5, 1.875em, 0 ); + } + } + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less index a8744164e0..293f3c367f 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less @@ -1,26 +1,37 @@ @import "mediawiki.mixins"; .mw-rcfilters-ui-filterItemWidget { - padding-left: 0.5em; + padding: 0 0.5em; .box-sizing( border-box ); - &-label { - &-title { - font-weight: bold; - font-size: 1.2em; - color: #222; + .mw-rcfilters-ui-table { + padding-top: 0.5em; + } + + &-filterCheckbox { + &-label { + &-title { + font-weight: bold; + font-size: 1.2em; + color: #222; + } + &-desc { + color: #464a4f; + } } - &-desc { - color: #464a4f; + + .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline { + // Override margin-top and -bottom rules from FieldLayout + margin: 0 !important; } - } - .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline { - // Override margin-top and -bottom rules from FieldLayout - margin: 0 !important; + &-muted { + opacity: 0.5; + } } - &-muted { - opacity: 0.5; + &-highlightButton { + width: 4em; + padding-left: 1em; } } diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FiltersListWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FiltersListWidget.less index b874e0f9c1..7fd3a215f4 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FiltersListWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FiltersListWidget.less @@ -6,6 +6,7 @@ color: #54595d; border-bottom: 1px solid #c8ccd1; background: #f8f9fa; + overflow: hidden; } &-noresults { @@ -13,4 +14,8 @@ // TODO: Unify colors with official design palette color: #666; } + + &-hightlightButton { + float: right; + } } diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.HighlightColorPickerWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.HighlightColorPickerWidget.less new file mode 100644 index 0000000000..14c07c1991 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.HighlightColorPickerWidget.less @@ -0,0 +1,73 @@ +@import "mw.rcfilters.mixins"; + +.mw-rcfilters-ui-highlightColorPickerWidget { + &-label { + display: block; + font-weight: bold; + font-size: 1.2em; + } + + &-buttonSelect { + &-color { + .oo-ui-iconElement-icon { + width: 2em; + height: 2em; + } + + &-none { + .mw-rcfilters-mixin-circle( @highlight-none, 2em, 0.5em, true ); + + &.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected { + background-color: @highlight-none; + } + } + &-c1 { + .mw-rcfilters-mixin-circle( @highlight-c1 ); + + &.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected { + background-color: @highlight-c1; + } + } + &-c2 { + .mw-rcfilters-mixin-circle( @highlight-c2 ); + + &.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected { + background-color: @highlight-c2; + } + } + &-c3 { + .mw-rcfilters-mixin-circle( @highlight-c3 ); + + &.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected { + background-color: @highlight-c3; + } + } + &-c4 { + .mw-rcfilters-mixin-circle( @highlight-c4 ); + + &.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected { + background-color: @highlight-c4; + } + } + &-c5 { + .mw-rcfilters-mixin-circle( @highlight-c5 ); + + &.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed, + &.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected { + background-color: @highlight-c5; + } + } + } + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less new file mode 100644 index 0000000000..957e9e9009 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less @@ -0,0 +1,16 @@ +.mw-rcfilters-ui { + &-table { + display: table; + width: 100%; + } + + &-row { + display: table-row; + } + + &-cell { + display: table-cell; + vertical-align: top; + } +} + diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less new file mode 100644 index 0000000000..1ef49e2102 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less @@ -0,0 +1,19 @@ +// Highlight color definitions +@highlight-none: #fff; +@highlight-c1: #36c; +@highlight-c2: #00af89; +@highlight-c3: #fc3; +@highlight-c4: #ff6d22; +@highlight-c5: #d33; + +// Muted state +@muted-opacity: 0.5; + +// Result list circle indicators +// Defined and used in mw.rcfilters.ui.ChangesListWrapperWidget.less +@result-circle-margin: 0.1em; +@result-circle-general-margin: 0.5em; +// In these small sizes, 'em' appears +// squished and inconsistent. +// Pixels are better for this use case: +@result-circle-diameter: 5px; diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js index 40d31c54a3..a547020ce6 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js @@ -45,6 +45,9 @@ // Set initial text for the popup - the description descLabelWidget.setLabel( this.model.getDescription() ); + this.$highlight = $( '
' ) + .addClass( 'mw-rcfilters-ui-capsuleItemWidget-highlight' ); + // Events this.model.connect( this, { update: 'onModelUpdate' } ); @@ -53,12 +56,14 @@ // Initialization this.$overlay.append( this.popup.$element ); this.$element + .prepend( this.$highlight ) .attr( 'aria-haspopup', 'true' ) .addClass( 'mw-rcfilters-ui-capsuleItemWidget' ) .on( 'mouseover', this.onHover.bind( this, true ) ) .on( 'mouseout', this.onHover.bind( this, false ) ); this.setCurrentMuteState(); + this.setHighlightColor(); }; OO.inheritClass( mw.rcfilters.ui.CapsuleItemWidget, OO.ui.CapsuleItemWidget ); @@ -69,6 +74,19 @@ */ mw.rcfilters.ui.CapsuleItemWidget.prototype.onModelUpdate = function () { this.setCurrentMuteState(); + + this.setHighlightColor(); + }; + + mw.rcfilters.ui.CapsuleItemWidget.prototype.setHighlightColor = function () { + var selectedColor = this.model.isHighlightEnabled() ? this.model.getHighlightColor() : null; + + this.$highlight + .attr( 'data-color', selectedColor ) + .toggleClass( + 'mw-rcfilters-ui-capsuleItemWidget-highlight-highlighted', + !!selectedColor + ); }; /** @@ -78,6 +96,7 @@ this.$element .toggleClass( 'mw-rcfilters-ui-capsuleItemWidget-muted', + !this.model.isSelected() || this.model.isIncluded() || this.model.isConflicted() || this.model.isFullyCovered() @@ -106,6 +125,7 @@ */ mw.rcfilters.ui.CapsuleItemWidget.prototype.onCapsuleRemovedByUser = function () { this.controller.updateFilter( this.model.getName(), false ); + this.controller.clearHighlightColor( this.model.getName() ); }; /** diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js index f929eb2653..a2a6b16cfe 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js @@ -6,27 +6,43 @@ * @mixins OO.ui.mixin.PendingElement * * @constructor - * @param {mw.rcfilters.dm.ChangesListViewModel} model View model + * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model + * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model * @param {jQuery} $changesListRoot Root element of the changes list to attach to * @param {Object} config Configuration object */ - mw.rcfilters.ui.ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget( model, $changesListRoot, config ) { - config = config || {}; + mw.rcfilters.ui.ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget( + filtersViewModel, + changesListViewModel, + $changesListRoot, + config + ) { + config = $.extend( {}, config, { + $element: $changesListRoot + } ); // Parent - mw.rcfilters.ui.ChangesListWrapperWidget.parent.call( this, $.extend( {}, config, { - $element: $changesListRoot - } ) ); + mw.rcfilters.ui.ChangesListWrapperWidget.parent.call( this, config ); // Mixin constructors OO.ui.mixin.PendingElement.call( this, config ); - this.model = model; + this.filtersViewModel = filtersViewModel; + this.changesListViewModel = changesListViewModel; // Events - this.model.connect( this, { + this.filtersViewModel.connect( this, { + itemUpdate: 'onItemUpdate', + highlightChange: 'onHighlightChange' + } ); + this.changesListViewModel.connect( this, { invalidate: 'onModelInvalidate', update: 'onModelUpdate' } ); + + this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget' ); + + // Set up highlight containers + this.setupHighlightContainers( this.$element ); }; /* Initialization */ @@ -35,26 +51,130 @@ OO.mixinClass( mw.rcfilters.ui.ChangesListWrapperWidget, OO.ui.mixin.PendingElement ); /** - * Respond to model invalidate + * 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.filtersViewModel.isHighlightEnabled() ) { + this.clearHighlight(); + this.applyHighlight(); + } + }; + + /** + * Respond to changes list model invalidate */ mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelInvalidate = function () { this.pushPending(); }; /** - * Respond to model update + * Respond to changes list model update * - * @param {jQuery|string} changesListContent The content of the updated changes list + * @param {jQuery|string} $changesListContent The content of the updated changes list */ - mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelUpdate = function ( changesListContent ) { - var isEmpty = changesListContent === 'NO_RESULTS'; + mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelUpdate = function ( $changesListContent ) { + var isEmpty = $changesListContent === 'NO_RESULTS'; + this.$element.toggleClass( 'mw-changeslist', !isEmpty ); this.$element.toggleClass( 'mw-changeslist-empty', isEmpty ); - this.$element.empty().append( - isEmpty ? - document.createTextNode( mw.message( 'recentchanges-noresult' ).text() ) : - changesListContent - ); + if ( isEmpty ) { + this.$changesListContent = null; + this.$element.empty().append( + document.createTextNode( mw.message( 'recentchanges-noresult' ).text() ) + ); + } else { + this.$changesListContent = $changesListContent; + this.$element.empty().append( this.$changesListContent ); + // Set up highlight containers + this.setupHighlightContainers( this.$element ); + + // Apply highlight + this.applyHighlight(); + + // Make sure enhanced RC re-initializes correctly + mw.hook( 'wikipage.content' ).fire( this.$changesListContent ); + } this.popPending(); }; + + /** + * Set up the highlight containers with all color circle indicators. + * + * @param {jQuery|string} $content The content of the updated changes list + */ + mw.rcfilters.ui.ChangesListWrapperWidget.prototype.setupHighlightContainers = function ( $content ) { + var $highlights = $( '
' ) + .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlights' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlights-color-none' ) + .prop( 'data-color', 'none' ) + ); + + mw.rcfilters.HighlightColors.forEach( function ( color ) { + $highlights.append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlights-color-' + color ) + .prop( 'data-color', color ) + ); + } ); + + if ( Number( mw.user.options.get( 'usenewrc' ) ) ) { + // Enhanced RC + $content.find( 'td.mw-enhanced-rc' ) + .parent() + .prepend( + $( '' ) + .append( $highlights.clone() ) + ); + } else { + // Regular RC + $content.find( 'ul.special li' ) + .prepend( $highlights.clone() ); + } + }; + + /** + * 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 ) { + // Add highlight class to all highlighted list items + this.$element.find( '.' + filterItem.getCssClass() ) + .addClass( 'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor() ); + }.bind( this ) ); + + // 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 ) ); + + // Turn off highlights + this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' ); + }; }( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js index 7f8d79da92..910e8e12d0 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js @@ -13,14 +13,15 @@ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups */ mw.rcfilters.ui.FilterCapsuleMultiselectWidget = function MwRcfiltersUiFilterCapsuleMultiselectWidget( controller, model, filterInput, config ) { + this.$overlay = config.$overlay || this.$element; + // Parent - mw.rcfilters.ui.FilterCapsuleMultiselectWidget.parent.call( this, $.extend( { - $autoCloseIgnore: filterInput.$element + mw.rcfilters.ui.FilterCapsuleMultiselectWidget.parent.call( this, $.extend( true, { + popup: { $autoCloseIgnore: filterInput.$element.add( this.$overlay ) } }, config ) ); this.controller = controller; this.model = model; - this.$overlay = config.$overlay || this.$element; this.filterInput = filterInput; @@ -44,7 +45,10 @@ // Events this.resetButton.connect( this, { click: 'onResetButtonClick' } ); - this.model.connect( this, { itemUpdate: 'onModelItemUpdate' } ); + this.model.connect( this, { + itemUpdate: 'onModelItemUpdate', + highlightChange: 'onModelHighlightChange' + } ); // Add the filterInput as trigger this.filterInput.$input .on( 'focus', this.focus.bind( this ) ); @@ -101,7 +105,14 @@ * @param {mw.rcfilters.dm.FilterItem} item Filter item model */ mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.onModelItemUpdate = function ( item ) { - if ( item.isSelected() ) { + if ( + item.isSelected() || + ( + this.model.isHighlightEnabled() && + item.isHighlightSupported() && + item.getHighlightColor() + ) + ) { this.addItemByName( item.getName() ); } else { this.removeItemByName( item.getName() ); @@ -111,6 +122,29 @@ this.reevaluateResetRestoreState(); }; + /** + * Respond to highlightChange event + * + * @param {boolean} isHighlightEnabled Highlight is enabled + */ + mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) { + var highlightedItems = this.model.getHighlightedItems(); + + if ( isHighlightEnabled ) { + // Add capsule widgets + highlightedItems.forEach( function ( filterItem ) { + this.addItemByName( filterItem.getName() ); + }.bind( this ) ); + } else { + // Remove capsule widgets if they're not selected + highlightedItems.forEach( function ( filterItem ) { + if ( !filterItem.isSelected() ) { + this.removeItemByName( filterItem.getName() ); + } + }.bind( this ) ); + } + }; + /** * Respond to click event on the reset button */ diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js index 37182d6c46..f858ab0fd6 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js @@ -27,6 +27,7 @@ $label: $( '
' ) .addClass( 'mw-rcfilters-ui-filterGroupWidget-title' ) } ) ); + this.$overlay = config.$overlay || this.$element; // Populate this.populateFromModel(); @@ -68,7 +69,8 @@ filterItem, { label: filterItem.getLabel(), - description: filterItem.getDescription() + description: filterItem.getDescription(), + $overlay: widget.$overlay } ); } ) diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js new file mode 100644 index 0000000000..32db0b6fe4 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js @@ -0,0 +1,71 @@ +( function ( mw, $ ) { + /** + * 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 {Object} [config] Configuration object + */ + mw.rcfilters.ui.FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, config ) { + config = config || {}; + + this.colorPickerWidget = new mw.rcfilters.ui.HighlightColorPickerWidget( controller, model ); + + // Parent + mw.rcfilters.ui.FilterItemHighlightButton.parent.call( this, $.extend( {}, config, { + icon: 'edit', + indicator: 'down', + popup: { + anchor: false, + padded: true, + align: 'backwards', + width: 290, + $content: this.colorPickerWidget.$element + } + } ) ); + + this.controller = controller; + this.model = model; + + // Event + this.model.connect( this, { update: 'onModelUpdate' } ); + this.colorPickerWidget.connect( this, { chooseColor: 'onChooseColor' } ); + + this.$element + .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.FilterItemHighlightButton, OO.ui.PopupButtonWidget ); + + /* Methods */ + + /** + * Respond to item model update event + */ + mw.rcfilters.ui.FilterItemHighlightButton.prototype.onModelUpdate = 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 + ); + } ); + }; + + mw.rcfilters.ui.FilterItemHighlightButton.prototype.onChooseColor = function () { + this.popup.toggle( false ); + }; +}( 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 index 9bf26d1a5e..63db2b042a 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js @@ -40,6 +40,15 @@ ); } + this.highlightButton = new mw.rcfilters.ui.FilterItemHighlightButton( + this.controller, + this.model, + { + $overlay: config.$overlay || this.$element + } + ); + this.highlightButton.toggle( this.model.isHighlightEnabled() ); + layout = new OO.ui.FieldLayout( this.checkboxWidget, { label: $label, align: 'inline' @@ -53,7 +62,20 @@ this.$element .addClass( 'mw-rcfilters-ui-filterItemWidget' ) .append( - layout.$element + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-filterItemWidget-filterCheckbox' ) + .append( layout.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-filterItemWidget-highlightButton' ) + .append( this.highlightButton.$element ) + ) + ) ); }; @@ -106,7 +128,10 @@ !this.model.isSelected() ) ); + + this.highlightButton.toggle( this.model.isHighlightEnabled() ); }; + /** * Get the name of this filter * @@ -115,5 +140,4 @@ 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 index 315ca86fc8..d46bd4b3cd 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js @@ -28,7 +28,8 @@ this.controller, this.model, { - label: mw.msg( 'rcfilters-filterlist-title' ) + label: mw.msg( 'rcfilters-filterlist-title' ), + $overlay: this.$overlay } ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js index 4ef3461269..ae9ee71efd 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js @@ -25,6 +25,14 @@ this.controller = controller; this.model = model; + this.$overlay = config.$overlay || this.$element; + + this.highlightButton = new OO.ui.ButtonWidget( { + label: mw.message( 'rcfilters-highlightbutton-title' ).text(), + classes: [ 'mw-rcfilters-ui-filtersListWidget-hightlightButton' ] + } ); + + this.$label.append( this.highlightButton.$element ); this.noResultsLabel = new OO.ui.LabelWidget( { label: mw.msg( 'rcfilters-filterlist-noresults' ), @@ -32,8 +40,10 @@ } ); // Events + this.highlightButton.connect( this, { click: 'onHighlightButtonClick' } ); this.model.connect( this, { - initialize: 'onModelInitialize' + initialize: 'onModelInitialize', + highlightChange: 'onHighlightChange' } ); // Initialize @@ -69,12 +79,26 @@ Object.keys( this.model.getFilterGroups() ).map( function ( groupName ) { return new mw.rcfilters.ui.FilterGroupWidget( widget.controller, - widget.model.getGroup( groupName ) + widget.model.getGroup( groupName ), + { + $overlay: widget.$overlay + } ); } ) ); }; + mw.rcfilters.ui.FiltersListWidget.prototype.onHighlightChange = function ( highlightEnabled ) { + this.highlightButton.setActive( highlightEnabled ); + }; + + /** + * Respond to highlight button click + */ + mw.rcfilters.ui.FiltersListWidget.prototype.onHighlightButtonClick = function () { + this.controller.toggleHighlight(); + }; + /** * Switch between showing the 'no results' message for filtering results or the result list. * diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js new file mode 100644 index 0000000000..570647e68a --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js @@ -0,0 +1,112 @@ +( function ( mw, $ ) { + /** + * 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 {mw.rcfilters.dm.FilterItem} model Filter item model + * @param {Object} [config] Configuration object + */ + mw.rcfilters.ui.HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, model, 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.model = model; + + this.currentSelection = ''; + 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' + } ); + this.selectColor( 'none' ); + + // Event + this.model.connect( this, { update: 'onModelUpdate' } ); + 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 */ + + /** + * Respond to item model update event + */ + mw.rcfilters.ui.HighlightColorPickerWidget.prototype.onModelUpdate = function () { + this.selectColor( this.model.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.getItemFromData( this.currentSelection ), + selectedItem = this.buttonSelect.getItemFromData( 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.model.getName() ); + } else { + this.controller.setHighlightColor( this.model.getName(), color ); + } + this.emit( 'chooseColor', color ); + }; +}( mediaWiki, jQuery ) ); diff --git a/tests/phpunit/includes/user/UserTest.php b/tests/phpunit/includes/user/UserTest.php index 65b49ba2da..fea4a4480a 100644 --- a/tests/phpunit/includes/user/UserTest.php +++ b/tests/phpunit/includes/user/UserTest.php @@ -884,4 +884,53 @@ class UserTest extends MediaWikiTestCase { $noRateLimitUser->expects( $this->any() )->method( 'getRights' )->willReturn( [ 'noratelimit' ] ); $this->assertFalse( $noRateLimitUser->isPingLimitable() ); } + + public function provideExperienceLevel() { + return [ + [ 2, 2, 'newcomer' ], + [ 12, 3, 'newcomer' ], + [ 8, 5, 'newcomer' ], + [ 15, 10, 'learner' ], + [ 450, 20, 'learner' ], + [ 460, 33, 'learner' ], + [ 525, 28, 'learner' ], + [ 538, 33, 'experienced' ], + ]; + } + + /** + * @dataProvider provideExperienceLevel + */ + public function testExperienceLevel( $editCount, $memberSince, $expLevel ) { + $this->setMwGlobals( [ + 'wgLearnerEdits' => 10, + 'wgLearnerMemberSince' => 4, + 'wgExperiencedUserEdits' => 500, + 'wgExperiencedUserMemberSince' => 30, + ] ); + + $db = wfGetDB( DB_MASTER ); + + $data = new stdClass(); + $data->user_id = 1; + $data->user_name = 'name'; + $data->user_real_name = 'Real Name'; + $data->user_touched = 1; + $data->user_token = 'token'; + $data->user_email = 'a@a.a'; + $data->user_email_authenticated = null; + $data->user_email_token = 'token'; + $data->user_email_token_expires = null; + $data->user_editcount = $editCount; + $data->user_registration = $db->timestamp( time() - $memberSince * 86400 ); + $user = User::newFromRow( $data ); + + $this->assertEquals( $expLevel, $user->getExperienceLevel() ); + } + + public function testExperienceLevelAnon() { + $user = User::newFromName( '10.11.12.13', false ); + + $this->assertFalse( $user->getExperienceLevel() ); + } } diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js index a5b12c9b60..3a940d081e 100644 --- a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js @@ -1157,4 +1157,116 @@ 'Selecting a non-conflicting filter from a conflicting group removes the conflict' ); } ); + + QUnit.test( 'Filter highlights', function ( assert ) { + var definition = { + group1: { + title: 'Group 1', + type: 'string_options', + filters: [ + { name: 'filter1', class: 'class1' }, + { name: 'filter2', class: 'class2' }, + { name: 'filter3', class: 'class3' }, + { name: 'filter4', class: 'class4' }, + { name: 'filter5', class: 'class5' }, + { name: 'filter6' } + ] + } + }, + model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( definition ); + + assert.ok( + !model.isHighlightEnabled(), + 'Initially, highlight is disabled.' + ); + + model.toggleHighlight( true ); + assert.ok( + model.isHighlightEnabled(), + 'Highlight is enabled on toggle.' + ); + + model.setHighlightColor( 'filter1', 'color1' ); + model.setHighlightColor( 'filter2', 'color2' ); + + assert.deepEqual( + model.getHighlightedItems().map( function ( item ) { + return item.getName(); + } ), + [ + 'filter1', + 'filter2' + ], + 'Highlighted items are highlighted.' + ); + + assert.equal( + model.getItemByName( 'filter1' ).getHighlightColor(), + 'color1', + 'Item highlight color is set.' + ); + + model.setHighlightColor( 'filter1', 'color1changed' ); + assert.equal( + model.getItemByName( 'filter1' ).getHighlightColor(), + 'color1changed', + 'Item highlight color is changed on setHighlightColor.' + ); + + model.clearHighlightColor( 'filter1' ); + assert.deepEqual( + model.getHighlightedItems().map( function ( item ) { + return item.getName(); + } ), + [ + 'filter2' + ], + 'Clear highlight from an item results in the item no longer being highlighted.' + ); + + // Reset + model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( definition ); + + model.setHighlightColor( 'filter1', 'color1' ); + model.setHighlightColor( 'filter2', 'color2' ); + model.setHighlightColor( 'filter3', 'color3' ); + + assert.deepEqual( + model.getHighlightedItems().map( function ( item ) { + return item.getName(); + } ), + [ + 'filter1', + 'filter2', + 'filter3' + ], + 'Even if highlights are not enabled, the items remember their highlight state' + // NOTE: When actually displaying the highlights, the UI checks whether + // highlighting is generally active and then goes over the highlighted + // items. The item models, however, and the view model in general, still + // retains the knowledge about which filters have different colors, so we + // can seamlessly return to the colors the user previously chose if they + // reapply highlights. + ); + + // Reset + model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( definition ); + + model.setHighlightColor( 'filter1', 'color1' ); + model.setHighlightColor( 'filter6', 'color6' ); + + assert.deepEqual( + model.getHighlightedItems().map( function ( item ) { + return item.getName(); + } ), + [ + 'filter1' + ], + 'Items without a specified class identifier are not highlighted.' + ); + } ); }( mediaWiki, jQuery ) ); -- 2.20.1