From 91b2ebe8343a40d6dd4b8a1a475a9b4fe1df0421 Mon Sep 17 00:00:00 2001 From: Moriel Schottlender Date: Fri, 23 Jun 2017 15:35:03 -0700 Subject: [PATCH] RCFilters: Add range group filters - limit, days and hours - Add 'hidden' groups that have base defaults but are not viewed in the filter drop-down. - Add a UI for days, hours and limit selections, based on their group models. - Clean up the fieldset form to remove redundant line breaks and empty objects. - Add 'hours' as a subset of days, where the UI can split itself by picking up values >=1 and <1 - Add the ability to allow 'arbitrary' information from the URL values, but also make sure there is a validation method and a possibility to re-sort the values that are added in. Bug: T162784 Bug: T162786 Change-Id: I8068a7cc411eef40ddb8af4eef1d4f1e5f2a2b82 --- languages/i18n/en.json | 6 + languages/i18n/qqq.json | 6 + resources/Resources.php | 13 ++ .../dm/mw.rcfilters.dm.FilterGroup.js | 97 ++++++++++++--- .../mw.rcfilters.Controller.js | 101 ++++++++++++++- .../mw.rcfilters.UriProcessor.js | 18 ++- .../mw.rcfilters.ui.DatePopupWidget.less | 5 + .../mw.rcfilters.ui.FilterWrapperWidget.less | 9 ++ ...w.rcfilters.ui.LiveUpdateButtonWidget.less | 2 + .../mw.rcfilters.ui.ValuePickerWidget.less | 7 ++ ...w.rcfilters.ui.ChangesLimitButtonWidget.js | 107 ++++++++++++++++ ...mw.rcfilters.ui.ChangesLimitPopupWidget.js | 47 +++++++ .../ui/mw.rcfilters.ui.DateButtonWidget.js | 115 ++++++++++++++++++ .../ui/mw.rcfilters.ui.DatePopupWidget.js | 61 ++++++++++ ...rcfilters.ui.FilterTagMultiselectWidget.js | 4 + .../ui/mw.rcfilters.ui.FilterWrapperWidget.js | 22 +++- .../ui/mw.rcfilters.ui.FormWrapperWidget.js | 10 ++ .../ui/mw.rcfilters.ui.ValuePickerWidget.js | 109 +++++++++++++++++ .../dm.FiltersViewModel.test.js | 33 +++-- 19 files changed, 741 insertions(+), 31 deletions(-) create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.DatePopupWidget.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ValuePickerWidget.less create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitButtonWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DateButtonWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 9447de6622..fdfe75a8f0 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1351,6 +1351,12 @@ "recentchanges-submit": "Show", "rcfilters-activefilters": "Active filters", "rcfilters-advancedfilters": "Advanced filters", + "rcfilters-limit-title": "Changes to show", + "rcfilters-limit-shownum": "Show last $1 changes", + "rcfilters-days-title": "Recent days", + "rcfilters-hours-title": "Recent hours", + "rcfilters-days-show-days": "$1 {{PLURAL:$1|day|days}}", + "rcfilters-days-show-hours": "$1 {{PLURAL:$1|hour|hours}}", "rcfilters-quickfilters": "Saved filters", "rcfilters-quickfilters-placeholder-title": "No links saved yet", "rcfilters-quickfilters-placeholder-description": "To save your filter settings and reuse them later, click the bookmark icon in the Active Filter area, below.", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 7c995f03de..a5d6d54145 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1540,6 +1540,12 @@ "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-limit-title": "Title for the options to change the number of results shown.", + "rcfilters-days-title": "Title for the options to change the number of days for the results shown.", + "rcfilters-hours-title": "Title for the options to change the number of hours for the results shown.", + "rcfilters-limit-shownum": "Title for the button that opens the operation to control how many results are shown. \n\nParameters: $1 - Number of results shown", + "rcfilters-days-show-days": "Title for the button that opens the operation to control the day range for the results. \n\nParameters: $1 - Number of days shown", + "rcfilters-days-show-hours": "Title for the button that opens the operation to control the hour range for the results. \n\nParameters: $1 - Number of hours shown", "rcfilters-advancedfilters": "Title for the buttons allowing the user to switch to the various advanced filters views.", "rcfilters-quickfilters": "Label for the button that opens the saved filter settings menu in [[Special:RecentChanges]]", "rcfilters-quickfilters-placeholder-title": "Title for the text shown in the quick filters menu on [[Special:RecentChanges]] if the user has not saved any quick filters.", diff --git a/resources/Resources.php b/resources/Resources.php index a8cf91dee7..967ae6cca4 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1781,6 +1781,11 @@ return [ 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ViewSwitchWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitButtonWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DateButtonWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js', @@ -1806,6 +1811,8 @@ return [ 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.MenuSelectWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ViewSwitchWidget.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ValuePickerWidget.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.DatePopupWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.HighlightColorPickerWidget.less', @@ -1825,6 +1832,12 @@ return [ 'messages' => [ 'rcfilters-activefilters', 'rcfilters-advancedfilters', + 'rcfilters-limit-title', + 'rcfilters-limit-shownum', + 'rcfilters-days-title', + 'rcfilters-hours-title', + 'rcfilters-days-show-days', + 'rcfilters-days-show-hours', 'rcfilters-quickfilters', 'rcfilters-quickfilters-placeholder-title', 'rcfilters-quickfilters-placeholder-description', diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js index 2307f301cf..b061064c29 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js @@ -13,6 +13,8 @@ * is a part of. * @cfg {string} [title] Group title * @cfg {boolean} [hidden] This group is hidden from the regular menu views + * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the + * group from the URL, even if it wasn't initially set up. * @cfg {string} [separator='|'] Value separator for 'string_options' groups * @cfg {boolean} [active] Group is active * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results @@ -38,9 +40,11 @@ this.view = config.view || 'default'; this.title = config.title || name; this.hidden = !!config.hidden; + this.allowArbitrary = !!config.allowArbitrary; this.separator = config.separator || '|'; this.labelPrefixKey = config.labelPrefixKey; + this.currSelected = null; this.active = !!config.active; this.fullCoverage = !!config.fullCoverage; @@ -75,7 +79,8 @@ * @param {string|Object} [groupDefault] Definition of the group default */ mw.rcfilters.dm.FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) { - var supersetMap = {}, + var defaultParam, + supersetMap = {}, model = this, items = []; @@ -159,11 +164,17 @@ } ) ).join( this.getSeparator() ); } else if ( this.getType() === 'single_option' ) { + defaultParam = groupDefault !== undefined ? + groupDefault : this.getItems()[ 0 ].getParamName(); + // For this group, the parameter is the group name, - // and a single item can be selected, or none at all - // The item also must be recognized or none is set as - // default - model.defaultParams[ this.getName() ] = this.getItemByParamName( groupDefault ) ? groupDefault : ''; + // and a single item can be selected: default or first item + this.defaultParams[ this.getName() ] = defaultParam; + + // Single option means there must be a single option + // selected, so we have to either select the default + // or select the first option + this.selectItemByParamName( defaultParam ); } }; @@ -175,20 +186,24 @@ */ mw.rcfilters.dm.FilterGroup.prototype.onFilterItemUpdate = function ( item ) { // Update state - var active = this.areAnySelected(), - itemName = item && item.getName(); - - if ( item.isSelected() && this.getType() === 'single_option' ) { - // Change the selection to only be the newly selected item - this.getItems().forEach( function ( filterItem ) { - if ( filterItem.getName() !== itemName ) { - filterItem.toggleSelected( false ); - } - } ); + var active = this.areAnySelected(); + + if ( + item.isSelected() && + this.getType() === 'single_option' && + this.currSelected && + this.currSelected !== item + ) { + this.currSelected.toggleSelected( false ); } - if ( this.active !== active ) { + if ( + this.active !== active || + this.currSelected !== item + ) { this.active = active; + this.currSelected = item; + this.emit( 'update' ); } }; @@ -211,6 +226,15 @@ return this.hidden; }; + /** + * Get group allow arbitrary state + * + * @return {boolean} Group allows an arbitrary value from the URL + */ + mw.rcfilters.dm.FilterGroup.prototype.isAllowArbitrary = function () { + return this.allowArbitrary; + }; + /** * Get group name * @@ -229,6 +253,15 @@ return this.defaultParams; }; + /** + * This is for a single_option and string_options group types + * it returns the value of the default + * + * @return {string} Value of the default + */ + mw.rcfilters.dm.FilterGroup.prototype.getDefaulParamValue = function () { + return this.defaultParams[ this.getName() ]; + }; /** * Get the messags defining the 'whats this' popup for this group * @@ -499,7 +532,8 @@ * @return {Object} Filter representation */ mw.rcfilters.dm.FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) { - var areAnySelected, paramValues, + var areAnySelected, paramValues, defaultValue, item, + oneWasSelected = false, model = this, paramToFilterMap = {}, result = {}; @@ -559,9 +593,10 @@ paramValues.indexOf( filterItem.getParamName() ) > -1; } ); } else if ( this.getType() === 'single_option' ) { - // There is parameter that fits a single filter, or none at all + // There is parameter that fits a single filter and if not, get the default this.getItems().forEach( function ( filterItem ) { result[ filterItem.getName() ] = filterItem.getParamName() === paramRepresentation; + oneWasSelected = oneWasSelected || filterItem.getParamName() === paramRepresentation; } ); } @@ -569,8 +604,23 @@ // If any filters are missing, they will get a falsey value this.getItems().forEach( function ( filterItem ) { result[ filterItem.getName() ] = !!result[ filterItem.getName() ]; + oneWasSelected = oneWasSelected || !!result[ filterItem.getName() ]; } ); + // Make sure that at least one option is selected in + // single_option groups, no matter what path was taken + // If none was selected by the given definition, then + // we need to select the one in the base state -- either + // the default given, or the first item + if ( + this.getType() === 'single_option' && + !oneWasSelected + ) { + defaultValue = this.getDefaultParams(); + item = this.getItemByParamName( defaultValue[ this.getName() ] ); + result[ item.getName() ] = true; + } + return result; }; @@ -586,6 +636,17 @@ } )[ 0 ]; }; + /** + * Select an item by its parameter name + * + * @param {string} paramName Filter parameter name + */ + mw.rcfilters.dm.FilterGroup.prototype.selectItemByParamName = function ( paramName ) { + this.getItems().forEach( function ( item ) { + item.toggleSelected( item.getParamName() === paramName ); + } ); + }; + /** * Get item by its parameter name * diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js index 58585668df..4d0b803740 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -32,7 +32,13 @@ views = {}, items = [], uri = new mw.Uri(), - $changesList = $( '.mw-changeslist' ).first().contents(); + $changesList = $( '.mw-changeslist' ).first().contents(), + createFilterDataFromNumber = function ( num, convertedNumForLabel ) { + return { + name: String( num ), + label: mw.language.convertNumber( convertedNumForLabel ) + }; + }; // Prepare views if ( namespaceStructure ) { @@ -83,6 +89,99 @@ }; } + // Add parameter range operations + views.range = { + groups: [ + { + name: 'limit', + type: 'single_option', + title: '', // Because it's a hidden group, this title actually appears nowhere + hidden: true, + allowArbitrary: true, + validate: $.isNumeric, + sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); }, + 'default': '50', + filters: [ 50, 100, 250, 500 ].map( function ( num ) { + return createFilterDataFromNumber( num, num ); + } ) + }, + { + name: 'days', + type: 'single_option', + title: '', // Because it's a hidden group, this title actually appears nowhere + hidden: true, + allowArbitrary: true, + validate: $.isNumeric, + sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); }, + 'default': '7', + filters: [ + // Hours (1, 2, 6, 12) + 0.04166, 0.0833, 0.25, 0.5, + // Days + 1, 3, 7, 14, 30 + ].map( function ( num ) { + return createFilterDataFromNumber( + num, + // Convert fractions of days to number of hours for the labels + num < 1 ? Math.round( num * 24 ) : num + ); + } ) + } + ] + }; + + // Before we do anything, we need to see if we require another item in the + // groups that have 'AllowArbitrary'. For the moment, those are only single_option + // groups; if we ever expand it, this might need further generalization: + $.each( views, function ( viewName, viewData ) { + viewData.groups.forEach( function ( groupData ) { + // This is only true for single_option and string_options + // We assume these are the only groups that will allow for + // arbitrary, since it doesn't make any sense for the other + // groups. + var uriValue = uri.query[ groupData.name ]; + + if ( + // If the group allows for arbitrary data + groupData.allowArbitrary && + // and it is single_option (or string_options, but we + // don't have cases of those yet, nor do we plan to) + groupData.type === 'single_option' && + // and if there is a valid value in the URI already + uri.query[ groupData.name ] !== undefined && + // and, if there is a validate method and it passes on + // the data + ( !groupData.validate || groupData.validate( uri.query[ groupData.name ] ) ) && + // but if that value isn't already in the definition + groupData.filters + .map( function ( filterData ) { + return filterData.name; + } ) + .indexOf( uri.query[ groupData.name ] ) === -1 + ) { + // Add the filter information + if ( groupData.name === 'days' ) { + // Specific fix for hours/days which go by the same param + groupData.filters.push( createFilterDataFromNumber( + uriValue, + // In this case we don't want to round because it can be arbitrary + // weird numbers but we want to round to 2 decimal digits + Number( uriValue ) < 1 ? + ( Number( uriValue ) * 24 ).toFixed( 2 ) : + Number( uriValue ) + ) ); + } else { + groupData.filters.push( createFilterDataFromNumber( uriValue, uriValue ) ); + } + + // If there's a sort function set up, re-sort the values + if ( groupData.sortFunc ) { + groupData.filters.sort( groupData.sortFunc ); + } + } + } ); + } ); + // Initialize the model this.filtersModel.initializeFilters( filterStructure, views ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js index b7852d04b7..b4ea8af24b 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js @@ -234,12 +234,22 @@ // wiki default. // Any subsequent change of the URL through the RCFilters // system will receive 'urlversion=2' - var base = this.getVersion( uriQuery ) === 2 ? - {} : - this.filtersModel.getDefaultParams(); + var hiddenParamDefaults = {}, + base = this.getVersion( uriQuery ) === 2 ? + {} : + this.filtersModel.getDefaultParams(); + + // Go over the model and get all hidden parameters' defaults + // These defaults should be applied regardless of the urlversion + // but be overridden by the URL params if they exist + $.each( this.filtersModel.getFilterGroups(), function ( groupName, groupModel ) { + if ( groupModel.isHidden() ) { + $.extend( true, hiddenParamDefaults, groupModel.getDefaultParams() ); + } + } ); return this.minimizeQuery( - $.extend( true, {}, base, uriQuery, { urlversion: '2' } ) + $.extend( true, {}, hiddenParamDefaults, base, uriQuery, { urlversion: '2' } ) ); }; diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.DatePopupWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.DatePopupWidget.less new file mode 100644 index 0000000000..41557792d8 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.DatePopupWidget.less @@ -0,0 +1,5 @@ +.mw-rcfilters-ui-datePopupWidget { + &-days { + margin-top: 1em; + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less index 5aa866de65..df4592c2c5 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less @@ -10,5 +10,14 @@ &-bottom { margin-top: 1em; + + .mw-rcfilters-ui-changesLimitButtonWidget, + .mw-rcfilters-ui-dateButtonWidget { + display: inline-block; + + &:not( :first-child ) { + margin-left: 0.5em; + } + } } } diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less index 63ea264844..e8f504a2b7 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less @@ -1,4 +1,6 @@ .mw-rcfilters-ui-liveUpdateButtonWidget { + margin-left: 1em; + &.oo-ui-toggleWidget-on { position: relative; overflow: hidden; diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ValuePickerWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ValuePickerWidget.less new file mode 100644 index 0000000000..38ad1ee601 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ValuePickerWidget.less @@ -0,0 +1,7 @@ +.mw-rcfilters-ui-valuePickerWidget { + &-title { + display: block; + font-weight: bold; + margin-bottom: 0.5em; + } +} diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitButtonWidget.js new file mode 100644 index 0000000000..61ee4a529b --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitButtonWidget.js @@ -0,0 +1,107 @@ +( function ( mw ) { + /** + * Widget defining the button controlling the popup for the number of results + * + * @extends OO.ui.Widget + * + * @constructor + * @param {mw.rcfilters.Controller} controller Controller + * @param {mw.rcfilters.dm.FiltersViewModel} model View model + * @param {Object} [config] Configuration object + * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups + */ + mw.rcfilters.ui.ChangesLimitButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.ChangesLimitButtonWidget.parent.call( this, config ); + + this.controller = controller; + this.model = model; + + this.$overlay = config.$overlay || this.$element; + + this.button = null; + this.limitGroupModel = null; + + this.model.connect( this, { + initialize: 'onModelInitialize' + } ); + + this.$element + .addClass( 'mw-rcfilters-ui-changesLimitButtonWidget' ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.ChangesLimitButtonWidget, OO.ui.Widget ); + + /** + * Respond to model initialize event + */ + mw.rcfilters.ui.ChangesLimitButtonWidget.prototype.onModelInitialize = function () { + var changesLimitPopupWidget, selectedItem, currentValue; + + this.limitGroupModel = this.model.getGroup( 'limit' ); + + // HACK: We need the model to be ready before we populate the button + // and the widget, because we require the filter items for the + // limit and their events. This addition is only done after the + // model is initialized. + // Note: This will be fixed soon! + if ( this.limitGroupModel ) { + changesLimitPopupWidget = new mw.rcfilters.ui.ChangesLimitPopupWidget( + this.limitGroupModel + ); + + selectedItem = this.limitGroupModel.getSelectedItems()[ 0 ]; + currentValue = ( selectedItem && selectedItem.getLabel() ) || + mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() ); + + this.button = new OO.ui.PopupButtonWidget( { + indicator: 'down', + label: mw.msg( 'rcfilters-limit-shownum', currentValue ), + $overlay: this.$overlay, + popup: { + width: 300, + padded: true, + anchor: false, + align: 'backwards', + $autoCloseIgnore: this.$overlay, + $content: changesLimitPopupWidget.$element + } + } ); + + // Events + this.limitGroupModel.connect( this, { update: 'onLimitGroupModelUpdate' } ); + changesLimitPopupWidget.connect( this, { limit: 'onPopupLimit' } ); + + this.$element.append( this.button.$element ); + } + }; + + /** + * Respond to popup limit change event + * + * @param {string} filterName Chosen filter name + */ + mw.rcfilters.ui.ChangesLimitButtonWidget.prototype.onPopupLimit = function ( filterName ) { + this.controller.toggleFilterSelect( filterName, true ); + }; + + /** + * Respond to limit choose event + * + * @param {string} filterName Filter name + */ + mw.rcfilters.ui.ChangesLimitButtonWidget.prototype.onLimitGroupModelUpdate = function () { + var item = this.limitGroupModel.getSelectedItems()[ 0 ], + label = item && item.getLabel(); + + // Update the label + if ( label ) { + this.button.setLabel( mw.msg( 'rcfilters-limit-shownum', label ) ); + } + }; + +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js new file mode 100644 index 0000000000..02101ab44b --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js @@ -0,0 +1,47 @@ +( function ( mw ) { + /** + * Widget defining the popup to choose number of results + * + * @extends OO.ui.Widget + * + * @constructor + * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'limit' + * @param {Object} [config] Configuration object + */ + mw.rcfilters.ui.ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( model, config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.ChangesLimitPopupWidget.parent.call( this, config ); + + this.model = model; + + this.valuePicker = new mw.rcfilters.ui.ValuePickerWidget( + this.model, + { + label: mw.msg( 'rcfilters-limit-title' ) + } + ); + + // Events + this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } ); + + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' ) + .append( this.valuePicker.$element ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.ChangesLimitPopupWidget, OO.ui.Widget ); + + /* Events */ + + /** + * @event limit + * @param {string} name Item name + * + * A limit item was chosen + */ +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DateButtonWidget.js new file mode 100644 index 0000000000..1569f38662 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DateButtonWidget.js @@ -0,0 +1,115 @@ +( function ( mw ) { + /** + * Widget defining the button controlling the popup for the date range for the results + * + * @extends OO.ui.Widget + * + * @constructor + * @param {mw.rcfilters.Controller} controller Controller + * @param {mw.rcfilters.dm.FiltersViewModel} model View model + * @param {Object} [config] Configuration object + * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups + */ + mw.rcfilters.ui.DateButtonWidget = function MwRcfiltersUiDateButtonWidget( controller, model, config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.ChangesLimitButtonWidget.parent.call( this, config ); + + this.controller = controller; + this.model = model; + + this.$overlay = config.$overlay || this.$element; + + this.button = null; + this.daysGroupModel = null; + + this.model.connect( this, { + initialize: 'onModelInitialize' + } ); + + this.$element + .addClass( 'mw-rcfilters-ui-dateButtonWidget' ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.DateButtonWidget, OO.ui.Widget ); + + /** + * Respond to model initialize event + */ + mw.rcfilters.ui.DateButtonWidget.prototype.onModelInitialize = function () { + var datePopupWidget; + + this.daysGroupModel = this.model.getGroup( 'days' ); + + // HACK: We need the model to be ready before we populate the button + // and the widget, because we require the filter items for the + // limit and their events. This addition is only done after the + // model is initialized. + // Note: This will be fixed soon! + if ( this.daysGroupModel ) { + datePopupWidget = new mw.rcfilters.ui.DatePopupWidget( + this.daysGroupModel + ); + + this.button = new OO.ui.PopupButtonWidget( { + indicator: 'down', + icon: 'calendar', + $overlay: this.$overlay, + popup: { + width: 300, + padded: true, + anchor: false, + align: 'backwards', + $autoCloseIgnore: this.$overlay, + $content: datePopupWidget.$element + } + } ); + this.updateButtonLabel(); + + // Events + this.daysGroupModel.connect( this, { update: 'onDaysGroupModelUpdate' } ); + datePopupWidget.connect( this, { days: 'onPopupDays' } ); + + this.$element.append( this.button.$element ); + } + }; + + /** + * Respond to popup limit change event + * + * @param {string} filterName Chosen filter name + */ + mw.rcfilters.ui.DateButtonWidget.prototype.onPopupDays = function ( filterName ) { + this.controller.toggleFilterSelect( filterName, true ); + }; + + /** + * Respond to limit choose event + * + * @param {string} filterName Filter name + */ + mw.rcfilters.ui.DateButtonWidget.prototype.onDaysGroupModelUpdate = function () { + this.updateButtonLabel(); + }; + + /** + * Update the button label + */ + mw.rcfilters.ui.DateButtonWidget.prototype.updateButtonLabel = function () { + var item = this.daysGroupModel.getSelectedItems()[ 0 ]; + + // Update the label + if ( item ) { + this.button.setLabel( + mw.msg( + Number( item.getParamName() ) < 1 ? + 'rcfilters-days-show-hours' : 'rcfilters-days-show-days', + item.getLabel() + ) + ); + } + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js new file mode 100644 index 0000000000..6971df520b --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js @@ -0,0 +1,61 @@ +( function ( mw ) { + /** + * Widget defining the popup to choose date for the results + * + * @extends OO.ui.Widget + * + * @constructor + * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days' + * @param {Object} [config] Configuration object + */ + mw.rcfilters.ui.DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.ChangesLimitPopupWidget.parent.call( this, config ); + + this.model = model; + + this.hoursValuePicker = new mw.rcfilters.ui.ValuePickerWidget( + this.model, + { + classes: [ 'mw-rcfilters-ui-datePopupWidget-hours' ], + label: mw.msg( 'rcfilters-hours-title' ), + itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) < 1; } + } + ); + this.daysValuePicker = new mw.rcfilters.ui.ValuePickerWidget( + this.model, + { + classes: [ 'mw-rcfilters-ui-datePopupWidget-days' ], + label: mw.msg( 'rcfilters-days-title' ), + itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) >= 1; } + } + ); + + // Events + this.hoursValuePicker.connect( this, { choose: [ 'emit', 'days' ] } ); + this.daysValuePicker.connect( this, { choose: [ 'emit', 'days' ] } ); + + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-datePopupWidget' ) + .append( + this.hoursValuePicker.$element, + this.daysValuePicker.$element + ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.DatePopupWidget, OO.ui.Widget ); + + /* Events */ + + /** + * @event days + * @param {string} name Item name + * + * A days item was chosen + */ +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js index 4bee31e701..0198347c7b 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js @@ -385,6 +385,10 @@ * @param {mw.rcfilters.dm.FilterItem} item Filter item model */ mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) { + if ( item.getGroupModel().isHidden() ) { + return; + } + if ( item.isSelected() || ( 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 a748063461..883527f6cd 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js @@ -38,6 +38,22 @@ this.controller ); + this.numChangesWidget = new mw.rcfilters.ui.ChangesLimitButtonWidget( + this.controller, + this.model, + { + $overlay: this.$overlay + } + ); + + this.dateWidget = new mw.rcfilters.ui.DateButtonWidget( + this.controller, + this.model, + { + $overlay: this.$overlay + } + ); + // Initialize this.$element .addClass( 'mw-rcfilters-ui-filterWrapperWidget' ); @@ -56,7 +72,11 @@ } $bottom = $( '
' ) - .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' ); + .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' ) + .append( + this.numChangesWidget.$element, + this.dateWidget.$element + ); if ( mw.config.get( 'wgStructuredChangeFiltersEnableLiveUpdate' ) ) { $bottom.append( this.liveUpdateButton.$element ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js index dbee65c776..6004e25dba 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js @@ -138,8 +138,18 @@ this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach(); } + // Hide limit and days + this.$element.find( '.rclinks' ).detach(); + if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) { + this.$element.find( '.mw-recentchanges-table' ).detach(); this.$element.find( 'hr' ).detach(); } + if ( !this.$element.find( '.rcshowhide' ).contents().length ) { + this.$element.find( '.rcshowhide' ).detach(); + // If we're hiding rcshowhide, the '
's are around it, + // there's no need for them either. + this.$element.find( 'br' ).detach(); + } }; }( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js new file mode 100644 index 0000000000..7045ab6b9c --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js @@ -0,0 +1,109 @@ +( function ( mw ) { + /** + * Widget defining the behavior used to choose from a set of values + * in a single_value group + * + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.LabelElement + * + * @constructor + * @param {mw.rcfilters.dm.FilterGroup} model Group model + * @param {Object} [config] Configuration object + * @cfg {Function} [itemFilter] A filter function for the items from the + * model. If not given, all items will be included. The function must + * handle item models and return a boolean whether the item is included + * or not. Example: function ( itemModel ) { return itemModel.isSelected(); } + */ + mw.rcfilters.ui.ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.ValuePickerWidget.parent.call( this, config ); + // Mixin constructors + OO.ui.mixin.LabelElement.call( this, config ); + + this.model = model; + this.itemFilter = config.itemFilter || function () { return true; }; + + // Build the selection from the item models + this.selectWidget = new OO.ui.ButtonSelectWidget(); + this.initializeSelectWidget(); + + // Events + this.model.connect( this, { update: 'onModelUpdate' } ); + this.selectWidget.connect( this, { choose: 'onSelectWidgetChoose' } ); + + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-valuePickerWidget' ) + .append( + this.$label + .addClass( 'mw-rcfilters-ui-valuePickerWidget-title' ), + this.selectWidget.$element + ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.ValuePickerWidget, OO.ui.Widget ); + OO.mixinClass( mw.rcfilters.ui.ValuePickerWidget, OO.ui.mixin.LabelElement ); + + /* Events */ + + /** + * @event choose + * @param {string} name Item name + * + * An item has been chosen + */ + + /* Methods */ + + /** + * Respond to model update event + */ + mw.rcfilters.ui.ValuePickerWidget.prototype.onModelUpdate = function () { + this.selectCurrentModelItem(); + }; + + /** + * Respond to select widget choose event + * + * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item + * @fires choose + */ + mw.rcfilters.ui.ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) { + this.emit( 'choose', chosenItem.getData() ); + }; + + /** + * Initialize the select widget + */ + mw.rcfilters.ui.ValuePickerWidget.prototype.initializeSelectWidget = function () { + var items = this.model.getItems() + .filter( this.itemFilter ) + .map( function ( filterItem ) { + return new OO.ui.ButtonOptionWidget( { + data: filterItem.getName(), + label: filterItem.getLabel() + } ); + } ); + + this.selectWidget.clearItems(); + this.selectWidget.addItems( items ); + + this.selectCurrentModelItem(); + }; + + /** + * Select the current item that corresponds with the model item + * that is currently selected + */ + mw.rcfilters.ui.ValuePickerWidget.prototype.selectCurrentModelItem = function () { + var selectedItem = this.model.getSelectedItems()[ 0 ]; + + if ( selectedItem ) { + this.selectWidget.selectItemByData( selectedItem.getName() ); + } + }; +}( mediaWiki ) ); 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 5212ee9ce6..edaef7953d 100644 --- a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js @@ -57,12 +57,20 @@ }, { name: 'group4', type: 'single_option', - default: 'option1', + default: 'option2', filters: [ { name: 'option1', label: 'group4option1-label', description: 'group4option1-desc' }, { name: 'option2', label: 'group4option2-label', description: 'group4option2-desc' }, { name: 'option3', label: 'group4option3-label', description: 'group4option3-desc' } ] + }, { + name: 'group5', + type: 'single_option', + filters: [ + { name: 'option1', label: 'group5option1-label', description: 'group5option1-desc' }, + { name: 'option2', label: 'group5option2-label', description: 'group5option2-desc' }, + { name: 'option3', label: 'group5option3-label', description: 'group5option3-desc' } + ] } ], viewsDefinition = { namespaces: { @@ -90,7 +98,8 @@ filter5: '1', filter6: '0', group3: 'filter8', - group4: 'option1', + group4: 'option2', + group5: 'option1', namespace: '' }, baseParamRepresentation = { @@ -101,7 +110,8 @@ filter5: '0', filter6: '0', group3: '', - group4: '', + group4: 'option2', + group5: 'option1', namespace: '' }, baseFilterRepresentation = { @@ -114,9 +124,14 @@ group3__filter7: false, group3__filter8: false, group3__filter9: false, + // The 'single_value' type of group can't have empty value; it's either + // the default given or the first item that will get the truthy value group4__option1: false, - group4__option2: false, + group4__option2: true, // Default group4__option3: false, + group5__option1: true, // No default set, first item is default value + group5__option2: false, + group5__option3: false, namespace__0: false, namespace__1: false, namespace__2: false, @@ -133,8 +148,11 @@ group3__filter8: { selected: false, conflicted: false, included: false }, group3__filter9: { selected: false, conflicted: false, included: false }, group4__option1: { selected: false, conflicted: false, included: false }, - group4__option2: { selected: false, conflicted: false, included: false }, + group4__option2: { selected: true, conflicted: false, included: false }, group4__option3: { selected: false, conflicted: false, included: false }, + group5__option1: { selected: true, conflicted: false, included: false }, + group5__option2: { selected: false, conflicted: false, included: false }, + group5__option3: { selected: false, conflicted: false, included: false }, namespace__0: { selected: false, conflicted: false, included: false }, namespace__1: { selected: false, conflicted: false, included: false }, namespace__2: { selected: false, conflicted: false, included: false }, @@ -557,7 +575,7 @@ assert.deepEqual( model.getFiltersFromParameters( {} ), baseFilterRepresentation, - 'Empty parameter query results in an object representing all filters set to false' + 'Empty parameter query results in an object representing all filters set to their base state' ); assert.deepEqual( @@ -705,7 +723,8 @@ assert.deepEqual( model.getSelectedState(), $.extend( {}, baseFilterRepresentation, { - group4__option1: true + group4__option1: true, + group4__option2: false } ), 'A \'single_option\' parameter reflects a single selected value.' ); -- 2.20.1