/* eslint no-underscore-dangle: "off" */
/**
* Controller for the filters in Recent Changes
+ * @class
*
+ * @constructor
* @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
* @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
* @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
this.filtersModel = filtersModel;
this.changesListModel = changesListModel;
this.savedQueriesModel = savedQueriesModel;
- this.requestCounter = 0;
+ this.requestCounter = {};
this.baseFilterState = {};
this.uriProcessor = null;
this.initializing = false;
* @param {Object} [tagList] Tag definition
*/
mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList ) {
- var parsedSavedQueries,
+ var parsedSavedQueries, limitDefault,
+ controller = this,
views = {},
items = [],
uri = new mw.Uri(),
- $changesList = $( '.mw-changeslist' ).first().contents(),
- createFilterDataFromNumber = function ( num, convertedNumForLabel ) {
- return {
- name: String( num ),
- label: mw.language.convertNumber( convertedNumForLabel )
- };
- };
+ $changesList = $( '.mw-changeslist' ).first().contents();
// Prepare views
if ( namespaceStructure ) {
};
}
+ // Convert the default from the old preference
+ // since the limit preference actually affects more
+ // than just the RecentChanges page
+ limitDefault = Number( mw.user.options.get( 'rcfilters-rclimit', mw.user.options.get( 'rclimit', '50' ) ) );
+
// Add parameter range operations
views.range = {
groups: [
allowArbitrary: true,
validate: $.isNumeric,
sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
- 'default': '50',
+ 'default': String( limitDefault ),
+ // Temporarily making this not sticky until we resolve the problem
+ // with the misleading preference. Note that if this is to be permanent
+ // we should remove all sticky behavior methods completely
+ // See T172156
+ // isSticky: true,
filters: [ 50, 100, 250, 500 ].map( function ( num ) {
- return createFilterDataFromNumber( num, num );
+ return controller._createFilterDataFromNumber( num, num );
} )
},
{
allowArbitrary: true,
validate: $.isNumeric,
sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
- 'default': '7',
+ numToLabelFunc: function ( i ) {
+ return Number( i ) < 1 ?
+ ( Number( i ) * 24 ).toFixed( 2 ) :
+ Number( i );
+ },
+ 'default': mw.user.options.get( 'rcdays', '30' ),
+ // Temporarily making this not sticky while limit is not sticky, see above
+ // isSticky: true,
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(
+ return controller._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
+ // Before we do anything, we need to see if we require additional items 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 ) );
+ var extraValues = [];
+ if ( groupData.allowArbitrary ) {
+ // If the value in the URI isn't in the group, add it
+ if ( uri.query[ groupData.name ] !== undefined ) {
+ extraValues.push( uri.query[ groupData.name ] );
}
-
- // If there's a sort function set up, re-sort the values
- if ( groupData.sortFunc ) {
- groupData.filters.sort( groupData.sortFunc );
+ // If the default value isn't in the group, add it
+ if ( groupData.default !== undefined ) {
+ extraValues.push( String( groupData.default ) );
}
+ controller.addNumberValuesToGroup( groupData, extraValues );
}
} );
} );
// can normalize them per each query item
this.savedQueriesModel.initialize(
parsedSavedQueries,
- this._getBaseFilterState()
+ this._getBaseFilterState(),
+ // This is for backwards compatibility - delete all sticky filter states
+ Object.keys( this.filtersModel.getStickyFiltersState() )
);
// Check whether we need to load defaults.
this.initializing = false;
this.switchView( 'default' );
+
+ this._scheduleLiveUpdate();
+ };
+
+ /**
+ * Create filter data from a number, for the filters that are numerical value
+ *
+ * @param {Number} num Number
+ * @param {Number} numForDisplay Number for the label
+ * @return {Object} Filter data
+ */
+ mw.rcfilters.Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
+ return {
+ name: String( num ),
+ label: mw.language.convertNumber( numForDisplay )
+ };
+ };
+
+ /**
+ * Add an arbitrary values to groups that allow arbitrary values
+ *
+ * @param {Object} groupData Group data
+ * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
+ */
+ mw.rcfilters.Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
+ var controller = this;
+
+ arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
+
+ // This is only true for single_option group
+ // We assume these are the only groups that will allow for
+ // arbitrary, since it doesn't make any sense for the other
+ // groups.
+ arbitraryValues.forEach( function ( val ) {
+ 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 validate method and it passes on
+ // the data
+ ( !groupData.validate || groupData.validate( val ) ) &&
+ // but if that value isn't already in the definition
+ groupData.filters
+ .map( function ( filterData ) {
+ return filterData.name;
+ } )
+ .indexOf( val ) === -1
+ ) {
+ // Add the filter information
+ groupData.filters.push( controller._createFilterDataFromNumber(
+ val,
+ groupData.numToLabelFunc ?
+ groupData.numToLabelFunc( val ) :
+ val
+ ) );
+
+ // If there's a sort function set up, re-sort the values
+ if ( groupData.sortFunc ) {
+ groupData.filters.sort( groupData.sortFunc );
+ }
+ }
+ } );
};
/**
*/
mw.rcfilters.Controller.prototype.resetToDefaults = function () {
this.uriProcessor.updateModelBasedOnQuery( this._getDefaultParams() );
+
this.updateChangesList();
};
* @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;
+ this.changesListModel.toggleLiveUpdate( enable );
+ if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
+ this.showNewChanges();
}
};
* @private
*/
mw.rcfilters.Controller.prototype._scheduleLiveUpdate = function () {
- this.liveUpdateTimeout = setTimeout( this._doLiveUpdate.bind( this ), 3000 );
+ setTimeout( this._doLiveUpdate.bind( this ), 3000 );
};
/**
* @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();
+ if ( !this._shouldCheckForNewChanges() ) {
+ // skip this turn and check back later
+ this._scheduleLiveUpdate();
+ return;
+ }
+
+ this._checkForNewChanges()
+ .then( function ( data ) {
+ if ( !this._shouldCheckForNewChanges() ) {
+ // by the time the response is received,
+ // it may not be appropriate anymore
+ return;
}
- } );
+
+ if ( data.changes !== 'NO_RESULTS' ) {
+ if ( this.changesListModel.getLiveUpdate() ) {
+ return this.updateChangesList( false, null, true, false );
+ } else {
+ this.changesListModel.setNewChangesExist( true );
+ }
+ }
+ }.bind( this ) )
+ .always( this._scheduleLiveUpdate.bind( this ) );
+ };
+
+ /**
+ * @return {boolean} It's appropriate to check for new changes now
+ * @private
+ */
+ mw.rcfilters.Controller.prototype._shouldCheckForNewChanges = function () {
+ var liveUpdateFeatureFlag = mw.config.get( 'wgStructuredChangeFiltersEnableLiveUpdate' ) ||
+ new mw.Uri().query.liveupdate;
+
+ return !document.hidden &&
+ !this.changesListModel.getNewChangesExist() &&
+ !this.updatingChangesList &&
+ liveUpdateFeatureFlag;
+ };
+
+ /**
+ * Check if new changes, newer than those currently shown, are available
+ *
+ * @return {jQuery.Promise} Promise object that resolves after trying
+ * to fetch 1 change newer than the last known 'from' parameter value
+ *
+ * @private
+ */
+ mw.rcfilters.Controller.prototype._checkForNewChanges = function () {
+ return this._fetchChangesList(
+ 'liveUpdate',
+ {
+ limit: 1,
+ from: this.changesListModel.getNextFrom()
+ }
+ );
+ };
+
+ /**
+ * Show the new changes
+ *
+ * @return {jQuery.Promise} Promise object that resolves after
+ * fetching and showing the new changes
+ */
+ mw.rcfilters.Controller.prototype.showNewChanges = function () {
+ return this.updateChangesList( false, null, true, true );
};
/**
* Save the current model state as a saved query
*
* @param {string} [label] Label of the saved query
+ * @param {boolean} [setAsDefault=false] This query should be set as the default
*/
- mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label ) {
- var highlightedItems = {},
- highlightEnabled = this.filtersModel.isHighlightEnabled();
+ mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
+ var queryID,
+ highlightedItems = {},
+ highlightEnabled = this.filtersModel.isHighlightEnabled(),
+ selectedState = this.filtersModel.getSelectedState();
// Prepare highlights
this.filtersModel.getHighlightedItems().forEach( function ( item ) {
// These are filter states; highlight is stored as boolean
highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
+ // Delete all sticky filters
+ this._deleteStickyValuesFromFilterState( selectedState );
+
// Add item
- this.savedQueriesModel.addNewQuery(
+ queryID = this.savedQueriesModel.addNewQuery(
label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
{
- filters: this.filtersModel.getSelectedState(),
+ filters: selectedState,
highlights: highlightedItems,
invert: this.filtersModel.areNamespacesInverted()
}
);
+ if ( setAsDefault ) {
+ this.savedQueriesModel.setDefault( queryID );
+ }
+
// Save item
this._saveSavedQueries();
};
highlights.highlight = highlights.highlights || highlights.highlight;
// Update model state from filters
- this.filtersModel.toggleFiltersSelected( data.filters );
+ this.filtersModel.toggleFiltersSelected(
+ // Merge filters with sticky values
+ $.extend( true, {}, data.filters, this.filtersModel.getStickyFiltersState() )
+ );
// Update namespace inverted property
this.filtersModel.toggleInvertedNamespaces( !!Number( data.invert ) );
* @return {boolean} Query exists
*/
mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
- var highlightedItems = {};
+ var highlightedItems = {},
+ selectedState = this.filtersModel.getSelectedState();
// Prepare highlights of the current query
this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
} );
highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
+ // Remove sticky filters
+ this._deleteStickyValuesFromFilterState( selectedState );
+
return this.savedQueriesModel.findMatchingQuery(
{
- filters: this.filtersModel.getSelectedState(),
+ filters: selectedState,
highlights: highlightedItems,
invert: this.filtersModel.areNamespacesInverted()
}
);
};
+ /**
+ * Delete sticky filters from given object
+ *
+ * @param {Object} filterState Filter state
+ */
+ mw.rcfilters.Controller.prototype._deleteStickyValuesFromFilterState = function ( filterState ) {
+ // Remove sticky filters
+ $.each( this.filtersModel.getStickyFiltersState(), function ( filterName ) {
+ delete filterState[ filterName ];
+ } );
+ };
+
/**
* Get an object representing the base state of parameters
* and highlights.
mw.user.options.set( 'rcfilters-saved-queries', stringified );
};
+ /**
+ * Update sticky preferences with current model state
+ */
+ mw.rcfilters.Controller.prototype.updateStickyPreferences = function () {
+ // Update default sticky values with selected, whether they came from
+ // the initial defaults or from the URL value that is being normalized
+ this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).getSelectedItems()[ 0 ].getParamName() );
+ this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).getSelectedItems()[ 0 ].getParamName() );
+ };
+
+ /**
+ * Update the limit default value
+ *
+ * param {number} newValue New value
+ */
+ mw.rcfilters.Controller.prototype.updateLimitDefault = function ( /* newValue */ ) {
+ // HACK: Temporarily remove this from being sticky
+ // See T172156
+
+ /*
+ if ( !$.isNumeric( newValue ) ) {
+ return;
+ }
+
+ newValue = Number( newValue );
+
+ if ( mw.user.options.get( 'rcfilters-rclimit' ) !== newValue ) {
+ // Save the preference
+ new mw.Api().saveOption( 'rcfilters-rclimit', newValue );
+ // Update the preference for this session
+ mw.user.options.set( 'rcfilters-rclimit', newValue );
+ }
+ */
+ return;
+ };
+
+ /**
+ * Update the days default value
+ *
+ * param {number} newValue New value
+ */
+ mw.rcfilters.Controller.prototype.updateDaysDefault = function ( /* newValue */ ) {
+ // HACK: Temporarily remove this from being sticky
+ // See T172156
+
+ /*
+ if ( !$.isNumeric( newValue ) ) {
+ return;
+ }
+
+ newValue = Number( newValue );
+
+ if ( mw.user.options.get( 'rcdays' ) !== newValue ) {
+ // Save the preference
+ new mw.Api().saveOption( 'rcdays', newValue );
+ // Update the preference for this session
+ mw.user.options.set( 'rcdays', newValue );
+ }
+ */
+ return;
+ };
+
/**
* Synchronize the URL with the current state of the filters
* without adding an history entry.
this.uriProcessor.updateModelBasedOnQuery( new mw.Uri().query );
+ // Update the sticky preferences, in case we received a value
+ // from the URL
+ this.updateStickyPreferences();
+
// Only update and fetch new results if it is requested
if ( fetchChangesList ) {
this.updateChangesList();
/**
* Update the list of changes and notify the model
*
+ * @param {boolean} [updateUrl=true] Whether the URL should be updated with the current state of the filters
* @param {Object} [params] Extra parameters to add to the API call
- * @param {boolean} [isLiveUpdate] Don't update the URL or invalidate the changes list
+ * @param {boolean} [isLiveUpdate=false] The purpose of this update is to show new results for the same filters
+ * @param {boolean} [invalidateCurrentChanges=true] Invalidate current changes by default (show spinner)
* @return {jQuery.Promise} Promise that is resolved when the update is complete
*/
- mw.rcfilters.Controller.prototype.updateChangesList = function ( params, isLiveUpdate ) {
- if ( !isLiveUpdate ) {
+ mw.rcfilters.Controller.prototype.updateChangesList = function ( updateUrl, params, isLiveUpdate, invalidateCurrentChanges ) {
+ updateUrl = updateUrl === undefined ? true : updateUrl;
+ invalidateCurrentChanges = invalidateCurrentChanges === undefined ? true : invalidateCurrentChanges;
+ if ( updateUrl ) {
this._updateURL( params );
+ }
+ if ( invalidateCurrentChanges ) {
this.changesListModel.invalidate();
}
+ this.changesListModel.setNewChangesExist( false );
+ this.updatingChangesList = true;
return this._fetchChangesList()
.then(
// Success
function ( pieces ) {
var $changesListContent = pieces.changes,
$fieldset = pieces.fieldset;
- this.changesListModel.update( $changesListContent, $fieldset );
+ this.changesListModel.update( $changesListContent, $fieldset, false, isLiveUpdate );
}.bind( this )
// Do nothing for failure
- );
- };
-
- /**
- * Get an object representing the default parameter state, whether
- * it is from the model defaults or from the saved queries.
- *
- * @return {Object} Default parameters
- */
- mw.rcfilters.Controller.prototype._getDefaultParams = function () {
- var data, queryHighlights,
- savedParams = {},
- savedHighlights = {},
- defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
-
- if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
- defaultSavedQueryItem ) {
-
- data = defaultSavedQueryItem.getData();
-
- queryHighlights = data.highlights || {};
- savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
-
- // Translate highlights to parameters
- savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
- $.each( queryHighlights, function ( filterName, color ) {
- if ( filterName !== 'highlights' ) {
- savedHighlights[ filterName + '_color' ] = color;
- }
- } );
-
- return $.extend( true, {}, savedParams, savedHighlights, { invert: data.invert } );
- }
-
- return $.extend(
- { highlight: '0' },
- this.filtersModel.getDefaultParams()
- );
+ )
+ .always( function () {
+ this.updatingChangesList = false;
+ }.bind( this ) );
};
/**
savedHighlights = {},
defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
- if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
- defaultSavedQueryItem ) {
-
+ if ( defaultSavedQueryItem ) {
data = defaultSavedQueryItem.getData();
queryHighlights = data.highlights || {};
- savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
+ savedParams = this.filtersModel.getParametersFromFilters(
+ // Merge filters with sticky values
+ $.extend( true, {}, data.filters, this.filtersModel.getStickyFiltersState() )
+ );
// Translate highlights to parameters
savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
/**
* Fetch the list of changes from the server for the current filters
*
+ * @param {string} [counterId='updateChangesList'] Id for this request. To allow concurrent requests
+ * not to invalidate each other.
+ * @param {Object} [params={}] Parameters to add to the query
+ *
* @return {jQuery.Promise} Promise object that will resolve with the changes list
* or with a string denoting no results.
*/
- mw.rcfilters.Controller.prototype._fetchChangesList = function () {
+ mw.rcfilters.Controller.prototype._fetchChangesList = function ( counterId, params ) {
var uri = this._getUpdatedUri(),
- requestId = ++this.requestCounter,
- latestRequest = function () {
- return requestId === this.requestCounter;
- }.bind( this );
+ stickyParams = this.filtersModel.getStickyParams(),
+ requestId,
+ latestRequest;
+
+ counterId = counterId || 'updateChangesList';
+ params = params || {};
+
+ uri.extend( params );
+
+ this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
+ requestId = ++this.requestCounter[ counterId ];
+ latestRequest = function () {
+ return requestId === this.requestCounter[ counterId ];
+ }.bind( this );
+
+ // Sticky parameters override the URL params
+ // this is to make sure that whether we represent
+ // the sticky params in the URL or not (they may
+ // be normalized out) the sticky parameters are
+ // always being sent to the server with their
+ // current/default values
+ uri.extend( stickyParams );
return $.ajax( uri.toString(), { contentType: 'html' } )
.then(
* Track usage of highlight feature
*
* @param {string} action
- * @param {array|object|string} filters
+ * @param {Array|Object|string} filters
*/
mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) {
filters = typeof filters === 'string' ? { name: filters } : filters;