this.baseFilterState = {};
this.uriProcessor = null;
this.initializing = false;
+
+ this.prevLoggedItems = [];
};
/* Initialization */
*/
mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList ) {
var parsedSavedQueries,
+ 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 ) {
+ items = [];
+ $.each( namespaceStructure, function ( namespaceID, label ) {
+ // Build and clean up the individual namespace items definition
+ items.push( {
+ name: namespaceID,
+ label: label || mw.msg( 'blanknamespace' ),
+ description: '',
+ identifiers: [
+ ( namespaceID < 0 || namespaceID % 2 === 0 ) ?
+ 'subject' : 'talk'
+ ],
+ cssClass: 'mw-changeslist-ns-' + namespaceID
+ } );
+ } );
+
+ views.namespaces = {
+ title: mw.msg( 'namespaces' ),
+ trigger: ':',
+ groups: [ {
+ // Group definition (single group)
+ name: 'namespace', // parameter name is singular
+ type: 'string_options',
+ title: mw.msg( 'namespaces' ),
+ labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+ separator: ';',
+ fullCoverage: true,
+ filters: items
+ } ]
+ };
+ }
+ if ( tagList ) {
+ views.tags = {
+ title: mw.msg( 'rcfilters-view-tags' ),
+ trigger: '#',
+ groups: [ {
+ // Group definition (single group)
+ name: 'tagfilter', // Parameter name
+ type: 'string_options',
+ title: 'rcfilters-view-tags', // Message key
+ labelPrefixKey: 'rcfilters-tag-prefix-tags',
+ separator: '|',
+ fullCoverage: false,
+ filters: tagList
+ } ]
+ };
+ }
+
+ // Add parameter range operations
+ views.range = {
+ groups: [
+ {
+ name: 'limit',
+ type: 'single_option',
+ title: '', // Because it's a hidden group, this title actually appears nowhere
+ hidden: true,
+ allowArbitrary: true,
+ 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, namespaceStructure, tagList );
+ this.filtersModel.initializeFilters( filterStructure, views );
this._buildBaseFilterState();
// so it gets processed
this.changesListModel.update(
$changesList.length ? $changesList : 'NO_RESULTS',
- $( 'fieldset.rcoptions' ).first()
+ $( 'fieldset.rcoptions' ).first(),
+ true // We're using existing DOM elements
);
}
this.filtersModel.toggleFilterSelected( filterName, false );
this.updateChangesList();
this.filtersModel.reassessFilterInteractions( filterItem );
+
+ // Log filter grouping
+ this.trackFilterGroupings( 'removefilter' );
}
if ( isHighlighted ) {
this._trackHighlight( 'clear', filterName );
};
+ /**
+ * Enable or disable live updates.
+ * @param {boolean} enable True to enable, false to disable
+ */
+ mw.rcfilters.Controller.prototype.toggleLiveUpdate = function ( enable ) {
+ if ( enable && !this.liveUpdateTimeout ) {
+ this._scheduleLiveUpdate();
+ } else if ( !enable && this.liveUpdateTimeout ) {
+ clearTimeout( this.liveUpdateTimeout );
+ this.liveUpdateTimeout = null;
+ }
+ };
+
+ /**
+ * Set a timeout for the next live update.
+ * @private
+ */
+ mw.rcfilters.Controller.prototype._scheduleLiveUpdate = function () {
+ this.liveUpdateTimeout = setTimeout( this._doLiveUpdate.bind( this ), 3000 );
+ };
+
+ /**
+ * Perform a live update.
+ * @private
+ */
+ mw.rcfilters.Controller.prototype._doLiveUpdate = function () {
+ var controller = this;
+ this.updateChangesList( {}, true )
+ .always( function () {
+ if ( controller.liveUpdateTimeout ) {
+ // Live update was not disabled in the meantime
+ controller._scheduleLiveUpdate();
+ }
+ } );
+ };
+
/**
* Save the current model state as a saved query
*
* @param {string} queryID Query id
*/
mw.rcfilters.Controller.prototype.removeSavedQuery = function ( queryID ) {
- var query = this.savedQueriesModel.getItemByID( queryID );
+ this.savedQueriesModel.removeQuery( queryID );
- this.savedQueriesModel.removeItems( [ query ] );
-
- // Check if this item was the default
- if ( this.savedQueriesModel.getDefault() === queryID ) {
- // Nulify the default
- this.savedQueriesModel.setDefault( null );
- }
this._saveSavedQueries();
};
this.filtersModel.reassessFilterInteractions();
this.updateChangesList();
+
+ // Log filter grouping
+ this.trackFilterGroupings( 'savedfilters' );
}
};
* Update the list of changes and notify the model
*
* @param {Object} [params] Extra parameters to add to the API call
+ * @param {boolean} [isLiveUpdate] Don't update the URL or invalidate the changes list
+ * @return {jQuery.Promise} Promise that is resolved when the update is complete
*/
- mw.rcfilters.Controller.prototype.updateChangesList = function ( params ) {
- this._updateURL( params );
- this.changesListModel.invalidate();
- this._fetchChangesList()
+ mw.rcfilters.Controller.prototype.updateChangesList = function ( params, isLiveUpdate ) {
+ if ( !isLiveUpdate ) {
+ this._updateURL( params );
+ this.changesListModel.invalidate();
+ }
+ return this._fetchChangesList()
.then(
// Success
function ( pieces ) {
this.uriProcessor.getVersion( currentUri.query ) !== 2 ||
this.uriProcessor.isNewState( currentUri.query, updatedUri.query )
) {
- if ( this.initializing ) {
- // Initially, when we just build the first page load
- // out of defaults, we want to replace the history
- mw.rcfilters.UriProcessor.static.replaceState( updatedUri );
- } else {
- mw.rcfilters.UriProcessor.static.pushState( updatedUri );
- }
+ mw.rcfilters.UriProcessor.static.replaceState( updatedUri );
}
};
);
};
+ /**
+ * Track filter grouping usage
+ *
+ * @param {string} action Action taken
+ */
+ mw.rcfilters.Controller.prototype.trackFilterGroupings = function ( action ) {
+ var controller = this,
+ rightNow = new Date().getTime(),
+ randomIdentifier = String( mw.user.sessionId() ) + String( rightNow ) + String( Math.random() ),
+ // Get all current filters
+ filters = this.filtersModel.getSelectedItems().map( function ( item ) {
+ return item.getName();
+ } );
+
+ action = action || 'filtermenu';
+
+ // Check if these filters were the ones we just logged previously
+ // (Don't log the same grouping twice, in case the user opens/closes)
+ // the menu without action, or with the same result
+ if (
+ // Only log if the two arrays are different in size
+ filters.length !== this.prevLoggedItems.length ||
+ // Or if any filters are not the same as the cached filters
+ filters.some( function ( filterName ) {
+ return controller.prevLoggedItems.indexOf( filterName ) === -1;
+ } ) ||
+ // Or if any cached filters are not the same as given filters
+ this.prevLoggedItems.some( function ( filterName ) {
+ return filters.indexOf( filterName ) === -1;
+ } )
+ ) {
+ filters.forEach( function ( filterName ) {
+ mw.track(
+ 'event.ChangesListFilterGrouping',
+ {
+ action: action,
+ groupIdentifier: randomIdentifier,
+ filter: filterName,
+ userId: mw.user.getId()
+ }
+ );
+ } );
+
+ // Cache the filter names
+ this.prevLoggedItems = filters;
+ }
+ };
}( mediaWiki, jQuery ) );