RCFilters: Add range group filters - limit, days and hours
authorMoriel Schottlender <moriel@gmail.com>
Fri, 23 Jun 2017 22:35:03 +0000 (15:35 -0700)
committerRoan Kattouw <roan.kattouw@gmail.com>
Wed, 19 Jul 2017 02:21:10 +0000 (19:21 -0700)
- 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

19 files changed:
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.DatePopupWidget.less [new file with mode: 0644]
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ValuePickerWidget.less [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DateButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js [new file with mode: 0644]
tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js

index 9447de6..fdfe75a 100644 (file)
        "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.",
index 7c995f0..a5d6d54 100644 (file)
        "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.",
index a8cf91d..967ae6c 100644 (file)
@@ -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',
index 2307f30..b061064 100644 (file)
@@ -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
                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 = [];
 
                                } )
                        ).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 );
                }
        };
 
         */
        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' );
                }
        };
                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
         *
                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
         *
         * @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 = {};
                                        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;
                        } );
                }
 
                // 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;
        };
 
                } )[ 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
         *
index 5858566..4d0b803 100644 (file)
                        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 ) {
                        };
                }
 
+               // 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 );
 
index b7852d0..b4ea8af 100644 (file)
                // 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 (file)
index 0000000..4155779
--- /dev/null
@@ -0,0 +1,5 @@
+.mw-rcfilters-ui-datePopupWidget {
+       &-days {
+               margin-top: 1em;
+       }
+}
index 5aa866d..df4592c 100644 (file)
 
        &-bottom {
                margin-top: 1em;
+
+               .mw-rcfilters-ui-changesLimitButtonWidget,
+               .mw-rcfilters-ui-dateButtonWidget {
+                       display: inline-block;
+
+                       &:not( :first-child ) {
+                               margin-left: 0.5em;
+                       }
+               }
        }
 }
index 63ea264..e8f504a 100644 (file)
@@ -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 (file)
index 0000000..38ad1ee
--- /dev/null
@@ -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 (file)
index 0000000..61ee4a5
--- /dev/null
@@ -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 (file)
index 0000000..02101ab
--- /dev/null
@@ -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 (file)
index 0000000..1569f38
--- /dev/null
@@ -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 (file)
index 0000000..6971df5
--- /dev/null
@@ -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 ) );
index 4bee31e..0198347 100644 (file)
         * @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() ||
                        (
index a748063..883527f 100644 (file)
                        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' );
                }
 
                $bottom = $( '<div>' )
-                       .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 );
index dbee65c..6004e25 100644 (file)
                        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 '<br>'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 (file)
index 0000000..7045ab6
--- /dev/null
@@ -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 ) );
index 5212ee9..edaef79 100644 (file)
                }, {
                        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 = {
                        filter5: '0',
                        filter6: '0',
                        group3: '',
-                       group4: '',
+                       group4: 'option2',
+                       group5: 'option1',
                        namespace: ''
                },
                baseFilterRepresentation = {
                        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,
                        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 },
                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(
                assert.deepEqual(
                        model.getSelectedState(),
                        $.extend( {}, baseFilterRepresentation, {
-                               group4__option1: true
+                               group4__option1: true,
+                               group4__option2: false
                        } ),
                        'A \'single_option\' parameter reflects a single selected value.'
                );