],
],
'mediawiki.rcfilters.filters.dm' => [
- 'scripts' => [
- 'resources/src/mediawiki.rcfilters/mw.rcfilters.js',
- 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js',
- 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js',
- 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js',
- 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js',
- 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js',
- 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js',
- 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js',
- 'resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js',
- 'resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js',
+ 'localBasePath' => "$IP/resources/src/mediawiki.rcfilters",
+ 'remoteBasePath' => "$wgResourceBasePath/resources/src/mediawiki.rcfilters",
+ 'packageFiles' => [
+ 'mw.rcfilters.js',
+ 'Controller.js',
+ 'UriProcessor.js',
+ 'dm/ChangesListViewModel.js',
+ 'dm/FilterGroup.js',
+ 'dm/FilterItem.js',
+ 'dm/FiltersViewModel.js',
+ 'dm/ItemModel.js',
+ 'dm/SavedQueriesModel.js',
+ 'dm/SavedQueryItemModel.js',
],
'dependencies' => [
'mediawiki.String',
],
],
'mediawiki.rcfilters.filters.ui' => [
- 'scripts' => [
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.GroupWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuOptionWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuSectionOptionWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagItemWidget.js',
- '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.MainWrapperWidget.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.ChangesLimitAndDateButtonWidget.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',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightPopupWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js',
- 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js',
- 'resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js',
- 'resources/src/mediawiki.rcfilters/mw.rcfilters.init.js',
+ 'localBasePath' => "$IP/resources/src/mediawiki.rcfilters",
+ 'remoteBasePath' => "$wgResourceBasePath/resources/src/mediawiki.rcfilters",
+ 'packageFiles' => [
+ 'mw.rcfilters.init.js',
+ 'HighlightColors.js',
+ 'ui/GroupWidget.js',
+ 'ui/CheckboxInputWidget.js',
+ 'ui/FilterTagMultiselectWidget.js',
+ 'ui/ItemMenuOptionWidget.js',
+ 'ui/FilterMenuOptionWidget.js',
+ 'ui/FilterMenuSectionOptionWidget.js',
+ 'ui/TagItemWidget.js',
+ 'ui/FilterTagItemWidget.js',
+ 'ui/FilterMenuHeaderWidget.js',
+ 'ui/MenuSelectWidget.js',
+ 'ui/MainWrapperWidget.js',
+ 'ui/ViewSwitchWidget.js',
+ 'ui/ValuePickerWidget.js',
+ 'ui/ChangesLimitPopupWidget.js',
+ 'ui/ChangesLimitAndDateButtonWidget.js',
+ 'ui/DatePopupWidget.js',
+ 'ui/FilterWrapperWidget.js',
+ 'ui/ChangesListWrapperWidget.js',
+ 'ui/SavedLinksListWidget.js',
+ 'ui/SavedLinksListItemWidget.js',
+ 'ui/SaveFiltersPopupButtonWidget.js',
+ 'ui/FormWrapperWidget.js',
+ 'ui/FilterItemHighlightButton.js',
+ 'ui/HighlightPopupWidget.js',
+ 'ui/HighlightColorPickerWidget.js',
+ 'ui/LiveUpdateButtonWidget.js',
+ 'ui/MarkSeenButtonWidget.js',
+ 'ui/RcTopSectionWidget.js',
+ 'ui/RclTopSectionWidget.js',
+ 'ui/RclTargetPageWidget.js',
+ 'ui/RclToOrFromWidget.js',
+ 'ui/WatchlistTopSectionWidget.js',
],
'styles' => [
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.mixins.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.Overlay.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.TagItemWidget.less',
- '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.ChangesLimitPopupWidget.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',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemHighlightButton.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListItemWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RcTopSectionWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclTargetPageWidget.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.WatchlistTopSectionWidget.less',
+ 'styles/mw.rcfilters.mixins.less',
+ 'styles/mw.rcfilters.variables.less',
+ 'styles/mw.rcfilters.ui.less',
+ 'styles/mw.rcfilters.ui.Overlay.less',
+ 'styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less',
+ 'styles/mw.rcfilters.ui.ItemMenuOptionWidget.less',
+ 'styles/mw.rcfilters.ui.FilterMenuOptionWidget.less',
+ 'styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less',
+ 'styles/mw.rcfilters.ui.TagItemWidget.less',
+ 'styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less',
+ 'styles/mw.rcfilters.ui.MenuSelectWidget.less',
+ 'styles/mw.rcfilters.ui.ViewSwitchWidget.less',
+ 'styles/mw.rcfilters.ui.ValuePickerWidget.less',
+ 'styles/mw.rcfilters.ui.ChangesLimitPopupWidget.less',
+ 'styles/mw.rcfilters.ui.DatePopupWidget.less',
+ 'styles/mw.rcfilters.ui.FilterWrapperWidget.less',
+ 'styles/mw.rcfilters.ui.ChangesListWrapperWidget.less',
+ 'styles/mw.rcfilters.ui.HighlightColorPickerWidget.less',
+ 'styles/mw.rcfilters.ui.FilterItemHighlightButton.less',
+ 'styles/mw.rcfilters.ui.SavedLinksListWidget.less',
+ 'styles/mw.rcfilters.ui.SavedLinksListItemWidget.less',
+ 'styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less',
+ 'styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less',
+ 'styles/mw.rcfilters.ui.RcTopSectionWidget.less',
+ 'styles/mw.rcfilters.ui.RclToOrFromWidget.less',
+ 'styles/mw.rcfilters.ui.RclTargetPageWidget.less',
+ 'styles/mw.rcfilters.ui.WatchlistTopSectionWidget.less',
],
'skinStyles' => [
'vector' => [
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.Overlay.vector.less',
+ 'styles/mw.rcfilters.ui.Overlay.vector.less',
],
'monobook' => [
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.Overlay.monobook.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.monobook.less',
- 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.monobook.less',
+ 'styles/mw.rcfilters.ui.Overlay.monobook.less',
+ 'styles/mw.rcfilters.ui.CapsuleItemWidget.monobook.less',
+ 'styles/mw.rcfilters.ui.FilterMenuOptionWidget.monobook.less',
],
],
'messages' => [
--- /dev/null
+( function () {
+
+ var byteLength = require( 'mediawiki.String' ).byteLength,
+ UriProcessor = require( './UriProcessor.js' ),
+ Controller;
+
+ /* eslint no-underscore-dangle: "off" */
+ /**
+ * Controller for the filters in Recent Changes
+ * @class mw.rcfilters.Controller
+ *
+ * @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
+ * @param {Object} config Additional configuration
+ * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
+ * @cfg {string} daysPreferenceName Preference name for the days filter
+ * @cfg {string} limitPreferenceName Preference name for the limit filter
+ * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
+ * the active filters area
+ * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
+ * title normalization to separate title subpage/parts into the target= url
+ * parameter
+ */
+ Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
+ this.filtersModel = filtersModel;
+ this.changesListModel = changesListModel;
+ this.savedQueriesModel = savedQueriesModel;
+ this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
+ this.daysPreferenceName = config.daysPreferenceName;
+ this.limitPreferenceName = config.limitPreferenceName;
+ this.collapsedPreferenceName = config.collapsedPreferenceName;
+ this.normalizeTarget = !!config.normalizeTarget;
+
+ this.requestCounter = {};
+ this.baseFilterState = {};
+ this.uriProcessor = null;
+ this.initialized = false;
+ this.wereSavedQueriesSaved = false;
+
+ this.prevLoggedItems = [];
+
+ this.FILTER_CHANGE = 'filterChange';
+ this.SHOW_NEW_CHANGES = 'showNewChanges';
+ this.LIVE_UPDATE = 'liveUpdate';
+ };
+
+ /* Initialization */
+ OO.initClass( Controller );
+
+ /**
+ * Initialize the filter and parameter states
+ *
+ * @param {Array} filterStructure Filter definition and structure for the model
+ * @param {Object} [namespaceStructure] Namespace definition
+ * @param {Object} [tagList] Tag definition
+ * @param {Object} [conditionalViews] Conditional view definition
+ */
+ Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
+ var parsedSavedQueries, pieces,
+ displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
+ defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
+ controller = this,
+ views = $.extend( true, {}, conditionalViews ),
+ items = [],
+ uri = new mw.Uri();
+
+ // Prepare views
+ if ( namespaceStructure ) {
+ items = [];
+ // eslint-disable-next-line jquery/no-each-util
+ $.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: [
+ mw.Title.isTalkNamespace( namespaceID ) ?
+ 'talk' : 'subject'
+ ],
+ 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
+ } ]
+ };
+ views.invert = {
+ groups: [
+ {
+ name: 'invertGroup',
+ type: 'boolean',
+ hidden: true,
+ filters: [ {
+ name: 'invert',
+ default: '0'
+ } ]
+ } ]
+ };
+ }
+ 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,
+ // FIXME: $.isNumeric is deprecated
+ validate: $.isNumeric,
+ range: {
+ min: 0, // The server normalizes negative numbers to 0 results
+ max: 1000
+ },
+ sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
+ default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
+ sticky: true,
+ filters: displayConfig.limitArray.map( function ( num ) {
+ return controller._createFilterDataFromNumber( num, num );
+ } )
+ },
+ {
+ name: 'days',
+ type: 'single_option',
+ title: '', // Because it's a hidden group, this title actually appears nowhere
+ hidden: true,
+ allowArbitrary: true,
+ // FIXME: $.isNumeric is deprecated
+ validate: $.isNumeric,
+ range: {
+ min: 0,
+ max: displayConfig.maxDays
+ },
+ sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
+ numToLabelFunc: function ( i ) {
+ return Number( i ) < 1 ?
+ ( Number( i ) * 24 ).toFixed( 2 ) :
+ Number( i );
+ },
+ default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
+ sticky: true,
+ filters: [
+ // Hours (1, 2, 6, 12)
+ 0.04166, 0.0833, 0.25, 0.5
+ // Days
+ ].concat( displayConfig.daysArray )
+ .map( function ( num ) {
+ return controller._createFilterDataFromNumber(
+ num,
+ // Convert fractions of days to number of hours for the labels
+ num < 1 ? Math.round( num * 24 ) : num
+ );
+ } )
+ }
+ ]
+ };
+
+ views.display = {
+ groups: [
+ {
+ name: 'display',
+ type: 'boolean',
+ title: '', // Because it's a hidden group, this title actually appears nowhere
+ hidden: true,
+ sticky: true,
+ filters: [
+ {
+ name: 'enhanced',
+ default: String( mw.user.options.get( 'usenewrc', 0 ) )
+ }
+ ]
+ }
+ ]
+ };
+
+ // 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:
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( views, function ( viewName, viewData ) {
+ viewData.groups.forEach( function ( groupData ) {
+ 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 the default value isn't in the group, add it
+ if ( groupData.default !== undefined ) {
+ extraValues.push( String( groupData.default ) );
+ }
+ controller.addNumberValuesToGroup( groupData, extraValues );
+ }
+ } );
+ } );
+
+ // Initialize the model
+ this.filtersModel.initializeFilters( filterStructure, views );
+
+ this.uriProcessor = new UriProcessor(
+ this.filtersModel,
+ { normalizeTarget: this.normalizeTarget }
+ );
+
+ if ( !mw.user.isAnon() ) {
+ try {
+ parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
+ } catch ( err ) {
+ parsedSavedQueries = {};
+ }
+
+ // Initialize saved queries
+ this.savedQueriesModel.initialize( parsedSavedQueries );
+ if ( this.savedQueriesModel.isConverted() ) {
+ // Since we know we converted, we're going to re-save
+ // the queries so they are now migrated to the new format
+ this._saveSavedQueries();
+ }
+ }
+
+ if ( defaultSavedQueryExists ) {
+ // This came from the server, meaning that we have a default
+ // saved query, but the server could not load it, probably because
+ // it was pre-conversion to the new format.
+ // We need to load this query again
+ this.applySavedQuery( this.savedQueriesModel.getDefault() );
+ } else {
+ // There are either recognized parameters in the URL
+ // or there are none, but there is also no default
+ // saved query (so defaults are from the backend)
+ // We want to update the state but not fetch results
+ // again
+ this.updateStateFromUrl( false );
+
+ pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
+
+ // Update the changes list with the existing data
+ // so it gets processed
+ this.changesListModel.update(
+ pieces.changes,
+ pieces.fieldset,
+ pieces.noResultsDetails,
+ true // We're using existing DOM elements
+ );
+ }
+
+ this.initialized = true;
+ this.switchView( 'default' );
+
+ this.pollingRate = mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' );
+ if ( this.pollingRate ) {
+ this._scheduleLiveUpdate();
+ }
+ };
+
+ /**
+ * Check if the controller has finished initializing.
+ * @return {boolean} Controller is initialized
+ */
+ Controller.prototype.isInitialized = function () {
+ return this.initialized;
+ };
+
+ /**
+ * Extracts information from the changes list DOM
+ *
+ * @param {jQuery} $root Root DOM to find children from
+ * @param {boolean} [statusCode] Server response status code
+ * @return {Object} Information about changes list
+ * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
+ * (either normally or as an error)
+ * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
+ * 'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
+ * @return {jQuery} return.fieldset Fieldset
+ */
+ Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
+ var info,
+ $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
+ areResults = !!$changesListContents.length,
+ checkForLogout = !areResults && statusCode === 200;
+
+ // We check if user logged out on different tab/browser or the session has expired.
+ // 205 status code returned from the server, which indicates that we need to reload the page
+ // is not usable on WL page, because we get redirected to login page, which gives 200 OK
+ // status code (if everything else goes well).
+ // Bug: T177717
+ if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
+ location.reload( false );
+ return;
+ }
+
+ info = {
+ changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
+ fieldset: $root.find( 'fieldset.cloptions' ).first()
+ };
+
+ if ( !areResults ) {
+ if ( $root.find( '.mw-changeslist-timeout' ).length ) {
+ info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
+ } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
+ info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
+ } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
+ info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
+ } else {
+ info.noResultsDetails = 'NO_RESULTS_NORMAL';
+ }
+ }
+
+ return info;
+ };
+
+ /**
+ * 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
+ */
+ 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
+ */
+ Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
+ var controller = this,
+ normalizeWithinRange = function ( range, val ) {
+ if ( val < range.min ) {
+ return range.min; // Min
+ } else if ( val >= range.max ) {
+ return range.max; // Max
+ }
+ return val;
+ };
+
+ arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
+
+ // Normalize the arbitrary values and the default value for a range
+ if ( groupData.range ) {
+ arbitraryValues = arbitraryValues.map( function ( val ) {
+ return normalizeWithinRange( groupData.range, val );
+ } );
+
+ // Normalize the default, since that's user defined
+ if ( groupData.default !== undefined ) {
+ groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
+ }
+ }
+
+ // 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 String( filterData.name );
+ } )
+ .indexOf( String( 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 );
+ }
+ }
+ } );
+ };
+
+ /**
+ * Reset to default filters
+ */
+ Controller.prototype.resetToDefaults = function () {
+ var params = this._getDefaultParams();
+ if ( this.applyParamChange( params ) ) {
+ // Only update the changes list if there was a change to actual filters
+ this.updateChangesList();
+ } else {
+ this.uriProcessor.updateURL( params );
+ }
+ };
+
+ /**
+ * Check whether the default values of the filters are all false.
+ *
+ * @return {boolean} Defaults are all false
+ */
+ Controller.prototype.areDefaultsEmpty = function () {
+ return $.isEmptyObject( this._getDefaultParams() );
+ };
+
+ /**
+ * Empty all selected filters
+ */
+ Controller.prototype.emptyFilters = function () {
+ var highlightedFilterNames = this.filtersModel.getHighlightedItems()
+ .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
+
+ if ( this.applyParamChange( {} ) ) {
+ // Only update the changes list if there was a change to actual filters
+ this.updateChangesList();
+ } else {
+ this.uriProcessor.updateURL();
+ }
+
+ if ( highlightedFilterNames ) {
+ this._trackHighlight( 'clearAll', highlightedFilterNames );
+ }
+ };
+
+ /**
+ * Update the selected state of a filter
+ *
+ * @param {string} filterName Filter name
+ * @param {boolean} [isSelected] Filter selected state
+ */
+ Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
+ var filterItem = this.filtersModel.getItemByName( filterName );
+
+ if ( !filterItem ) {
+ // If no filter was found, break
+ return;
+ }
+
+ isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
+
+ if ( filterItem.isSelected() !== isSelected ) {
+ this.filtersModel.toggleFilterSelected( filterName, isSelected );
+
+ this.updateChangesList();
+
+ // Check filter interactions
+ this.filtersModel.reassessFilterInteractions( filterItem );
+ }
+ };
+
+ /**
+ * Clear both highlight and selection of a filter
+ *
+ * @param {string} filterName Name of the filter item
+ */
+ Controller.prototype.clearFilter = function ( filterName ) {
+ var filterItem = this.filtersModel.getItemByName( filterName ),
+ isHighlighted = filterItem.isHighlighted(),
+ isSelected = filterItem.isSelected();
+
+ if ( isSelected || isHighlighted ) {
+ this.filtersModel.clearHighlightColor( filterName );
+ this.filtersModel.toggleFilterSelected( filterName, false );
+
+ if ( isSelected ) {
+ // Only update the changes list if the filter changed
+ // its selection state. If it only changed its highlight
+ // then don't reload
+ this.updateChangesList();
+ }
+
+ this.filtersModel.reassessFilterInteractions( filterItem );
+
+ // Log filter grouping
+ this.trackFilterGroupings( 'removefilter' );
+ }
+
+ if ( isHighlighted ) {
+ this._trackHighlight( 'clear', filterName );
+ }
+ };
+
+ /**
+ * Toggle the highlight feature on and off
+ */
+ Controller.prototype.toggleHighlight = function () {
+ this.filtersModel.toggleHighlight();
+ this.uriProcessor.updateURL();
+
+ if ( this.filtersModel.isHighlightEnabled() ) {
+ mw.hook( 'RcFilters.highlight.enable' ).fire();
+ }
+ };
+
+ /**
+ * Toggle the namespaces inverted feature on and off
+ */
+ Controller.prototype.toggleInvertedNamespaces = function () {
+ this.filtersModel.toggleInvertedNamespaces();
+ if (
+ this.filtersModel.getFiltersByView( 'namespaces' ).filter(
+ function ( filterItem ) { return filterItem.isSelected(); }
+ ).length
+ ) {
+ // Only re-fetch results if there are namespace items that are actually selected
+ this.updateChangesList();
+ } else {
+ this.uriProcessor.updateURL();
+ }
+ };
+
+ /**
+ * Set the value of the 'showlinkedto' parameter
+ * @param {boolean} value
+ */
+ Controller.prototype.setShowLinkedTo = function ( value ) {
+ var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
+ showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
+
+ this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
+ this.uriProcessor.updateURL();
+ // reload the results only when target is set
+ if ( targetItem.getValue() ) {
+ this.updateChangesList();
+ }
+ };
+
+ /**
+ * Set the target page
+ * @param {string} page
+ */
+ Controller.prototype.setTargetPage = function ( page ) {
+ var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
+ targetItem.setValue( page );
+ this.uriProcessor.updateURL();
+ this.updateChangesList();
+ };
+
+ /**
+ * Set the highlight color for a filter item
+ *
+ * @param {string} filterName Name of the filter item
+ * @param {string} color Selected color
+ */
+ Controller.prototype.setHighlightColor = function ( filterName, color ) {
+ this.filtersModel.setHighlightColor( filterName, color );
+ this.uriProcessor.updateURL();
+ this._trackHighlight( 'set', { name: filterName, color: color } );
+ };
+
+ /**
+ * Clear highlight for a filter item
+ *
+ * @param {string} filterName Name of the filter item
+ */
+ Controller.prototype.clearHighlightColor = function ( filterName ) {
+ this.filtersModel.clearHighlightColor( filterName );
+ this.uriProcessor.updateURL();
+ this._trackHighlight( 'clear', filterName );
+ };
+
+ /**
+ * Enable or disable live updates.
+ * @param {boolean} enable True to enable, false to disable
+ */
+ Controller.prototype.toggleLiveUpdate = function ( enable ) {
+ this.changesListModel.toggleLiveUpdate( enable );
+ if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
+ this.updateChangesList( null, this.LIVE_UPDATE );
+ }
+ };
+
+ /**
+ * Set a timeout for the next live update.
+ * @private
+ */
+ Controller.prototype._scheduleLiveUpdate = function () {
+ setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
+ };
+
+ /**
+ * Perform a live update.
+ * @private
+ */
+ Controller.prototype._doLiveUpdate = function () {
+ if ( !this._shouldCheckForNewChanges() ) {
+ // skip this turn and check back later
+ this._scheduleLiveUpdate();
+ return;
+ }
+
+ this._checkForNewChanges()
+ .then( function ( statusCode ) {
+ // no result is 204 with the 'peek' param
+ // logged out is 205
+ var newChanges = statusCode === 200;
+
+ if ( !this._shouldCheckForNewChanges() ) {
+ // by the time the response is received,
+ // it may not be appropriate anymore
+ return;
+ }
+
+ // 205 is the status code returned from server when user's logged in/out
+ // status is not matching while fetching live update changes.
+ // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
+ // Bug: T177717
+ if ( statusCode === 205 ) {
+ location.reload( false );
+ return;
+ }
+
+ if ( newChanges ) {
+ if ( this.changesListModel.getLiveUpdate() ) {
+ return this.updateChangesList( null, this.LIVE_UPDATE );
+ } else {
+ this.changesListModel.setNewChangesExist( true );
+ }
+ }
+ }.bind( this ) )
+ .always( this._scheduleLiveUpdate.bind( this ) );
+ };
+
+ /**
+ * @return {boolean} It's appropriate to check for new changes now
+ * @private
+ */
+ Controller.prototype._shouldCheckForNewChanges = function () {
+ return !document.hidden &&
+ !this.filtersModel.hasConflict() &&
+ !this.changesListModel.getNewChangesExist() &&
+ !this.updatingChangesList &&
+ this.changesListModel.getNextFrom();
+ };
+
+ /**
+ * Check if new changes, newer than those currently shown, are available
+ *
+ * @return {jQuery.Promise} Promise object that resolves with a bool
+ * specifying if there are new changes or not
+ *
+ * @private
+ */
+ Controller.prototype._checkForNewChanges = function () {
+ var params = {
+ limit: 1,
+ peek: 1, // bypasses ChangesList specific UI
+ from: this.changesListModel.getNextFrom(),
+ isAnon: mw.user.isAnon()
+ };
+ return this._queryChangesList( 'liveUpdate', params ).then(
+ function ( data ) {
+ return data.status;
+ }
+ );
+ };
+
+ /**
+ * Show the new changes
+ *
+ * @return {jQuery.Promise} Promise object that resolves after
+ * fetching and showing the new changes
+ */
+ Controller.prototype.showNewChanges = function () {
+ return this.updateChangesList( null, this.SHOW_NEW_CHANGES );
+ };
+
+ /**
+ * 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
+ */
+ Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
+ // Add item
+ this.savedQueriesModel.addNewQuery(
+ label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
+ this.filtersModel.getCurrentParameterState( true ),
+ setAsDefault
+ );
+
+ // Save item
+ this._saveSavedQueries();
+ };
+
+ /**
+ * Remove a saved query
+ *
+ * @param {string} queryID Query id
+ */
+ Controller.prototype.removeSavedQuery = function ( queryID ) {
+ this.savedQueriesModel.removeQuery( queryID );
+
+ this._saveSavedQueries();
+ };
+
+ /**
+ * Rename a saved query
+ *
+ * @param {string} queryID Query id
+ * @param {string} newLabel New label for the query
+ */
+ Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
+ var queryItem = this.savedQueriesModel.getItemByID( queryID );
+
+ if ( queryItem ) {
+ queryItem.updateLabel( newLabel );
+ }
+ this._saveSavedQueries();
+ };
+
+ /**
+ * Set a saved query as default
+ *
+ * @param {string} queryID Query Id. If null is given, default
+ * query is reset.
+ */
+ Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
+ this.savedQueriesModel.setDefault( queryID );
+ this._saveSavedQueries();
+ };
+
+ /**
+ * Load a saved query
+ *
+ * @param {string} queryID Query id
+ */
+ Controller.prototype.applySavedQuery = function ( queryID ) {
+ var currentMatchingQuery,
+ params = this.savedQueriesModel.getItemParams( queryID );
+
+ currentMatchingQuery = this.findQueryMatchingCurrentState();
+
+ if (
+ currentMatchingQuery &&
+ currentMatchingQuery.getID() === queryID
+ ) {
+ // If the query we want to load is the one that is already
+ // loaded, don't reload it
+ return;
+ }
+
+ if ( this.applyParamChange( params ) ) {
+ // Update changes list only if there was a difference in filter selection
+ this.updateChangesList();
+ } else {
+ this.uriProcessor.updateURL( params );
+ }
+
+ // Log filter grouping
+ this.trackFilterGroupings( 'savedfilters' );
+ };
+
+ /**
+ * Check whether the current filter and highlight state exists
+ * in the saved queries model.
+ *
+ * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
+ */
+ Controller.prototype.findQueryMatchingCurrentState = function () {
+ return this.savedQueriesModel.findMatchingQuery(
+ this.filtersModel.getCurrentParameterState( true )
+ );
+ };
+
+ /**
+ * Save the current state of the saved queries model with all
+ * query item representation in the user settings.
+ */
+ Controller.prototype._saveSavedQueries = function () {
+ var stringified, oldPrefValue,
+ backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
+ state = this.savedQueriesModel.getState();
+
+ // Stringify state
+ stringified = JSON.stringify( state );
+
+ if ( byteLength( stringified ) > 65535 ) {
+ // Sanity check, since the preference can only hold that.
+ return;
+ }
+
+ if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
+ // The queries were converted from the previous version
+ // Keep the old string in the [prefname]-versionbackup
+ oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
+
+ // Save the old preference in the backup preference
+ new mw.Api().saveOption( backupPrefName, oldPrefValue );
+ // Update the preference for this session
+ mw.user.options.set( backupPrefName, oldPrefValue );
+ }
+
+ // Save the preference
+ new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
+ // Update the preference for this session
+ mw.user.options.set( this.savedQueriesPreferenceName, stringified );
+
+ // Tag as already saved so we don't do this again
+ this.wereSavedQueriesSaved = true;
+ };
+
+ /**
+ * Update sticky preferences with current model state
+ */
+ 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' ).findSelectedItems()[ 0 ].getParamName() );
+ this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );
+
+ // TODO: Make these automatic by having the model go over sticky
+ // items and update their default values automatically
+ };
+
+ /**
+ * Update the limit default value
+ *
+ * @param {number} newValue New value
+ */
+ Controller.prototype.updateLimitDefault = function ( newValue ) {
+ this.updateNumericPreference( this.limitPreferenceName, newValue );
+ };
+
+ /**
+ * Update the days default value
+ *
+ * @param {number} newValue New value
+ */
+ Controller.prototype.updateDaysDefault = function ( newValue ) {
+ this.updateNumericPreference( this.daysPreferenceName, newValue );
+ };
+
+ /**
+ * Update the group by page default value
+ *
+ * @param {boolean} newValue New value
+ */
+ Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
+ this.updateNumericPreference( 'usenewrc', Number( newValue ) );
+ };
+
+ /**
+ * Update the collapsed state value
+ *
+ * @param {boolean} isCollapsed Filter area is collapsed
+ */
+ Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
+ this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
+ };
+
+ /**
+ * Update a numeric preference with a new value
+ *
+ * @param {string} prefName Preference name
+ * @param {number|string} newValue New value
+ */
+ Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
+ // FIXME: $.isNumeric is deprecated
+ // eslint-disable-next-line jquery/no-is-numeric
+ if ( !$.isNumeric( newValue ) ) {
+ return;
+ }
+
+ newValue = Number( newValue );
+
+ if ( mw.user.options.get( prefName ) !== newValue ) {
+ // Save the preference
+ new mw.Api().saveOption( prefName, newValue );
+ // Update the preference for this session
+ mw.user.options.set( prefName, newValue );
+ }
+ };
+
+ /**
+ * Synchronize the URL with the current state of the filters
+ * without adding an history entry.
+ */
+ Controller.prototype.replaceUrl = function () {
+ this.uriProcessor.updateURL();
+ };
+
+ /**
+ * Update filter state (selection and highlighting) based
+ * on current URL values.
+ *
+ * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
+ * list based on the updated model.
+ */
+ Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
+ fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
+
+ this.uriProcessor.updateModelBasedOnQuery();
+
+ // 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 {Object} [params] Extra parameters to add to the API call
+ * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
+ * @return {jQuery.Promise} Promise that is resolved when the update is complete
+ */
+ Controller.prototype.updateChangesList = function ( params, updateMode ) {
+ updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
+
+ if ( updateMode === this.FILTER_CHANGE ) {
+ this.uriProcessor.updateURL( params );
+ }
+ if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
+ 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,
+ pieces.noResultsDetails,
+ false,
+ // separator between old and new changes
+ updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
+ );
+ }.bind( this )
+ // Do nothing for failure
+ )
+ .always( function () {
+ this.updatingChangesList = false;
+ }.bind( this ) );
+ };
+
+ /**
+ * Get an object representing the default parameter state, whether
+ * it is from the model defaults or from the saved queries.
+ *
+ * @return {Object} Default parameters
+ */
+ Controller.prototype._getDefaultParams = function () {
+ if ( this.savedQueriesModel.getDefault() ) {
+ return this.savedQueriesModel.getDefaultParams();
+ } else {
+ return this.filtersModel.getDefaultParams();
+ }
+ };
+
+ /**
+ * Query the list of changes from the server for the current filters
+ *
+ * @param {string} counterId 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 resolved with { content, status }
+ */
+ Controller.prototype._queryChangesList = function ( counterId, params ) {
+ var uri = this.uriProcessor.getUpdatedUri(),
+ stickyParams = this.filtersModel.getStickyParamsValues(),
+ requestId,
+ latestRequest;
+
+ params = params || {};
+ params.action = 'render'; // bypasses MW chrome
+
+ 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(
+ function ( content, message, jqXHR ) {
+ if ( !latestRequest() ) {
+ return $.Deferred().reject();
+ }
+ return {
+ content: content,
+ status: jqXHR.status
+ };
+ },
+ // RC returns 404 when there is no results
+ function ( jqXHR ) {
+ if ( latestRequest() ) {
+ return $.Deferred().resolve(
+ {
+ content: jqXHR.responseText,
+ status: jqXHR.status
+ }
+ ).promise();
+ }
+ }
+ );
+ };
+
+ /**
+ * Fetch the list of changes from the server for the current filters
+ *
+ * @return {jQuery.Promise} Promise object that will resolve with the changes list
+ * and the fieldset.
+ */
+ Controller.prototype._fetchChangesList = function () {
+ return this._queryChangesList( 'updateChangesList' )
+ .then(
+ function ( data ) {
+ var $parsed;
+
+ // Status code 0 is not HTTP status code,
+ // but is valid value of XMLHttpRequest status.
+ // It is used for variety of network errors, for example
+ // when an AJAX call was cancelled before getting the response
+ if ( data && data.status === 0 ) {
+ return {
+ changes: 'NO_RESULTS',
+ // We need empty result set, to avoid exceptions because of undefined value
+ fieldset: $( [] ),
+ noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
+ };
+ }
+
+ $parsed = $( '<div>' ).append( $( $.parseHTML(
+ data ? data.content : ''
+ ) ) );
+
+ return this._extractChangesListInfo( $parsed, data.status );
+ }.bind( this )
+ );
+ };
+
+ /**
+ * Track usage of highlight feature
+ *
+ * @param {string} action
+ * @param {Array|Object|string} filters
+ */
+ Controller.prototype._trackHighlight = function ( action, filters ) {
+ filters = typeof filters === 'string' ? { name: filters } : filters;
+ filters = !Array.isArray( filters ) ? [ filters ] : filters;
+ mw.track(
+ 'event.ChangesListHighlights',
+ {
+ action: action,
+ filters: filters,
+ userId: mw.user.getId()
+ }
+ );
+ };
+
+ /**
+ * Track filter grouping usage
+ *
+ * @param {string} action Action taken
+ */
+ 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.findSelectedItems().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;
+ }
+ };
+
+ /**
+ * Apply a change of parameters to the model state, and check whether
+ * the new state is different than the old state.
+ *
+ * @param {Object} newParamState New parameter state to apply
+ * @return {boolean} New applied model state is different than the previous state
+ */
+ Controller.prototype.applyParamChange = function ( newParamState ) {
+ var after,
+ before = this.filtersModel.getSelectedState();
+
+ this.filtersModel.updateStateFromParams( newParamState );
+
+ after = this.filtersModel.getSelectedState();
+
+ return !OO.compare( before, after );
+ };
+
+ /**
+ * Mark all changes as seen on Watchlist
+ */
+ Controller.prototype.markAllChangesAsSeen = function () {
+ var api = new mw.Api();
+ api.postWithToken( 'csrf', {
+ formatversion: 2,
+ action: 'setnotificationtimestamp',
+ entirewatchlist: true
+ } ).then( function () {
+ this.updateChangesList( null, 'markSeen' );
+ }.bind( this ) );
+ };
+
+ /**
+ * Set the current search for the system.
+ *
+ * @param {string} searchQuery Search query, including triggers
+ */
+ Controller.prototype.setSearch = function ( searchQuery ) {
+ this.filtersModel.setSearch( searchQuery );
+ };
+
+ /**
+ * Switch the view by changing the search query trigger
+ * without changing the search term
+ *
+ * @param {string} view View to change to
+ */
+ Controller.prototype.switchView = function ( view ) {
+ this.setSearch(
+ this.filtersModel.getViewTrigger( view ) +
+ this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
+ );
+ };
+
+ /**
+ * Reset the search for a specific view. This means we null the search query
+ * and replace it with the relevant trigger for the requested view
+ *
+ * @param {string} [view='default'] View to change to
+ */
+ Controller.prototype.resetSearchForView = function ( view ) {
+ view = view || 'default';
+
+ this.setSearch(
+ this.filtersModel.getViewTrigger( view )
+ );
+ };
+
+ module.exports = Controller;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Supported highlight colors.
+ * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
+ *
+ * @member mw.rcfilters
+ * @property {string[]}
+ */
+ var HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];
+
+ module.exports = HighlightColors;
+}() );
--- /dev/null
+( function () {
+ /* eslint no-underscore-dangle: "off" */
+ /**
+ * URI Processor for RCFilters
+ *
+ * @class mw.rcfilters.UriProcessor
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
+ * @param {Object} [config] Configuration object
+ * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
+ * title normalization to separate title subpage/parts into the target= url
+ * parameter
+ */
+ var UriProcessor = function MwRcfiltersController( filtersModel, config ) {
+ config = config || {};
+ this.filtersModel = filtersModel;
+
+ this.normalizeTarget = !!config.normalizeTarget;
+ };
+
+ /* Initialization */
+ OO.initClass( UriProcessor );
+
+ /* Static methods */
+
+ /**
+ * Replace the url history through replaceState
+ *
+ * @param {mw.Uri} newUri New URI to replace
+ */
+ UriProcessor.static.replaceState = function ( newUri ) {
+ window.history.replaceState(
+ { tag: 'rcfilters' },
+ document.title,
+ newUri.toString()
+ );
+ };
+
+ /**
+ * Push the url to history through pushState
+ *
+ * @param {mw.Uri} newUri New URI to push
+ */
+ UriProcessor.static.pushState = function ( newUri ) {
+ window.history.pushState(
+ { tag: 'rcfilters' },
+ document.title,
+ newUri.toString()
+ );
+ };
+
+ /* Methods */
+
+ /**
+ * Get the version that this URL query is tagged with.
+ *
+ * @param {Object} [uriQuery] URI query
+ * @return {number} URL version
+ */
+ UriProcessor.prototype.getVersion = function ( uriQuery ) {
+ uriQuery = uriQuery || new mw.Uri().query;
+
+ return Number( uriQuery.urlversion || 1 );
+ };
+
+ /**
+ * Get an updated mw.Uri object based on the model state
+ *
+ * @param {mw.Uri} [uri] An external URI to build the new uri
+ * with. This is mainly for tests, to be able to supply external query
+ * parameters and make sure they are retained.
+ * @return {mw.Uri} Updated Uri
+ */
+ UriProcessor.prototype.getUpdatedUri = function ( uri ) {
+ var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
+ unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );
+
+ normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
+ $.extend(
+ true,
+ {},
+ normalizedUri.query,
+ // The representation must be expanded so it can
+ // override the uri query params but we then output
+ // a minimized version for the entire URI representation
+ // for the method
+ this.filtersModel.getExpandedParamRepresentation()
+ )
+ );
+
+ // Reapply unrecognized params and url version
+ normalizedUri.query = $.extend(
+ true,
+ {},
+ normalizedUri.query,
+ unrecognizedParams,
+ { urlversion: '2' }
+ );
+
+ return normalizedUri;
+ };
+
+ /**
+ * Move the subpage to the target parameter
+ *
+ * @param {mw.Uri} uri
+ * @return {mw.Uri}
+ * @private
+ */
+ UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
+ var parts,
+ // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
+ re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
+
+ if ( !this.normalizeTarget ) {
+ return uri;
+ }
+
+ // target in title param
+ if ( uri.query.title ) {
+ parts = uri.query.title.match( re );
+ if ( parts ) {
+ uri.query.title = parts[ 1 ];
+ uri.query.target = parts[ 2 ];
+ }
+ }
+
+ // target in path
+ parts = mw.Uri.decode( uri.path ).match( re );
+ if ( parts ) {
+ uri.path = parts[ 1 ];
+ uri.query.target = parts[ 2 ];
+ }
+
+ return uri;
+ };
+
+ /**
+ * Get an object representing given parameters that are unrecognized by the model
+ *
+ * @param {Object} params Full params object
+ * @return {Object} Unrecognized params
+ */
+ UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
+ // Start with full representation
+ var givenParamNames = Object.keys( params ),
+ unrecognizedParams = $.extend( true, {}, params );
+
+ // Extract unrecognized parameters
+ Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
+ // Remove recognized params
+ if ( givenParamNames.indexOf( paramName ) > -1 ) {
+ delete unrecognizedParams[ paramName ];
+ }
+ } );
+
+ return unrecognizedParams;
+ };
+
+ /**
+ * Update the URL of the page to reflect current filters
+ *
+ * This should not be called directly from outside the controller.
+ * If an action requires changing the URL, it should either use the
+ * highlighting actions below, or call #updateChangesList which does
+ * the uri corrections already.
+ *
+ * @param {Object} [params] Extra parameters to add to the API call
+ */
+ UriProcessor.prototype.updateURL = function ( params ) {
+ var currentUri = new mw.Uri(),
+ updatedUri = this.getUpdatedUri();
+
+ updatedUri.extend( params || {} );
+
+ if (
+ this.getVersion( currentUri.query ) !== 2 ||
+ this.isNewState( currentUri.query, updatedUri.query )
+ ) {
+ this.constructor.static.replaceState( updatedUri );
+ }
+ };
+
+ /**
+ * Update the filters model based on the URI query
+ * This happens on initialization, and from this moment on,
+ * we consider the system synchronized, and the model serves
+ * as the source of truth for the URL.
+ *
+ * This methods should only be called once on initialization.
+ * After initialization, the model updates the URL, not the
+ * other way around.
+ *
+ * @param {Object} [uriQuery] URI query
+ */
+ UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
+ uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
+ this.filtersModel.updateStateFromParams(
+ this._getNormalizedQueryParams( uriQuery )
+ );
+ };
+
+ /**
+ * Compare two URI queries to decide whether they are different
+ * enough to represent a new state.
+ *
+ * @param {Object} currentUriQuery Current Uri query
+ * @param {Object} updatedUriQuery Updated Uri query
+ * @return {boolean} This is a new state
+ */
+ UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
+ var currentParamState, updatedParamState,
+ notEquivalent = function ( obj1, obj2 ) {
+ var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
+ return keys.some( function ( key ) {
+ return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
+ } );
+ };
+
+ // Compare states instead of parameters
+ // This will allow us to always have a proper check of whether
+ // the requested new url is one to change or not, regardless of
+ // actual parameter visibility/representation in the URL
+ currentParamState = $.extend(
+ true,
+ {},
+ this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
+ this.getUnrecognizedParams( currentUriQuery )
+ );
+ updatedParamState = $.extend(
+ true,
+ {},
+ this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
+ this.getUnrecognizedParams( updatedUriQuery )
+ );
+
+ return notEquivalent( currentParamState, updatedParamState );
+ };
+
+ /**
+ * Check whether the given query has parameters that are
+ * recognized as parameters we should load the system with
+ *
+ * @param {mw.Uri} [uriQuery] Given URI query
+ * @return {boolean} Query contains valid recognized parameters
+ */
+ UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
+ var anyValidInUrl,
+ validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );
+
+ uriQuery = uriQuery || new mw.Uri().query;
+
+ anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
+ return validParameterNames.indexOf( parameter ) > -1;
+ } );
+
+ // URL version 2 is allowed to be empty or within nonrecognized params
+ return anyValidInUrl || this.getVersion( uriQuery ) === 2;
+ };
+
+ /**
+ * Get the adjusted URI params based on the url version
+ * If the urlversion is not 2, the parameters are merged with
+ * the model's defaults.
+ * Always merge in the hidden parameter defaults.
+ *
+ * @private
+ * @param {Object} uriQuery Current URI query
+ * @return {Object} Normalized parameters
+ */
+ UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
+ // Check whether we are dealing with urlversion=2
+ // If we are, we do not merge the initial request with
+ // defaults. Not having urlversion=2 means we need to
+ // reproduce the server-side request and merge the
+ // requested parameters (or starting state) with the
+ // 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();
+
+ return $.extend(
+ true,
+ {},
+ this.filtersModel.getMinimizedParamRepresentation(
+ $.extend( true, {}, base, uriQuery )
+ ),
+ { urlversion: '2' }
+ );
+ };
+
+ module.exports = UriProcessor;
+}() );
--- /dev/null
+( function () {
+ /**
+ * View model for the changes list
+ *
+ * @class mw.rcfilters.dm.ChangesListViewModel
+ * @mixins OO.EventEmitter
+ *
+ * @param {jQuery} $initialFieldset The initial server-generated legacy form content
+ * @constructor
+ */
+ var ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+
+ this.valid = true;
+ this.newChangesExist = false;
+ this.liveUpdate = false;
+ this.unseenWatchedChanges = false;
+
+ this.extractNextFrom( $initialFieldset );
+ };
+
+ /* Initialization */
+ OO.initClass( ChangesListViewModel );
+ OO.mixinClass( ChangesListViewModel, OO.EventEmitter );
+
+ /* Events */
+
+ /**
+ * @event invalidate
+ *
+ * The list of changes is now invalid (out of date)
+ */
+
+ /**
+ * @event update
+ * @param {jQuery|string} $changesListContent List of changes
+ * @param {jQuery} $fieldset Server-generated form
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
+ * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
+ *
+ * The list of changes has been updated
+ */
+
+ /**
+ * @event newChangesExist
+ * @param {boolean} newChangesExist
+ *
+ * The existence of changes newer than those currently displayed has changed.
+ */
+
+ /**
+ * @event liveUpdateChange
+ * @param {boolean} enable
+ *
+ * The state of the 'live update' feature has changed.
+ */
+
+ /* Methods */
+
+ /**
+ * Invalidate the list of changes
+ *
+ * @fires invalidate
+ */
+ ChangesListViewModel.prototype.invalidate = function () {
+ if ( this.valid ) {
+ this.valid = false;
+ this.emit( 'invalidate' );
+ }
+ };
+
+ /**
+ * Update the model with an updated list of changes
+ *
+ * @param {jQuery|string} changesListContent
+ * @param {jQuery} $fieldset
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
+ * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
+ * @fires update
+ */
+ ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
+ var from = this.nextFrom;
+ this.valid = true;
+ this.extractNextFrom( $fieldset );
+ this.checkForUnseenWatchedChanges( changesListContent );
+ this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
+ };
+
+ /**
+ * Specify whether new changes exist
+ *
+ * @param {boolean} newChangesExist
+ * @fires newChangesExist
+ */
+ ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
+ if ( newChangesExist !== this.newChangesExist ) {
+ this.newChangesExist = newChangesExist;
+ this.emit( 'newChangesExist', newChangesExist );
+ }
+ };
+
+ /**
+ * @return {boolean} Whether new changes exist
+ */
+ ChangesListViewModel.prototype.getNewChangesExist = function () {
+ return this.newChangesExist;
+ };
+
+ /**
+ * Extract the value of the 'from' parameter from a link in the field set
+ *
+ * @param {jQuery} $fieldset
+ */
+ ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
+ var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
+ if ( data && data.from ) {
+ this.nextFrom = data.from;
+ }
+ };
+
+ /**
+ * @return {string} The 'from' parameter that can be used to query new changes
+ */
+ ChangesListViewModel.prototype.getNextFrom = function () {
+ return this.nextFrom;
+ };
+
+ /**
+ * Toggle the 'live update' feature on/off
+ *
+ * @param {boolean} enable
+ */
+ ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
+ enable = enable === undefined ? !this.liveUpdate : enable;
+ if ( enable !== this.liveUpdate ) {
+ this.liveUpdate = enable;
+ this.emit( 'liveUpdateChange', this.liveUpdate );
+ }
+ };
+
+ /**
+ * @return {boolean} The 'live update' feature is enabled
+ */
+ ChangesListViewModel.prototype.getLiveUpdate = function () {
+ return this.liveUpdate;
+ };
+
+ /**
+ * Check if some of the given changes watched and unseen
+ *
+ * @param {jQuery|string} changeslistContent
+ */
+ ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
+ this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
+ changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
+ };
+
+ /**
+ * @return {boolean} Whether some of the current changes are watched and unseen
+ */
+ ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
+ return this.unseenWatchedChanges;
+ };
+
+ module.exports = ChangesListViewModel;
+}() );
--- /dev/null
+( function () {
+ var FilterItem = require( './FilterItem.js' ),
+ FilterGroup;
+
+ /**
+ * View model for a filter group
+ *
+ * @class mw.rcfilters.dm.FilterGroup
+ * @mixins OO.EventEmitter
+ * @mixins OO.EmitterList
+ *
+ * @constructor
+ * @param {string} name Group name
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [type='send_unselected_if_any'] Group type
+ * @cfg {string} [view='default'] Name of the display group this group
+ * is a part of.
+ * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
+ * with a preference, does not participate in Saved Queries, and is
+ * not shown in the active filters area.
+ * @cfg {string} [title] Group title
+ * @cfg {boolean} [hidden] This group is hidden from the regular menu views
+ * and the active filters area.
+ * @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 {number} [range] An object defining minimum and maximum values for numeric
+ * groups. { min: x, max: y }
+ * @cfg {number} [minValue] Minimum value for numeric groups
+ * @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
+ * @cfg {Object} [conflicts] Defines the conflicts for this filter group
+ * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+ * group. If the prefix has 'invert' state, the parameter is expected to be an object
+ * with 'default' and 'inverted' as keys.
+ * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
+ * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
+ * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
+ * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
+ * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
+ * @cfg {boolean} [visible=true] The visibility of the group
+ */
+ FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
+ config = config || {};
+
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+ OO.EmitterList.call( this );
+
+ this.name = name;
+ this.type = config.type || 'send_unselected_if_any';
+ this.view = config.view || 'default';
+ this.sticky = !!config.sticky;
+ this.title = config.title || name;
+ this.hidden = !!config.hidden;
+ this.allowArbitrary = !!config.allowArbitrary;
+ this.numericRange = config.range;
+ this.separator = config.separator || '|';
+ this.labelPrefixKey = config.labelPrefixKey;
+ this.visible = config.visible === undefined ? true : !!config.visible;
+
+ this.currSelected = null;
+ this.active = !!config.active;
+ this.fullCoverage = !!config.fullCoverage;
+
+ this.whatsThis = config.whatsThis || {};
+
+ this.conflicts = config.conflicts || {};
+ this.defaultParams = {};
+ this.defaultFilters = {};
+
+ this.aggregate( { update: 'filterItemUpdate' } );
+ this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
+ };
+
+ /* Initialization */
+ OO.initClass( FilterGroup );
+ OO.mixinClass( FilterGroup, OO.EventEmitter );
+ OO.mixinClass( FilterGroup, OO.EmitterList );
+
+ /* Events */
+
+ /**
+ * @event update
+ *
+ * Group state has been updated
+ */
+
+ /* Methods */
+
+ /**
+ * Initialize the group and create its filter items
+ *
+ * @param {Object} filterDefinition Filter definition for this group
+ * @param {string|Object} [groupDefault] Definition of the group default
+ */
+ FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
+ var defaultParam,
+ supersetMap = {},
+ model = this,
+ items = [];
+
+ filterDefinition.forEach( function ( filter ) {
+ // Instantiate an item
+ var subsetNames = [],
+ filterItem = new FilterItem( filter.name, model, {
+ group: model.getName(),
+ label: filter.label || filter.name,
+ description: filter.description || '',
+ labelPrefixKey: model.labelPrefixKey,
+ cssClass: filter.cssClass,
+ identifiers: filter.identifiers,
+ defaultHighlightColor: filter.defaultHighlightColor
+ } );
+
+ if ( filter.subset ) {
+ filter.subset = filter.subset.map( function ( el ) {
+ return el.filter;
+ } );
+
+ subsetNames = [];
+
+ filter.subset.forEach( function ( subsetFilterName ) {
+ // Subsets (unlike conflicts) are always inside the same group
+ // We can re-map the names of the filters we are getting from
+ // the subsets with the group prefix
+ var subsetName = model.getPrefixedName( subsetFilterName );
+ // For convenience, we should store each filter's "supersets" -- these are
+ // the filters that have that item in their subset list. This will just
+ // make it easier to go through whether the item has any other items
+ // that affect it (and are selected) at any given time
+ supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
+ mw.rcfilters.utils.addArrayElementsUnique(
+ supersetMap[ subsetName ],
+ filterItem.getName()
+ );
+
+ // Translate subset param name to add the group name, so we
+ // get consistent naming. We know that subsets are only within
+ // the same group
+ subsetNames.push( subsetName );
+ } );
+
+ // Set translated subset
+ filterItem.setSubset( subsetNames );
+ }
+
+ items.push( filterItem );
+
+ // Store default parameter state; in this case, default is defined per filter
+ if (
+ model.getType() === 'send_unselected_if_any' ||
+ model.getType() === 'boolean'
+ ) {
+ // Store the default parameter state
+ // For this group type, parameter values are direct
+ // We need to convert from a boolean to a string ('1' and '0')
+ model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
+ } else if ( model.getType() === 'any_value' ) {
+ model.defaultParams[ filter.name ] = filter.default;
+ }
+ } );
+
+ // Add items
+ this.addItems( items );
+
+ // Now that we have all items, we can apply the superset map
+ this.getItems().forEach( function ( filterItem ) {
+ filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
+ } );
+
+ // Store default parameter state; in this case, default is defined per the
+ // entire group, given by groupDefault method parameter
+ if ( this.getType() === 'string_options' ) {
+ // Store the default parameter group state
+ // For this group, the parameter is group name and value is the names
+ // of selected items
+ this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
+ // Current values
+ groupDefault ?
+ groupDefault.split( this.getSeparator() ) :
+ [],
+ // Legal values
+ this.getItems().map( function ( item ) {
+ return item.getParamName();
+ } )
+ ).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: default or first item
+ this.defaultParams[ this.getName() ] = defaultParam;
+ }
+
+ // add highlights to defaultParams
+ this.getItems().forEach( function ( filterItem ) {
+ if ( filterItem.isHighlighted() ) {
+ this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
+ }
+ }.bind( this ) );
+
+ // Store default filter state based on default params
+ this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
+
+ // Check for filters that should be initially selected by their default value
+ if ( this.isSticky() ) {
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.defaultFilters, function ( filterName, filterValue ) {
+ model.getItemByName( filterName ).toggleSelected( filterValue );
+ } );
+ }
+
+ // Verify that single_option group has at least one item selected
+ if (
+ this.getType() === 'single_option' &&
+ this.findSelectedItems().length === 0
+ ) {
+ defaultParam = groupDefault !== undefined ?
+ groupDefault : this.getItems()[ 0 ].getParamName();
+
+ // 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 );
+ }
+ };
+
+ /**
+ * Respond to filterItem update event
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
+ * @fires update
+ */
+ FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
+ // Update state
+ var changed = false,
+ active = this.areAnySelected(),
+ model = this;
+
+ if ( this.getType() === 'single_option' ) {
+ // This group must have one item selected always
+ // and must never have more than one item selected at a time
+ if ( this.findSelectedItems().length === 0 ) {
+ // Nothing is selected anymore
+ // Select the default or the first item
+ this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
+ this.getItems()[ 0 ];
+ this.currSelected.toggleSelected( true );
+ changed = true;
+ } else if ( this.findSelectedItems().length > 1 ) {
+ // There is more than one item selected
+ // This should only happen if the item given
+ // is the one that is selected, so unselect
+ // all items that is not it
+ this.findSelectedItems().forEach( function ( itemModel ) {
+ // Note that in case the given item is actually
+ // not selected, this loop will end up unselecting
+ // all items, which would trigger the case above
+ // when the last item is unselected anyways
+ var selected = itemModel.getName() === item.getName() &&
+ item.isSelected();
+
+ itemModel.toggleSelected( selected );
+ if ( selected ) {
+ model.currSelected = itemModel;
+ }
+ } );
+ changed = true;
+ }
+ }
+
+ if ( this.isSticky() ) {
+ // If this group is sticky, then change the default according to the
+ // current selection.
+ this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
+ }
+
+ if (
+ changed ||
+ this.active !== active ||
+ this.currSelected !== item
+ ) {
+ this.active = active;
+ this.currSelected = item;
+
+ this.emit( 'update' );
+ }
+ };
+
+ /**
+ * Get group active state
+ *
+ * @return {boolean} Active state
+ */
+ FilterGroup.prototype.isActive = function () {
+ return this.active;
+ };
+
+ /**
+ * Get group hidden state
+ *
+ * @return {boolean} Hidden state
+ */
+ FilterGroup.prototype.isHidden = function () {
+ return this.hidden;
+ };
+
+ /**
+ * Get group allow arbitrary state
+ *
+ * @return {boolean} Group allows an arbitrary value from the URL
+ */
+ FilterGroup.prototype.isAllowArbitrary = function () {
+ return this.allowArbitrary;
+ };
+
+ /**
+ * Get group maximum value for numeric groups
+ *
+ * @return {number|null} Group max value
+ */
+ FilterGroup.prototype.getMaxValue = function () {
+ return this.numericRange && this.numericRange.max !== undefined ?
+ this.numericRange.max : null;
+ };
+
+ /**
+ * Get group minimum value for numeric groups
+ *
+ * @return {number|null} Group max value
+ */
+ FilterGroup.prototype.getMinValue = function () {
+ return this.numericRange && this.numericRange.min !== undefined ?
+ this.numericRange.min : null;
+ };
+
+ /**
+ * Get group name
+ *
+ * @return {string} Group name
+ */
+ FilterGroup.prototype.getName = function () {
+ return this.name;
+ };
+
+ /**
+ * Get the default param state of this group
+ *
+ * @return {Object} Default param state
+ */
+ FilterGroup.prototype.getDefaultParams = function () {
+ return this.defaultParams;
+ };
+
+ /**
+ * Get the default filter state of this group
+ *
+ * @return {Object} Default filter state
+ */
+ FilterGroup.prototype.getDefaultFilters = function () {
+ return this.defaultFilters;
+ };
+
+ /**
+ * This is for a single_option and string_options group types
+ * it returns the value of the default
+ *
+ * @return {string} Value of the default
+ */
+ FilterGroup.prototype.getDefaulParamValue = function () {
+ return this.defaultParams[ this.getName() ];
+ };
+ /**
+ * Get the messags defining the 'whats this' popup for this group
+ *
+ * @return {Object} What's this messages
+ */
+ FilterGroup.prototype.getWhatsThis = function () {
+ return this.whatsThis;
+ };
+
+ /**
+ * Check whether this group has a 'what's this' message
+ *
+ * @return {boolean} This group has a what's this message
+ */
+ FilterGroup.prototype.hasWhatsThis = function () {
+ return !!this.whatsThis.body;
+ };
+
+ /**
+ * Get the conflicts associated with the entire group.
+ * Conflict object is set up by filter name keys and conflict
+ * definition. For example:
+ * [
+ * {
+ * filterName: {
+ * filter: filterName,
+ * group: group1
+ * }
+ * },
+ * {
+ * filterName2: {
+ * filter: filterName2,
+ * group: group2
+ * }
+ * }
+ * ]
+ * @return {Object} Conflict definition
+ */
+ FilterGroup.prototype.getConflicts = function () {
+ return this.conflicts;
+ };
+
+ /**
+ * Set conflicts for this group. See #getConflicts for the expected
+ * structure of the definition.
+ *
+ * @param {Object} conflicts Conflicts for this group
+ */
+ FilterGroup.prototype.setConflicts = function ( conflicts ) {
+ this.conflicts = conflicts;
+ };
+
+ /**
+ * Set conflicts for each filter item in the group based on the
+ * given conflict map
+ *
+ * @param {Object} conflicts Object representing the conflict map,
+ * keyed by the item name, where its value is an object for all its conflicts
+ */
+ FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
+ this.getItems().forEach( function ( filterItem ) {
+ if ( conflicts[ filterItem.getName() ] ) {
+ filterItem.setConflicts( conflicts[ filterItem.getName() ] );
+ }
+ } );
+ };
+
+ /**
+ * Check whether this item has a potential conflict with the given item
+ *
+ * This checks whether the given item is in the list of conflicts of
+ * the current item, but makes no judgment about whether the conflict
+ * is currently at play (either one of the items may not be selected)
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+ * @return {boolean} This item has a conflict with the given item
+ */
+ FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
+ return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+ };
+
+ /**
+ * Check whether there are any items selected
+ *
+ * @return {boolean} Any items in the group are selected
+ */
+ FilterGroup.prototype.areAnySelected = function () {
+ return this.getItems().some( function ( filterItem ) {
+ return filterItem.isSelected();
+ } );
+ };
+
+ /**
+ * Check whether all items selected
+ *
+ * @return {boolean} All items are selected
+ */
+ FilterGroup.prototype.areAllSelected = function () {
+ var selected = [],
+ unselected = [];
+
+ this.getItems().forEach( function ( filterItem ) {
+ if ( filterItem.isSelected() ) {
+ selected.push( filterItem );
+ } else {
+ unselected.push( filterItem );
+ }
+ } );
+
+ if ( unselected.length === 0 ) {
+ return true;
+ }
+
+ // check if every unselected is a subset of a selected
+ return unselected.every( function ( unselectedFilterItem ) {
+ return selected.some( function ( selectedFilterItem ) {
+ return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
+ } );
+ } );
+ };
+
+ /**
+ * Get all selected items in this group
+ *
+ * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
+ * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+ */
+ FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
+ var excludeName = ( excludeItem && excludeItem.getName() ) || '';
+
+ return this.getItems().filter( function ( item ) {
+ return item.getName() !== excludeName && item.isSelected();
+ } );
+ };
+
+ /**
+ * Check whether all selected items are in conflict with the given item
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+ * @return {boolean} All selected items are in conflict with this item
+ */
+ FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
+ var selectedItems = this.findSelectedItems( filterItem );
+
+ return selectedItems.length > 0 &&
+ (
+ // The group as a whole is in conflict with this item
+ this.existsInConflicts( filterItem ) ||
+ // All selected items are in conflict individually
+ selectedItems.every( function ( selectedFilter ) {
+ return selectedFilter.existsInConflicts( filterItem );
+ } )
+ );
+ };
+
+ /**
+ * Check whether any of the selected items are in conflict with the given item
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+ * @return {boolean} Any of the selected items are in conflict with this item
+ */
+ FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
+ var selectedItems = this.findSelectedItems( filterItem );
+
+ return selectedItems.length > 0 && (
+ // The group as a whole is in conflict with this item
+ this.existsInConflicts( filterItem ) ||
+ // Any selected items are in conflict individually
+ selectedItems.some( function ( selectedFilter ) {
+ return selectedFilter.existsInConflicts( filterItem );
+ } )
+ );
+ };
+
+ /**
+ * Get the parameter representation from this group
+ *
+ * @param {Object} [filterRepresentation] An object defining the state
+ * of the filters in this group, keyed by their name and current selected
+ * state value.
+ * @return {Object} Parameter representation
+ */
+ FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
+ var values,
+ areAnySelected = false,
+ buildFromCurrentState = !filterRepresentation,
+ defaultFilters = this.getDefaultFilters(),
+ result = {},
+ model = this,
+ filterParamNames = {},
+ getSelectedParameter = function ( filters ) {
+ var item,
+ selected = [];
+
+ // Find if any are selected
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( filters, function ( name, value ) {
+ if ( value ) {
+ selected.push( name );
+ }
+ } );
+
+ item = model.getItemByName( selected[ 0 ] );
+ return ( item && item.getParamName() ) || '';
+ };
+
+ filterRepresentation = filterRepresentation || {};
+
+ // Create or complete the filterRepresentation definition
+ this.getItems().forEach( function ( item ) {
+ // Map filter names to their parameter names
+ filterParamNames[ item.getName() ] = item.getParamName();
+
+ if ( buildFromCurrentState ) {
+ // This means we have not been given a filter representation
+ // so we are building one based on current state
+ filterRepresentation[ item.getName() ] = item.getValue();
+ } else if ( filterRepresentation[ item.getName() ] === undefined ) {
+ // We are given a filter representation, but we have to make
+ // sure that we fill in the missing filters if there are any
+ // we will assume they are all falsey
+ if ( model.isSticky() ) {
+ filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
+ } else {
+ filterRepresentation[ item.getName() ] = false;
+ }
+ }
+
+ if ( filterRepresentation[ item.getName() ] ) {
+ areAnySelected = true;
+ }
+ } );
+
+ // Build result
+ if (
+ this.getType() === 'send_unselected_if_any' ||
+ this.getType() === 'boolean' ||
+ this.getType() === 'any_value'
+ ) {
+ // First, check if any of the items are selected at all.
+ // If none is selected, we're treating it as if they are
+ // all false
+
+ // Go over the items and define the correct values
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( filterRepresentation, function ( name, value ) {
+ // We must store all parameter values as strings '0' or '1'
+ if ( model.getType() === 'send_unselected_if_any' ) {
+ result[ filterParamNames[ name ] ] = areAnySelected ?
+ String( Number( !value ) ) :
+ '0';
+ } else if ( model.getType() === 'boolean' ) {
+ // Representation is straight-forward and direct from
+ // the parameter value to the filter state
+ result[ filterParamNames[ name ] ] = String( Number( !!value ) );
+ } else if ( model.getType() === 'any_value' ) {
+ result[ filterParamNames[ name ] ] = value;
+ }
+ } );
+ } else if ( this.getType() === 'string_options' ) {
+ values = [];
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( filterRepresentation, function ( name, value ) {
+ // Collect values
+ if ( value ) {
+ values.push( filterParamNames[ name ] );
+ }
+ } );
+
+ result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
+ 'all' : values.join( this.getSeparator() );
+ } else if ( this.getType() === 'single_option' ) {
+ result[ this.getName() ] = getSelectedParameter( filterRepresentation );
+ }
+
+ return result;
+ };
+
+ /**
+ * Get the filter representation this group would provide
+ * based on given parameter states.
+ *
+ * @param {Object} [paramRepresentation] An object defining a parameter
+ * state to translate the filter state from. If not given, an object
+ * representing all filters as falsey is returned; same as if the parameter
+ * given were an empty object, or had some of the filters missing.
+ * @return {Object} Filter representation
+ */
+ FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
+ var areAnySelected, paramValues, item, currentValue,
+ oneWasSelected = false,
+ defaultParams = this.getDefaultParams(),
+ expandedParams = $.extend( true, {}, paramRepresentation ),
+ model = this,
+ paramToFilterMap = {},
+ result = {};
+
+ if ( this.isSticky() ) {
+ // If the group is sticky, check if all parameters are represented
+ // and for those that aren't represented, add them with their default
+ // values
+ paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
+ }
+
+ paramRepresentation = paramRepresentation || {};
+ if (
+ this.getType() === 'send_unselected_if_any' ||
+ this.getType() === 'boolean' ||
+ this.getType() === 'any_value'
+ ) {
+ // Go over param representation; map and check for selections
+ this.getItems().forEach( function ( filterItem ) {
+ var paramName = filterItem.getParamName();
+
+ expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
+ paramToFilterMap[ paramName ] = filterItem;
+
+ if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
+ areAnySelected = true;
+ }
+ } );
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( expandedParams, function ( paramName, paramValue ) {
+ var filterItem = paramToFilterMap[ paramName ];
+
+ if ( model.getType() === 'send_unselected_if_any' ) {
+ // Flip the definition between the parameter
+ // state and the filter state
+ // This is what the 'toggleSelected' value of the filter is
+ result[ filterItem.getName() ] = areAnySelected ?
+ !Number( paramValue ) :
+ // Otherwise, there are no selected items in the
+ // group, which means the state is false
+ false;
+ } else if ( model.getType() === 'boolean' ) {
+ // Straight-forward definition of state
+ result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
+ } else if ( model.getType() === 'any_value' ) {
+ result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
+ }
+ } );
+ } else if ( this.getType() === 'string_options' ) {
+ currentValue = paramRepresentation[ this.getName() ] || '';
+
+ // Normalize the given parameter values
+ paramValues = mw.rcfilters.utils.normalizeParamOptions(
+ // Given
+ currentValue.split(
+ this.getSeparator()
+ ),
+ // Allowed values
+ this.getItems().map( function ( filterItem ) {
+ return filterItem.getParamName();
+ } )
+ );
+ // Translate the parameter values into a filter selection state
+ this.getItems().forEach( function ( filterItem ) {
+ // All true (either because all values are written or the term 'all' is written)
+ // is the same as all filters set to true
+ result[ filterItem.getName() ] = (
+ // If it is the word 'all'
+ paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
+ // All values are written
+ paramValues.length === model.getItemCount()
+ ) ?
+ true :
+ // Otherwise, the filter is selected only if it appears in the parameter values
+ paramValues.indexOf( filterItem.getParamName() ) > -1;
+ } );
+ } else if ( this.getType() === 'single_option' ) {
+ // There is parameter that fits a single filter and if not, get the default
+ this.getItems().forEach( function ( filterItem ) {
+ var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];
+
+ result[ filterItem.getName() ] = selected;
+ oneWasSelected = oneWasSelected || selected;
+ } );
+ }
+
+ // Go over result and make sure all filters are represented.
+ // If any filters are missing, they will get a falsey value
+ this.getItems().forEach( function ( filterItem ) {
+ if ( result[ filterItem.getName() ] === undefined ) {
+ result[ filterItem.getName() ] = this.getFalsyValue();
+ }
+ }.bind( this ) );
+
+ // 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
+ ) {
+ item = this.getItems()[ 0 ];
+ if ( defaultParams[ this.getName() ] ) {
+ item = this.getItemByParamName( defaultParams[ this.getName() ] );
+ }
+
+ result[ item.getName() ] = true;
+ }
+
+ return result;
+ };
+
+ /**
+ * @return {*} The appropriate falsy value for this group type
+ */
+ FilterGroup.prototype.getFalsyValue = function () {
+ return this.getType() === 'any_value' ? '' : false;
+ };
+
+ /**
+ * Get current selected state of all filter items in this group
+ *
+ * @return {Object} Selected state
+ */
+ FilterGroup.prototype.getSelectedState = function () {
+ var state = {};
+
+ this.getItems().forEach( function ( filterItem ) {
+ state[ filterItem.getName() ] = filterItem.getValue();
+ } );
+
+ return state;
+ };
+
+ /**
+ * Get item by its filter name
+ *
+ * @param {string} filterName Filter name
+ * @return {mw.rcfilters.dm.FilterItem} Filter item
+ */
+ FilterGroup.prototype.getItemByName = function ( filterName ) {
+ return this.getItems().filter( function ( item ) {
+ return item.getName() === filterName;
+ } )[ 0 ];
+ };
+
+ /**
+ * Select an item by its parameter name
+ *
+ * @param {string} paramName Filter parameter name
+ */
+ FilterGroup.prototype.selectItemByParamName = function ( paramName ) {
+ this.getItems().forEach( function ( item ) {
+ item.toggleSelected( item.getParamName() === String( paramName ) );
+ } );
+ };
+
+ /**
+ * Get item by its parameter name
+ *
+ * @param {string} paramName Parameter name
+ * @return {mw.rcfilters.dm.FilterItem} Filter item
+ */
+ FilterGroup.prototype.getItemByParamName = function ( paramName ) {
+ return this.getItems().filter( function ( item ) {
+ return item.getParamName() === String( paramName );
+ } )[ 0 ];
+ };
+
+ /**
+ * Get group type
+ *
+ * @return {string} Group type
+ */
+ FilterGroup.prototype.getType = function () {
+ return this.type;
+ };
+
+ /**
+ * Check whether this group is represented by a single parameter
+ * or whether each item is its own parameter
+ *
+ * @return {boolean} This group is a single parameter
+ */
+ FilterGroup.prototype.isPerGroupRequestParameter = function () {
+ return (
+ this.getType() === 'string_options' ||
+ this.getType() === 'single_option'
+ );
+ };
+
+ /**
+ * Get display group
+ *
+ * @return {string} Display group
+ */
+ FilterGroup.prototype.getView = function () {
+ return this.view;
+ };
+
+ /**
+ * Get the prefix used for the filter names inside this group.
+ *
+ * @param {string} [name] Filter name to prefix
+ * @return {string} Group prefix
+ */
+ FilterGroup.prototype.getNamePrefix = function () {
+ return this.getName() + '__';
+ };
+
+ /**
+ * Get a filter name with the prefix used for the filter names inside this group.
+ *
+ * @param {string} name Filter name to prefix
+ * @return {string} Group prefix
+ */
+ FilterGroup.prototype.getPrefixedName = function ( name ) {
+ return this.getNamePrefix() + name;
+ };
+
+ /**
+ * Get group's title
+ *
+ * @return {string} Title
+ */
+ FilterGroup.prototype.getTitle = function () {
+ return this.title;
+ };
+
+ /**
+ * Get group's values separator
+ *
+ * @return {string} Values separator
+ */
+ FilterGroup.prototype.getSeparator = function () {
+ return this.separator;
+ };
+
+ /**
+ * Check whether the group is defined as full coverage
+ *
+ * @return {boolean} Group is full coverage
+ */
+ FilterGroup.prototype.isFullCoverage = function () {
+ return this.fullCoverage;
+ };
+
+ /**
+ * Check whether the group is defined as sticky default
+ *
+ * @return {boolean} Group is sticky default
+ */
+ FilterGroup.prototype.isSticky = function () {
+ return this.sticky;
+ };
+
+ /**
+ * Normalize a value given to this group. This is mostly for correcting
+ * arbitrary values for 'single option' groups, given by the user settings
+ * or the URL that can go outside the limits that are allowed.
+ *
+ * @param {string} value Given value
+ * @return {string} Corrected value
+ */
+ FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
+ if (
+ this.getType() === 'single_option' &&
+ this.isAllowArbitrary()
+ ) {
+ if (
+ this.getMaxValue() !== null &&
+ value > this.getMaxValue()
+ ) {
+ // Change the value to the actual max value
+ return String( this.getMaxValue() );
+ } else if (
+ this.getMinValue() !== null &&
+ value < this.getMinValue()
+ ) {
+ // Change the value to the actual min value
+ return String( this.getMinValue() );
+ }
+ }
+
+ return value;
+ };
+
+ /**
+ * Toggle the visibility of this group
+ *
+ * @param {boolean} [isVisible] Item is visible
+ */
+ FilterGroup.prototype.toggleVisible = function ( isVisible ) {
+ isVisible = isVisible === undefined ? !this.visible : isVisible;
+
+ if ( this.visible !== isVisible ) {
+ this.visible = isVisible;
+ this.emit( 'update' );
+ }
+ };
+
+ /**
+ * Check whether the group is visible
+ *
+ * @return {boolean} Group is visible
+ */
+ FilterGroup.prototype.isVisible = function () {
+ return this.visible;
+ };
+
+ /**
+ * Set the visibility of the items under this group by the given items array
+ *
+ * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
+ */
+ FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
+ this.getItems().forEach( function ( itemModel ) {
+ itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
+ } );
+ };
+
+ module.exports = FilterGroup;
+}() );
--- /dev/null
+( function () {
+ var ItemModel = require( './ItemModel.js' ),
+ FilterItem;
+
+ /**
+ * Filter item model
+ *
+ * @class mw.rcfilters.dm.FilterItem
+ * @extends mw.rcfilters.dm.ItemModel
+ *
+ * @constructor
+ * @param {string} param Filter param name
+ * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
+ * @param {Object} config Configuration object
+ * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
+ * selected, makes inactive.
+ * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
+ * @cfg {Object} [conflicts] Defines the conflicts for this filter
+ * @cfg {boolean} [visible=true] The visibility of the group
+ */
+ FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
+ config = config || {};
+
+ this.groupModel = groupModel;
+
+ // Parent
+ FilterItem.parent.call( this, param, $.extend( {
+ namePrefix: this.groupModel.getNamePrefix()
+ }, config ) );
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+
+ // Interaction definitions
+ this.subset = config.subset || [];
+ this.conflicts = config.conflicts || {};
+ this.superset = [];
+ this.visible = config.visible === undefined ? true : !!config.visible;
+
+ // Interaction states
+ this.included = false;
+ this.conflicted = false;
+ this.fullyCovered = false;
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( FilterItem, ItemModel );
+
+ /* Methods */
+
+ /**
+ * Return the representation of the state of this item.
+ *
+ * @return {Object} State of the object
+ */
+ FilterItem.prototype.getState = function () {
+ return {
+ selected: this.isSelected(),
+ included: this.isIncluded(),
+ conflicted: this.isConflicted(),
+ fullyCovered: this.isFullyCovered()
+ };
+ };
+
+ /**
+ * Get the message for the display area for the currently active conflict
+ *
+ * @private
+ * @return {string} Conflict result message key
+ */
+ FilterItem.prototype.getCurrentConflictResultMessage = function () {
+ var details = {};
+
+ // First look in filter's own conflicts
+ details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
+ if ( !details.message ) {
+ // Fall back onto conflicts in the group
+ details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
+ }
+
+ return details.message;
+ };
+
+ /**
+ * Get the details of the active conflict on this filter
+ *
+ * @private
+ * @param {Object} conflicts Conflicts to examine
+ * @param {string} [key='contextDescription'] Message key
+ * @return {Object} Object with conflict message and conflict items
+ * @return {string} return.message Conflict message
+ * @return {string[]} return.names Conflicting item labels
+ */
+ FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
+ var group,
+ conflictMessage = '',
+ itemLabels = [];
+
+ key = key || 'contextDescription';
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( conflicts, function ( filterName, conflict ) {
+ if ( !conflict.item.isSelected() ) {
+ return;
+ }
+
+ if ( !conflictMessage ) {
+ conflictMessage = conflict[ key ];
+ group = conflict.group;
+ }
+
+ if ( group === conflict.group ) {
+ itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
+ }
+ } );
+
+ return {
+ message: conflictMessage,
+ names: itemLabels
+ };
+
+ };
+
+ /**
+ * @inheritdoc
+ */
+ FilterItem.prototype.getStateMessage = function () {
+ var messageKey, details, superset,
+ affectingItems = [];
+
+ if ( this.isSelected() ) {
+ if ( this.isConflicted() ) {
+ // First look in filter's own conflicts
+ details = this.getConflictDetails( this.getOwnConflicts() );
+ if ( !details.message ) {
+ // Fall back onto conflicts in the group
+ details = this.getConflictDetails( this.getGroupModel().getConflicts() );
+ }
+
+ messageKey = details.message;
+ affectingItems = details.names;
+ } else if ( this.isIncluded() && !this.isHighlighted() ) {
+ // We only show the 'no effect' full-coverage message
+ // if the item is also not highlighted. See T161273
+ superset = this.getSuperset();
+ // For this message we need to collect the affecting superset
+ affectingItems = this.getGroupModel().findSelectedItems( this )
+ .filter( function ( item ) {
+ return superset.indexOf( item.getName() ) !== -1;
+ } )
+ .map( function ( item ) {
+ return mw.msg( 'quotation-marks', item.getLabel() );
+ } );
+
+ messageKey = 'rcfilters-state-message-subset';
+ } else if ( this.isFullyCovered() && !this.isHighlighted() ) {
+ affectingItems = this.getGroupModel().findSelectedItems( this )
+ .map( function ( item ) {
+ return mw.msg( 'quotation-marks', item.getLabel() );
+ } );
+
+ messageKey = 'rcfilters-state-message-fullcoverage';
+ }
+ }
+
+ if ( messageKey ) {
+ // Build message
+ return mw.msg(
+ messageKey,
+ mw.language.listToText( affectingItems ),
+ affectingItems.length
+ );
+ }
+
+ // Display description
+ return this.getDescription();
+ };
+
+ /**
+ * Get the model of the group this filter belongs to
+ *
+ * @return {mw.rcfilters.dm.FilterGroup} Filter group model
+ */
+ FilterItem.prototype.getGroupModel = function () {
+ return this.groupModel;
+ };
+
+ /**
+ * Get the group name this filter belongs to
+ *
+ * @return {string} Filter group name
+ */
+ FilterItem.prototype.getGroupName = function () {
+ return this.groupModel.getName();
+ };
+
+ /**
+ * Get filter subset
+ * This is a list of filter names that are defined to be included
+ * when this filter is selected.
+ *
+ * @return {string[]} Filter subset
+ */
+ FilterItem.prototype.getSubset = function () {
+ return this.subset;
+ };
+
+ /**
+ * Get filter superset
+ * This is a generated list of filters that define this filter
+ * to be included when either of them is selected.
+ *
+ * @return {string[]} Filter superset
+ */
+ FilterItem.prototype.getSuperset = function () {
+ return this.superset;
+ };
+
+ /**
+ * Check whether the filter is currently in a conflict state
+ *
+ * @return {boolean} Filter is in conflict state
+ */
+ FilterItem.prototype.isConflicted = function () {
+ return this.conflicted;
+ };
+
+ /**
+ * Check whether the filter is currently in an already included subset
+ *
+ * @return {boolean} Filter is in an already-included subset
+ */
+ FilterItem.prototype.isIncluded = function () {
+ return this.included;
+ };
+
+ /**
+ * Check whether the filter is currently fully covered
+ *
+ * @return {boolean} Filter is in fully-covered state
+ */
+ FilterItem.prototype.isFullyCovered = function () {
+ return this.fullyCovered;
+ };
+
+ /**
+ * Get all conflicts associated with this filter or its group
+ *
+ * Conflict object is set up by filter name keys and conflict
+ * definition. For example:
+ *
+ * {
+ * filterName: {
+ * filter: filterName,
+ * group: group1,
+ * label: itemLabel,
+ * item: itemModel
+ * }
+ * filterName2: {
+ * filter: filterName2,
+ * group: group2
+ * label: itemLabel2,
+ * item: itemModel2
+ * }
+ * }
+ *
+ * @return {Object} Filter conflicts
+ */
+ FilterItem.prototype.getConflicts = function () {
+ return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
+ };
+
+ /**
+ * Get the conflicts associated with this filter
+ *
+ * @return {Object} Filter conflicts
+ */
+ FilterItem.prototype.getOwnConflicts = function () {
+ return this.conflicts;
+ };
+
+ /**
+ * Set conflicts for this filter. See #getConflicts for the expected
+ * structure of the definition.
+ *
+ * @param {Object} conflicts Conflicts for this filter
+ */
+ FilterItem.prototype.setConflicts = function ( conflicts ) {
+ this.conflicts = conflicts || {};
+ };
+
+ /**
+ * Set filter superset
+ *
+ * @param {string[]} superset Filter superset
+ */
+ FilterItem.prototype.setSuperset = function ( superset ) {
+ this.superset = superset || [];
+ };
+
+ /**
+ * Set filter subset
+ *
+ * @param {string[]} subset Filter subset
+ */
+ FilterItem.prototype.setSubset = function ( subset ) {
+ this.subset = subset || [];
+ };
+
+ /**
+ * Check whether a filter exists in the subset list for this filter
+ *
+ * @param {string} filterName Filter name
+ * @return {boolean} Filter name is in the subset list
+ */
+ FilterItem.prototype.existsInSubset = function ( filterName ) {
+ return this.subset.indexOf( filterName ) > -1;
+ };
+
+ /**
+ * Check whether this item has a potential conflict with the given item
+ *
+ * This checks whether the given item is in the list of conflicts of
+ * the current item, but makes no judgment about whether the conflict
+ * is currently at play (either one of the items may not be selected)
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+ * @return {boolean} This item has a conflict with the given item
+ */
+ FilterItem.prototype.existsInConflicts = function ( filterItem ) {
+ return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+ };
+
+ /**
+ * Set the state of this filter as being conflicted
+ * (This means any filters in its conflicts are selected)
+ *
+ * @param {boolean} [conflicted] Filter is in conflict state
+ * @fires update
+ */
+ FilterItem.prototype.toggleConflicted = function ( conflicted ) {
+ conflicted = conflicted === undefined ? !this.conflicted : conflicted;
+
+ if ( this.conflicted !== conflicted ) {
+ this.conflicted = conflicted;
+ this.emit( 'update' );
+ }
+ };
+
+ /**
+ * Set the state of this filter as being already included
+ * (This means any filters in its superset are selected)
+ *
+ * @param {boolean} [included] Filter is included as part of a subset
+ * @fires update
+ */
+ FilterItem.prototype.toggleIncluded = function ( included ) {
+ included = included === undefined ? !this.included : included;
+
+ if ( this.included !== included ) {
+ this.included = included;
+ this.emit( 'update' );
+ }
+ };
+
+ /**
+ * Toggle the fully covered state of the item
+ *
+ * @param {boolean} [isFullyCovered] Filter is fully covered
+ * @fires update
+ */
+ FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
+ isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
+
+ if ( this.fullyCovered !== isFullyCovered ) {
+ this.fullyCovered = isFullyCovered;
+ this.emit( 'update' );
+ }
+ };
+
+ /**
+ * Toggle the visibility of this item
+ *
+ * @param {boolean} [isVisible] Item is visible
+ */
+ FilterItem.prototype.toggleVisible = function ( isVisible ) {
+ isVisible = isVisible === undefined ? !this.visible : !!isVisible;
+
+ if ( this.visible !== isVisible ) {
+ this.visible = isVisible;
+ this.emit( 'update' );
+ }
+ };
+
+ /**
+ * Check whether the item is visible
+ *
+ * @return {boolean} Item is visible
+ */
+ FilterItem.prototype.isVisible = function () {
+ return this.visible;
+ };
+
+ module.exports = FilterItem;
+
+}() );
--- /dev/null
+( function () {
+ var FilterGroup = require( './FilterGroup.js' ),
+ FilterItem = require( './FilterItem.js' ),
+ FiltersViewModel;
+
+ /**
+ * View model for the filters selection and display
+ *
+ * @class mw.rcfilters.dm.FiltersViewModel
+ * @mixins OO.EventEmitter
+ * @mixins OO.EmitterList
+ *
+ * @constructor
+ */
+ FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+ OO.EmitterList.call( this );
+
+ this.groups = {};
+ this.defaultParams = {};
+ this.highlightEnabled = false;
+ this.parameterMap = {};
+ this.emptyParameterState = null;
+
+ this.views = {};
+ this.currentView = 'default';
+ this.searchQuery = null;
+
+ // Events
+ this.aggregate( { update: 'filterItemUpdate' } );
+ this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
+ };
+
+ /* Initialization */
+ OO.initClass( FiltersViewModel );
+ OO.mixinClass( FiltersViewModel, OO.EventEmitter );
+ OO.mixinClass( FiltersViewModel, OO.EmitterList );
+
+ /* Events */
+
+ /**
+ * @event initialize
+ *
+ * Filter list is initialized
+ */
+
+ /**
+ * @event update
+ *
+ * Model has been updated
+ */
+
+ /**
+ * @event itemUpdate
+ * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
+ *
+ * Filter item has changed
+ */
+
+ /**
+ * @event highlightChange
+ * @param {boolean} Highlight feature is enabled
+ *
+ * Highlight feature has been toggled enabled or disabled
+ */
+
+ /* Methods */
+
+ /**
+ * Re-assess the states of filter items based on the interactions between them
+ *
+ * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
+ * method will go over the state of all items
+ */
+ FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
+ var allSelected,
+ model = this,
+ iterationItems = item !== undefined ? [ item ] : this.getItems();
+
+ iterationItems.forEach( function ( checkedItem ) {
+ var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
+ groupModel = checkedItem.getGroupModel();
+
+ // Check for subsets (included filters) plus the item itself:
+ allCheckedItems.forEach( function ( filterItemName ) {
+ var itemInSubset = model.getItemByName( filterItemName );
+
+ itemInSubset.toggleIncluded(
+ // If any of itemInSubset's supersets are selected, this item
+ // is included
+ itemInSubset.getSuperset().some( function ( supersetName ) {
+ return ( model.getItemByName( supersetName ).isSelected() );
+ } )
+ );
+ } );
+
+ // Update coverage for the changed group
+ if ( groupModel.isFullCoverage() ) {
+ allSelected = groupModel.areAllSelected();
+ groupModel.getItems().forEach( function ( filterItem ) {
+ filterItem.toggleFullyCovered( allSelected );
+ } );
+ }
+ } );
+
+ // Check for conflicts
+ // In this case, we must go over all items, since
+ // conflicts are bidirectional and depend not only on
+ // individual items, but also on the selected states of
+ // the groups they're in.
+ this.getItems().forEach( function ( filterItem ) {
+ var inConflict = false,
+ filterItemGroup = filterItem.getGroupModel();
+
+ // For each item, see if that item is still conflicting
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( model.groups, function ( groupName, groupModel ) {
+ if ( filterItem.getGroupName() === groupName ) {
+ // Check inside the group
+ inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
+ } else {
+ // According to the spec, if two items conflict from two different
+ // groups, the conflict only lasts if the groups **only have selected
+ // items that are conflicting**. If a group has selected items that
+ // are conflicting and non-conflicting, the scope of the result has
+ // expanded enough to completely remove the conflict.
+
+ // For example, see two groups with conflicts:
+ // userExpLevel: [
+ // {
+ // name: 'experienced',
+ // conflicts: [ 'unregistered' ]
+ // }
+ // ],
+ // registration: [
+ // {
+ // name: 'registered',
+ // },
+ // {
+ // name: 'unregistered',
+ // }
+ // ]
+ // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
+ // because, inherently, 'experienced' filter only includes registered users, and so
+ // both filters are in conflict with one another.
+ // However, the minute we select 'registered', the scope of our results
+ // has expanded to no longer have a conflict with 'experienced' filter, and
+ // so the conflict is removed.
+
+ // In our case, we need to check if the entire group conflicts with
+ // the entire item's group, so we follow the above spec
+ inConflict = (
+ // The foreign group is in conflict with this item
+ groupModel.areAllSelectedInConflictWith( filterItem ) &&
+ // Every selected member of the item's own group is also
+ // in conflict with the other group
+ filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
+ return groupModel.areAllSelectedInConflictWith( otherGroupItem );
+ } )
+ );
+ }
+
+ // If we're in conflict, this will return 'false' which
+ // will break the loop. Otherwise, we're not in conflict
+ // and the loop continues
+ return !inConflict;
+ } );
+
+ // Toggle the item state
+ filterItem.toggleConflicted( inConflict );
+ } );
+ };
+
+ /**
+ * Get whether the model has any conflict in its items
+ *
+ * @return {boolean} There is a conflict
+ */
+ FiltersViewModel.prototype.hasConflict = function () {
+ return this.getItems().some( function ( filterItem ) {
+ return filterItem.isSelected() && filterItem.isConflicted();
+ } );
+ };
+
+ /**
+ * Get the first item with a current conflict
+ *
+ * @return {mw.rcfilters.dm.FilterItem} Conflicted item
+ */
+ FiltersViewModel.prototype.getFirstConflictedItem = function () {
+ var conflictedItem;
+
+ this.getItems().forEach( function ( filterItem ) {
+ if ( filterItem.isSelected() && filterItem.isConflicted() ) {
+ conflictedItem = filterItem;
+ return false;
+ }
+ } );
+
+ return conflictedItem;
+ };
+
+ /**
+ * Set filters and preserve a group relationship based on
+ * the definition given by an object
+ *
+ * @param {Array} filterGroups Filters definition
+ * @param {Object} [views] Extra views definition
+ * Expected in the following format:
+ * {
+ * namespaces: {
+ * label: 'namespaces', // Message key
+ * trigger: ':',
+ * groups: [
+ * {
+ * // Group info
+ * name: 'namespaces' // Parameter name
+ * title: 'namespaces' // Message key
+ * type: 'string_options',
+ * separator: ';',
+ * labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+ * fullCoverage: true
+ * items: []
+ * }
+ * ]
+ * }
+ * }
+ */
+ FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
+ var filterConflictResult, groupConflictResult,
+ allViews = {},
+ model = this,
+ items = [],
+ groupConflictMap = {},
+ filterConflictMap = {},
+ /*!
+ * Expand a conflict definition from group name to
+ * the list of all included filters in that group.
+ * We do this so that the direct relationship in the
+ * models are consistently item->items rather than
+ * mixing item->group with item->item.
+ *
+ * @param {Object} obj Conflict definition
+ * @return {Object} Expanded conflict definition
+ */
+ expandConflictDefinitions = function ( obj ) {
+ var result = {};
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( obj, function ( key, conflicts ) {
+ var filterName,
+ adjustedConflicts = {};
+
+ conflicts.forEach( function ( conflict ) {
+ var filter;
+
+ if ( conflict.filter ) {
+ filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
+ filter = model.getItemByName( filterName );
+
+ // Rename
+ adjustedConflicts[ filterName ] = $.extend(
+ {},
+ conflict,
+ {
+ filter: filterName,
+ item: filter
+ }
+ );
+ } else {
+ // This conflict is for an entire group. Split it up to
+ // represent each filter
+
+ // Get the relevant group items
+ model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
+ // Rebuild the conflict
+ adjustedConflicts[ groupItem.getName() ] = $.extend(
+ {},
+ conflict,
+ {
+ filter: groupItem.getName(),
+ item: groupItem
+ }
+ );
+ } );
+ }
+ } );
+
+ result[ key ] = adjustedConflicts;
+ } );
+
+ return result;
+ };
+
+ // Reset
+ this.clearItems();
+ this.groups = {};
+ this.views = {};
+
+ // Clone
+ filterGroups = OO.copy( filterGroups );
+
+ // Normalize definition from the server
+ filterGroups.forEach( function ( data ) {
+ var i;
+ // What's this information needs to be normalized
+ data.whatsThis = {
+ body: data.whatsThisBody,
+ header: data.whatsThisHeader,
+ linkText: data.whatsThisLinkText,
+ url: data.whatsThisUrl
+ };
+
+ // Title is a msg-key
+ data.title = data.title ? mw.msg( data.title ) : data.name;
+
+ // Filters are given to us with msg-keys, we need
+ // to translate those before we hand them off
+ for ( i = 0; i < data.filters.length; i++ ) {
+ data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
+ data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
+ }
+ } );
+
+ // Collect views
+ allViews = $.extend( true, {
+ default: {
+ title: mw.msg( 'rcfilters-filterlist-title' ),
+ groups: filterGroups
+ }
+ }, views );
+
+ // Go over all views
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( allViews, function ( viewName, viewData ) {
+ // Define the view
+ model.views[ viewName ] = {
+ name: viewData.name,
+ title: viewData.title,
+ trigger: viewData.trigger
+ };
+
+ // Go over groups
+ viewData.groups.forEach( function ( groupData ) {
+ var group = groupData.name;
+
+ if ( !model.groups[ group ] ) {
+ model.groups[ group ] = new FilterGroup(
+ group,
+ $.extend( true, {}, groupData, { view: viewName } )
+ );
+ }
+
+ model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
+ items = items.concat( model.groups[ group ].getItems() );
+
+ // Prepare conflicts
+ if ( groupData.conflicts ) {
+ // Group conflicts
+ groupConflictMap[ group ] = groupData.conflicts;
+ }
+
+ groupData.filters.forEach( function ( itemData ) {
+ var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
+ // Filter conflicts
+ if ( itemData.conflicts ) {
+ filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
+ }
+ } );
+ } );
+ } );
+
+ // Add item references to the model, for lookup
+ this.addItems( items );
+
+ // Expand conflicts
+ groupConflictResult = expandConflictDefinitions( groupConflictMap );
+ filterConflictResult = expandConflictDefinitions( filterConflictMap );
+
+ // Set conflicts for groups
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( groupConflictResult, function ( group, conflicts ) {
+ model.groups[ group ].setConflicts( conflicts );
+ } );
+
+ // Set conflicts for items
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( filterConflictResult, function ( filterName, conflicts ) {
+ var filterItem = model.getItemByName( filterName );
+ // set conflicts for items in the group
+ filterItem.setConflicts( conflicts );
+ } );
+
+ // Create a map between known parameters and their models
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.groups, function ( group, groupModel ) {
+ if (
+ groupModel.getType() === 'send_unselected_if_any' ||
+ groupModel.getType() === 'boolean' ||
+ groupModel.getType() === 'any_value'
+ ) {
+ // Individual filters
+ groupModel.getItems().forEach( function ( filterItem ) {
+ model.parameterMap[ filterItem.getParamName() ] = filterItem;
+ } );
+ } else if (
+ groupModel.getType() === 'string_options' ||
+ groupModel.getType() === 'single_option'
+ ) {
+ // Group
+ model.parameterMap[ groupModel.getName() ] = groupModel;
+ }
+ } );
+
+ this.setSearch( '' );
+
+ this.updateHighlightedState();
+
+ // Finish initialization
+ this.emit( 'initialize' );
+ };
+
+ /**
+ * Update filter view model state based on a parameter object
+ *
+ * @param {Object} params Parameters object
+ */
+ FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
+ var filtersValue;
+ // For arbitrary numeric single_option values make sure the values
+ // are normalized to fit within the limits
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+ params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
+ } );
+
+ // Update filter values
+ filtersValue = this.getFiltersFromParameters( params );
+ Object.keys( filtersValue ).forEach( function ( filterName ) {
+ this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
+ }.bind( this ) );
+
+ // Update highlight state
+ this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
+ var color = params[ filterItem.getName() + '_color' ];
+ if ( color ) {
+ filterItem.setHighlightColor( color );
+ } else {
+ filterItem.clearHighlightColor();
+ }
+ } );
+ this.updateHighlightedState();
+
+ // Check all filter interactions
+ this.reassessFilterInteractions();
+ };
+
+ /**
+ * Get a representation of an empty (falsey) parameter state
+ *
+ * @return {Object} Empty parameter state
+ */
+ FiltersViewModel.prototype.getEmptyParameterState = function () {
+ if ( !this.emptyParameterState ) {
+ this.emptyParameterState = $.extend(
+ true,
+ {},
+ this.getParametersFromFilters( {} ),
+ this.getEmptyHighlightParameters()
+ );
+ }
+ return this.emptyParameterState;
+ };
+
+ /**
+ * Get a representation of only the non-falsey parameters
+ *
+ * @param {Object} [parameters] A given parameter state to minimize. If not given the current
+ * state of the system will be used.
+ * @return {Object} Empty parameter state
+ */
+ FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
+ var result = {};
+
+ parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
+
+ // Params
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.getEmptyParameterState(), function ( param, value ) {
+ if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
+ result[ param ] = parameters[ param ];
+ }
+ } );
+
+ // Highlights
+ Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
+ if ( parameters[ param ] ) {
+ // If a highlight parameter is not undefined and not null
+ // add it to the result
+ result[ param ] = parameters[ param ];
+ }
+ } );
+
+ return result;
+ };
+
+ /**
+ * Get a representation of the full parameter list, including all base values
+ *
+ * @return {Object} Full parameter representation
+ */
+ FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
+ return $.extend(
+ true,
+ {},
+ this.getEmptyParameterState(),
+ this.getCurrentParameterState()
+ );
+ };
+
+ /**
+ * Get a parameter representation of the current state of the model
+ *
+ * @param {boolean} [removeStickyParams] Remove sticky filters from final result
+ * @return {Object} Parameter representation of the current state of the model
+ */
+ FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
+ var state = this.getMinimizedParamRepresentation( $.extend(
+ true,
+ {},
+ this.getParametersFromFilters( this.getSelectedState() ),
+ this.getHighlightParameters()
+ ) );
+
+ if ( removeStickyParams ) {
+ state = this.removeStickyParams( state );
+ }
+
+ return state;
+ };
+
+ /**
+ * Delete sticky parameters from given object.
+ *
+ * @param {Object} paramState Parameter state
+ * @return {Object} Parameter state without sticky parameters
+ */
+ FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
+ this.getStickyParams().forEach( function ( paramName ) {
+ delete paramState[ paramName ];
+ } );
+
+ return paramState;
+ };
+
+ /**
+ * Turn the highlight feature on or off
+ */
+ FiltersViewModel.prototype.updateHighlightedState = function () {
+ this.toggleHighlight( this.getHighlightedItems().length > 0 );
+ };
+
+ /**
+ * Get the object that defines groups by their name.
+ *
+ * @return {Object} Filter groups
+ */
+ FiltersViewModel.prototype.getFilterGroups = function () {
+ return this.groups;
+ };
+
+ /**
+ * Get the object that defines groups that match a certain view by their name.
+ *
+ * @param {string} [view] Requested view. If not given, uses current view
+ * @return {Object} Filter groups matching a display group
+ */
+ FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
+ var result = {};
+
+ view = view || this.getCurrentView();
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.groups, function ( groupName, groupModel ) {
+ if ( groupModel.getView() === view ) {
+ result[ groupName ] = groupModel;
+ }
+ } );
+
+ return result;
+ };
+
+ /**
+ * Get an array of filters matching the given display group.
+ *
+ * @param {string} [view] Requested view. If not given, uses current view
+ * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
+ */
+ FiltersViewModel.prototype.getFiltersByView = function ( view ) {
+ var groups,
+ result = [];
+
+ view = view || this.getCurrentView();
+
+ groups = this.getFilterGroupsByView( view );
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( groups, function ( groupName, groupModel ) {
+ result = result.concat( groupModel.getItems() );
+ } );
+
+ return result;
+ };
+
+ /**
+ * Get the trigger for the requested view.
+ *
+ * @param {string} view View name
+ * @return {string} View trigger, if exists
+ */
+ FiltersViewModel.prototype.getViewTrigger = function ( view ) {
+ return ( this.views[ view ] && this.views[ view ].trigger ) || '';
+ };
+
+ /**
+ * Get the value of a specific parameter
+ *
+ * @param {string} name Parameter name
+ * @return {number|string} Parameter value
+ */
+ FiltersViewModel.prototype.getParamValue = function ( name ) {
+ return this.parameters[ name ];
+ };
+
+ /**
+ * Get the current selected state of the filters
+ *
+ * @param {boolean} [onlySelected] return an object containing only the filters with a value
+ * @return {Object} Filters selected state
+ */
+ FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
+ var i,
+ items = this.getItems(),
+ result = {};
+
+ for ( i = 0; i < items.length; i++ ) {
+ if ( !onlySelected || items[ i ].getValue() ) {
+ result[ items[ i ].getName() ] = items[ i ].getValue();
+ }
+ }
+
+ return result;
+ };
+
+ /**
+ * Get the current full state of the filters
+ *
+ * @return {Object} Filters full state
+ */
+ FiltersViewModel.prototype.getFullState = function () {
+ var i,
+ items = this.getItems(),
+ result = {};
+
+ for ( i = 0; i < items.length; i++ ) {
+ result[ items[ i ].getName() ] = {
+ selected: items[ i ].isSelected(),
+ conflicted: items[ i ].isConflicted(),
+ included: items[ i ].isIncluded()
+ };
+ }
+
+ return result;
+ };
+
+ /**
+ * Get an object representing default parameters state
+ *
+ * @return {Object} Default parameter values
+ */
+ FiltersViewModel.prototype.getDefaultParams = function () {
+ var result = {};
+
+ // Get default filter state
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.groups, function ( name, model ) {
+ if ( !model.isSticky() ) {
+ $.extend( true, result, model.getDefaultParams() );
+ }
+ } );
+
+ return result;
+ };
+
+ /**
+ * Get a parameter representation of all sticky parameters
+ *
+ * @return {Object} Sticky parameter values
+ */
+ FiltersViewModel.prototype.getStickyParams = function () {
+ var result = [];
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.groups, function ( name, model ) {
+ if ( model.isSticky() ) {
+ if ( model.isPerGroupRequestParameter() ) {
+ result.push( name );
+ } else {
+ // Each filter is its own param
+ result = result.concat( model.getItems().map( function ( filterItem ) {
+ return filterItem.getParamName();
+ } ) );
+ }
+ }
+ } );
+
+ return result;
+ };
+
+ /**
+ * Get a parameter representation of all sticky parameters
+ *
+ * @return {Object} Sticky parameter values
+ */
+ FiltersViewModel.prototype.getStickyParamsValues = function () {
+ var result = {};
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.groups, function ( name, model ) {
+ if ( model.isSticky() ) {
+ $.extend( true, result, model.getParamRepresentation() );
+ }
+ } );
+
+ return result;
+ };
+
+ /**
+ * Analyze the groups and their filters and output an object representing
+ * the state of the parameters they represent.
+ *
+ * @param {Object} [filterDefinition] An object defining the filter values,
+ * keyed by filter names.
+ * @return {Object} Parameter state object
+ */
+ FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
+ var groupItemDefinition,
+ result = {},
+ groupItems = this.getFilterGroups();
+
+ if ( filterDefinition ) {
+ groupItemDefinition = {};
+ // Filter definition is "flat", but in effect
+ // each group needs to tell us its result based
+ // on the values in it. We need to split this list
+ // back into groupings so we can "feed" it to the
+ // loop below, and we need to expand it so it includes
+ // all filters (set to false)
+ this.getItems().forEach( function ( filterItem ) {
+ groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
+ groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
+ } );
+ }
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( groupItems, function ( group, model ) {
+ $.extend(
+ result,
+ model.getParamRepresentation(
+ groupItemDefinition ?
+ groupItemDefinition[ group ] : null
+ )
+ );
+ } );
+
+ return result;
+ };
+
+ /**
+ * This is the opposite of the #getParametersFromFilters method; this goes over
+ * the given parameters and translates into a selected/unselected value in the filters.
+ *
+ * @param {Object} params Parameters query object
+ * @return {Object} Filter state object
+ */
+ FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
+ var groupMap = {},
+ model = this,
+ result = {};
+
+ // Go over the given parameters, break apart to groupings
+ // The resulting object represents the group with its parameter
+ // values. For example:
+ // {
+ // group1: {
+ // param1: "1",
+ // param2: "0",
+ // param3: "1"
+ // },
+ // group2: "param4|param5"
+ // }
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( params, function ( paramName, paramValue ) {
+ var groupName,
+ itemOrGroup = model.parameterMap[ paramName ];
+
+ if ( itemOrGroup ) {
+ groupName = itemOrGroup instanceof FilterItem ?
+ itemOrGroup.getGroupName() : itemOrGroup.getName();
+
+ groupMap[ groupName ] = groupMap[ groupName ] || {};
+ groupMap[ groupName ][ paramName ] = paramValue;
+ }
+ } );
+
+ // Go over all groups, so we make sure we get the complete output
+ // even if the parameters don't include a certain group
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.groups, function ( groupName, groupModel ) {
+ result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
+ } );
+
+ return result;
+ };
+
+ /**
+ * Get the highlight parameters based on current filter configuration
+ *
+ * @return {Object} Object where keys are `<filter name>_color` and values
+ * are the selected highlight colors.
+ */
+ FiltersViewModel.prototype.getHighlightParameters = function () {
+ var highlightEnabled = this.isHighlightEnabled(),
+ result = {};
+
+ this.getItems().forEach( function ( filterItem ) {
+ if ( filterItem.isHighlightSupported() ) {
+ result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
+ filterItem.getHighlightColor() :
+ null;
+ }
+ } );
+
+ return result;
+ };
+
+ /**
+ * Get an object representing the complete empty state of highlights
+ *
+ * @return {Object} Object containing all the highlight parameters set to their negative value
+ */
+ FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
+ var result = {};
+
+ this.getItems().forEach( function ( filterItem ) {
+ if ( filterItem.isHighlightSupported() ) {
+ result[ filterItem.getName() + '_color' ] = null;
+ }
+ } );
+
+ return result;
+ };
+
+ /**
+ * Get an array of currently applied highlight colors
+ *
+ * @return {string[]} Currently applied highlight colors
+ */
+ FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
+ var result = [];
+
+ if ( this.isHighlightEnabled() ) {
+ this.getHighlightedItems().forEach( function ( filterItem ) {
+ var color = filterItem.getHighlightColor();
+
+ if ( result.indexOf( color ) === -1 ) {
+ result.push( color );
+ }
+ } );
+ }
+
+ return result;
+ };
+
+ /**
+ * Sanitize value group of a string_option groups type
+ * Remove duplicates and make sure to only use valid
+ * values.
+ *
+ * @private
+ * @param {string} groupName Group name
+ * @param {string[]} valueArray Array of values
+ * @return {string[]} Array of valid values
+ */
+ FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
+ var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
+ return filterItem.getParamName();
+ } );
+
+ return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
+ };
+
+ /**
+ * Check whether no visible filter is selected.
+ *
+ * Filter groups that are hidden or sticky are not shown in the
+ * active filters area and therefore not included in this check.
+ *
+ * @return {boolean} No visible filter is selected
+ */
+ FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
+ // Check if there are either any selected items or any items
+ // that have highlight enabled
+ return !this.getItems().some( function ( filterItem ) {
+ var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
+ active = ( filterItem.isSelected() || filterItem.isHighlighted() );
+ return visible && active;
+ } );
+ };
+
+ /**
+ * Check whether the invert state is a valid one. A valid invert state is one where
+ * there are actual namespaces selected.
+ *
+ * This is done to compare states to previous ones that may have had the invert model
+ * selected but effectively had no namespaces, so are not effectively different than
+ * ones where invert is not selected.
+ *
+ * @return {boolean} Invert is effectively selected
+ */
+ FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
+ return this.getInvertModel().isSelected() &&
+ this.findSelectedItems().some( function ( itemModel ) {
+ return itemModel.getGroupModel().getName() === 'namespace';
+ } );
+ };
+
+ /**
+ * Get the item that matches the given name
+ *
+ * @param {string} name Filter name
+ * @return {mw.rcfilters.dm.FilterItem} Filter item
+ */
+ FiltersViewModel.prototype.getItemByName = function ( name ) {
+ return this.getItems().filter( function ( item ) {
+ return name === item.getName();
+ } )[ 0 ];
+ };
+
+ /**
+ * Set all filters to false or empty/all
+ * This is equivalent to display all.
+ */
+ FiltersViewModel.prototype.emptyAllFilters = function () {
+ this.getItems().forEach( function ( filterItem ) {
+ if ( !filterItem.getGroupModel().isSticky() ) {
+ this.toggleFilterSelected( filterItem.getName(), false );
+ }
+ }.bind( this ) );
+ };
+
+ /**
+ * Toggle selected state of one item
+ *
+ * @param {string} name Name of the filter item
+ * @param {boolean} [isSelected] Filter selected state
+ */
+ FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
+ var item = this.getItemByName( name );
+
+ if ( item ) {
+ item.toggleSelected( isSelected );
+ }
+ };
+
+ /**
+ * Toggle selected state of items by their names
+ *
+ * @param {Object} filterDef Filter definitions
+ */
+ FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
+ Object.keys( filterDef ).forEach( function ( name ) {
+ this.toggleFilterSelected( name, filterDef[ name ] );
+ }.bind( this ) );
+ };
+
+ /**
+ * Get a group model from its name
+ *
+ * @param {string} groupName Group name
+ * @return {mw.rcfilters.dm.FilterGroup} Group model
+ */
+ FiltersViewModel.prototype.getGroup = function ( groupName ) {
+ return this.groups[ groupName ];
+ };
+
+ /**
+ * Get all filters within a specified group by its name
+ *
+ * @param {string} groupName Group name
+ * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
+ */
+ FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
+ return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
+ };
+
+ /**
+ * Find items whose labels match the given string
+ *
+ * @param {string} query Search string
+ * @param {boolean} [returnFlat] Return a flat array. If false, the result
+ * is an object whose keys are the group names and values are an array of
+ * filters per group. If set to true, returns an array of filters regardless
+ * of their groups.
+ * @return {Object} An object of items to show
+ * arranged by their group names
+ */
+ FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
+ var i, searchIsEmpty,
+ groupTitle,
+ result = {},
+ flatResult = [],
+ view = this.getViewByTrigger( query.substr( 0, 1 ) ),
+ items = this.getFiltersByView( view );
+
+ // Normalize so we can search strings regardless of case and view
+ query = query.trim().toLowerCase();
+ if ( view !== 'default' ) {
+ query = query.substr( 1 );
+ }
+ // Trim again to also intercept cases where the spaces were after the trigger
+ // eg: '# str'
+ query = query.trim();
+
+ // Check if the search if actually empty; this can be a problem when
+ // we use prefixes to denote different views
+ searchIsEmpty = query.length === 0;
+
+ // item label starting with the query string
+ for ( i = 0; i < items.length; i++ ) {
+ if (
+ searchIsEmpty ||
+ items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
+ (
+ // For tags, we want the parameter name to be included in the search
+ view === 'tags' &&
+ items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+ )
+ ) {
+ result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+ result[ items[ i ].getGroupName() ].push( items[ i ] );
+ flatResult.push( items[ i ] );
+ }
+ }
+
+ if ( $.isEmptyObject( result ) ) {
+ // item containing the query string in their label, description, or group title
+ for ( i = 0; i < items.length; i++ ) {
+ groupTitle = items[ i ].getGroupModel().getTitle();
+ if (
+ searchIsEmpty ||
+ items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
+ items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
+ groupTitle.toLowerCase().indexOf( query ) > -1 ||
+ (
+ // For tags, we want the parameter name to be included in the search
+ view === 'tags' &&
+ items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+ )
+ ) {
+ result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+ result[ items[ i ].getGroupName() ].push( items[ i ] );
+ flatResult.push( items[ i ] );
+ }
+ }
+ }
+
+ return returnFlat ? flatResult : result;
+ };
+
+ /**
+ * Get items that are highlighted
+ *
+ * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
+ */
+ FiltersViewModel.prototype.getHighlightedItems = function () {
+ return this.getItems().filter( function ( filterItem ) {
+ return filterItem.isHighlightSupported() &&
+ filterItem.getHighlightColor();
+ } );
+ };
+
+ /**
+ * Get items that allow highlights even if they're not currently highlighted
+ *
+ * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
+ */
+ FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
+ return this.getItems().filter( function ( filterItem ) {
+ return filterItem.isHighlightSupported();
+ } );
+ };
+
+ /**
+ * Get all selected items
+ *
+ * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+ */
+ FiltersViewModel.prototype.findSelectedItems = function () {
+ var allSelected = [];
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+ allSelected = allSelected.concat( groupModel.findSelectedItems() );
+ } );
+
+ return allSelected;
+ };
+
+ /**
+ * Get the current view
+ *
+ * @return {string} Current view
+ */
+ FiltersViewModel.prototype.getCurrentView = function () {
+ return this.currentView;
+ };
+
+ /**
+ * Get the label for the current view
+ *
+ * @param {string} viewName View name
+ * @return {string} Label for the current view
+ */
+ FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
+ viewName = viewName || this.getCurrentView();
+
+ return this.views[ viewName ] && this.views[ viewName ].title;
+ };
+
+ /**
+ * Get the view that fits the given trigger
+ *
+ * @param {string} trigger Trigger
+ * @return {string} Name of view
+ */
+ FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
+ var result = 'default';
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.views, function ( name, data ) {
+ if ( data.trigger === trigger ) {
+ result = name;
+ }
+ } );
+
+ return result;
+ };
+
+ /**
+ * Return a version of the given string that is without any
+ * view triggers.
+ *
+ * @param {string} str Given string
+ * @return {string} Result
+ */
+ FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
+ if ( this.getViewFromString( str ) !== 'default' ) {
+ str = str.substr( 1 );
+ }
+
+ return str;
+ };
+
+ /**
+ * Get the view from the given string by a trigger, if it exists
+ *
+ * @param {string} str Given string
+ * @return {string} View name
+ */
+ FiltersViewModel.prototype.getViewFromString = function ( str ) {
+ return this.getViewByTrigger( str.substr( 0, 1 ) );
+ };
+
+ /**
+ * Set the current search for the system.
+ * This also dictates what items and groups are visible according
+ * to the search in #findMatches
+ *
+ * @param {string} searchQuery Search query, including triggers
+ * @fires searchChange
+ */
+ FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
+ var visibleGroups, visibleGroupNames;
+
+ if ( this.searchQuery !== searchQuery ) {
+ // Check if the view changed
+ this.switchView( this.getViewFromString( searchQuery ) );
+
+ visibleGroups = this.findMatches( searchQuery );
+ visibleGroupNames = Object.keys( visibleGroups );
+
+ // Update visibility of items and groups
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+ // Check if the group is visible at all
+ groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
+ groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
+ } );
+
+ this.searchQuery = searchQuery;
+ this.emit( 'searchChange', this.searchQuery );
+ }
+ };
+
+ /**
+ * Get the current search
+ *
+ * @return {string} Current search query
+ */
+ FiltersViewModel.prototype.getSearch = function () {
+ return this.searchQuery;
+ };
+
+ /**
+ * Switch the current view
+ *
+ * @private
+ * @param {string} view View name
+ */
+ FiltersViewModel.prototype.switchView = function ( view ) {
+ if ( this.views[ view ] && this.currentView !== view ) {
+ this.currentView = view;
+ }
+ };
+
+ /**
+ * Toggle the highlight feature on and off.
+ * Propagate the change to filter items.
+ *
+ * @param {boolean} enable Highlight should be enabled
+ * @fires highlightChange
+ */
+ FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
+ enable = enable === undefined ? !this.highlightEnabled : enable;
+
+ if ( this.highlightEnabled !== enable ) {
+ this.highlightEnabled = enable;
+ this.emit( 'highlightChange', this.highlightEnabled );
+ }
+ };
+
+ /**
+ * Check if the highlight feature is enabled
+ * @return {boolean}
+ */
+ FiltersViewModel.prototype.isHighlightEnabled = function () {
+ return !!this.highlightEnabled;
+ };
+
+ /**
+ * Toggle the inverted namespaces property on and off.
+ * Propagate the change to namespace filter items.
+ *
+ * @param {boolean} enable Inverted property is enabled
+ */
+ FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
+ this.toggleFilterSelected( this.getInvertModel().getName(), enable );
+ };
+
+ /**
+ * Get the model object that represents the 'invert' filter
+ *
+ * @return {mw.rcfilters.dm.FilterItem}
+ */
+ FiltersViewModel.prototype.getInvertModel = function () {
+ return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
+ };
+
+ /**
+ * Set highlight color for a specific filter item
+ *
+ * @param {string} filterName Name of the filter item
+ * @param {string} color Selected color
+ */
+ FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
+ this.getItemByName( filterName ).setHighlightColor( color );
+ };
+
+ /**
+ * Clear highlight for a specific filter item
+ *
+ * @param {string} filterName Name of the filter item
+ */
+ FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
+ this.getItemByName( filterName ).clearHighlightColor();
+ };
+
+ module.exports = FiltersViewModel;
+
+}() );
--- /dev/null
+( function () {
+ /**
+ * RCFilter base item model
+ *
+ * @class mw.rcfilters.dm.ItemModel
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {string} param Filter param name
+ * @param {Object} config Configuration object
+ * @cfg {string} [label] The label for the filter
+ * @cfg {string} [description] The description of the filter
+ * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+ * group. If the prefix has 'invert' state, the parameter is expected to be an object
+ * with 'default' and 'inverted' as keys.
+ * @cfg {boolean} [active=true] The filter is active and affecting the result
+ * @cfg {boolean} [selected] The item is selected
+ * @cfg {*} [value] The value of this item
+ * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
+ * identifier
+ * @cfg {string} [cssClass] The class identifying the results that match this filter
+ * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
+ * added and considered in the view.
+ * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
+ */
+ var ItemModel = function MwRcfiltersDmItemModel( param, config ) {
+ config = config || {};
+
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+
+ this.param = param;
+ this.namePrefix = config.namePrefix || 'item_';
+ this.name = this.namePrefix + param;
+
+ this.label = config.label || this.name;
+ this.labelPrefixKey = config.labelPrefixKey;
+ this.description = config.description || '';
+ this.setValue( config.value || config.selected );
+
+ this.identifiers = config.identifiers || [];
+
+ // Highlight
+ this.cssClass = config.cssClass;
+ this.highlightColor = config.defaultHighlightColor || null;
+ };
+
+ /* Initialization */
+
+ OO.initClass( ItemModel );
+ OO.mixinClass( ItemModel, OO.EventEmitter );
+
+ /* Events */
+
+ /**
+ * @event update
+ *
+ * The state of this filter has changed
+ */
+
+ /* Methods */
+
+ /**
+ * Return the representation of the state of this item.
+ *
+ * @return {Object} State of the object
+ */
+ ItemModel.prototype.getState = function () {
+ return {
+ selected: this.isSelected()
+ };
+ };
+
+ /**
+ * Get the name of this filter
+ *
+ * @return {string} Filter name
+ */
+ ItemModel.prototype.getName = function () {
+ return this.name;
+ };
+
+ /**
+ * Get the message key to use to wrap the label. This message takes the label as a parameter.
+ *
+ * @param {boolean} inverted Whether this item should be considered inverted
+ * @return {string|null} Message key, or null if no message
+ */
+ ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
+ if ( this.labelPrefixKey ) {
+ if ( typeof this.labelPrefixKey === 'string' ) {
+ return this.labelPrefixKey;
+ }
+ return this.labelPrefixKey[
+ // Only use inverted-prefix if the item is selected
+ // Highlight-only an inverted item makes no sense
+ inverted && this.isSelected() ?
+ 'inverted' : 'default'
+ ];
+ }
+ return null;
+ };
+
+ /**
+ * Get the param name or value of this filter
+ *
+ * @return {string} Filter param name
+ */
+ ItemModel.prototype.getParamName = function () {
+ return this.param;
+ };
+
+ /**
+ * Get the message representing the state of this model.
+ *
+ * @return {string} State message
+ */
+ ItemModel.prototype.getStateMessage = function () {
+ // Display description
+ return this.getDescription();
+ };
+
+ /**
+ * Get the label of this filter
+ *
+ * @return {string} Filter label
+ */
+ ItemModel.prototype.getLabel = function () {
+ return this.label;
+ };
+
+ /**
+ * Get the description of this filter
+ *
+ * @return {string} Filter description
+ */
+ ItemModel.prototype.getDescription = function () {
+ return this.description;
+ };
+
+ /**
+ * Get the default value of this filter
+ *
+ * @return {boolean} Filter default
+ */
+ ItemModel.prototype.getDefault = function () {
+ return this.default;
+ };
+
+ /**
+ * Get the selected state of this filter
+ *
+ * @return {boolean} Filter is selected
+ */
+ ItemModel.prototype.isSelected = function () {
+ return !!this.value;
+ };
+
+ /**
+ * Toggle the selected state of the item
+ *
+ * @param {boolean} [isSelected] Filter is selected
+ * @fires update
+ */
+ ItemModel.prototype.toggleSelected = function ( isSelected ) {
+ isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
+ this.setValue( isSelected );
+ };
+
+ /**
+ * Get the value
+ *
+ * @return {*}
+ */
+ ItemModel.prototype.getValue = function () {
+ return this.value;
+ };
+
+ /**
+ * Convert a given value to the appropriate representation based on group type
+ *
+ * @param {*} value
+ * @return {*}
+ */
+ ItemModel.prototype.coerceValue = function ( value ) {
+ return this.getGroupModel().getType() === 'any_value' ? value : !!value;
+ };
+
+ /**
+ * Set the value
+ *
+ * @param {*} newValue
+ */
+ ItemModel.prototype.setValue = function ( newValue ) {
+ newValue = this.coerceValue( newValue );
+ if ( this.value !== newValue ) {
+ this.value = newValue;
+ this.emit( 'update' );
+ }
+ };
+
+ /**
+ * Set the highlight color
+ *
+ * @param {string|null} highlightColor
+ */
+ ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
+ if ( !this.isHighlightSupported() ) {
+ return;
+ }
+ // If the highlight color on the item and in the parameter is null/undefined, return early.
+ if ( !this.highlightColor && !highlightColor ) {
+ return;
+ }
+
+ if ( this.highlightColor !== highlightColor ) {
+ this.highlightColor = highlightColor;
+ this.emit( 'update' );
+ }
+ };
+
+ /**
+ * Clear the highlight color
+ */
+ ItemModel.prototype.clearHighlightColor = function () {
+ this.setHighlightColor( null );
+ };
+
+ /**
+ * Get the highlight color, or null if none is configured
+ *
+ * @return {string|null}
+ */
+ ItemModel.prototype.getHighlightColor = function () {
+ return this.highlightColor;
+ };
+
+ /**
+ * Get the CSS class that matches changes that fit this filter
+ * or null if none is configured
+ *
+ * @return {string|null}
+ */
+ ItemModel.prototype.getCssClass = function () {
+ return this.cssClass;
+ };
+
+ /**
+ * Get the item's identifiers
+ *
+ * @return {string[]}
+ */
+ ItemModel.prototype.getIdentifiers = function () {
+ return this.identifiers;
+ };
+
+ /**
+ * Check if the highlight feature is supported for this filter
+ *
+ * @return {boolean}
+ */
+ ItemModel.prototype.isHighlightSupported = function () {
+ return !!this.getCssClass();
+ };
+
+ /**
+ * Check if the filter is currently highlighted
+ *
+ * @return {boolean}
+ */
+ ItemModel.prototype.isHighlighted = function () {
+ return !!this.getHighlightColor();
+ };
+
+ module.exports = ItemModel;
+}() );
--- /dev/null
+( function () {
+ var SavedQueryItemModel = require( './SavedQueryItemModel.js' ),
+ SavedQueriesModel;
+
+ /**
+ * View model for saved queries
+ *
+ * @class mw.rcfilters.dm.SavedQueriesModel
+ * @mixins OO.EventEmitter
+ * @mixins OO.EmitterList
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [default] Default query ID
+ */
+ SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
+ config = config || {};
+
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+ OO.EmitterList.call( this );
+
+ this.default = config.default;
+ this.filtersModel = filtersModel;
+ this.converted = false;
+
+ // Events
+ this.aggregate( { update: 'itemUpdate' } );
+ };
+
+ /* Initialization */
+
+ OO.initClass( SavedQueriesModel );
+ OO.mixinClass( SavedQueriesModel, OO.EventEmitter );
+ OO.mixinClass( SavedQueriesModel, OO.EmitterList );
+
+ /* Events */
+
+ /**
+ * @event initialize
+ *
+ * Model is initialized
+ */
+
+ /**
+ * @event itemUpdate
+ * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
+ *
+ * An item has changed
+ */
+
+ /**
+ * @event default
+ * @param {string} New default ID
+ *
+ * The default has changed
+ */
+
+ /* Methods */
+
+ /**
+ * Initialize the saved queries model by reading it from the user's settings.
+ * The structure of the saved queries is:
+ * {
+ * version: (string) Version number; if version 2, the query represents
+ * parameters. Otherwise, the older version represented filters
+ * and needs to be readjusted,
+ * default: (string) Query ID
+ * queries:{
+ * query_id_1: {
+ * data:{
+ * filters: (Object) Minimal definition of the filters
+ * highlights: (Object) Definition of the highlights
+ * },
+ * label: (optional) Name of this query
+ * }
+ * }
+ * }
+ *
+ * @param {Object} [savedQueries] An object with the saved queries with
+ * the above structure.
+ * @fires initialize
+ */
+ SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
+ var model = this;
+
+ savedQueries = savedQueries || {};
+
+ this.clearItems();
+ this.default = null;
+ this.converted = false;
+
+ if ( savedQueries.version !== '2' ) {
+ // Old version dealt with filter names. We need to migrate to the new structure
+ // The new structure:
+ // {
+ // version: (string) '2',
+ // default: (string) Query ID,
+ // queries: {
+ // query_id: {
+ // label: (string) Name of the query
+ // data: {
+ // params: (object) Representing all the parameter states
+ // highlights: (object) Representing all the filter highlight states
+ // }
+ // }
+ // }
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( savedQueries.queries || {}, function ( id, obj ) {
+ if ( obj.data && obj.data.filters ) {
+ obj.data = model.convertToParameters( obj.data );
+ }
+ } );
+
+ this.converted = true;
+ savedQueries.version = '2';
+ }
+
+ // Initialize the query items
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( savedQueries.queries || {}, function ( id, obj ) {
+ var normalizedData = obj.data,
+ isDefault = String( savedQueries.default ) === String( id );
+
+ if ( normalizedData && normalizedData.params ) {
+ // Backwards-compat fix: Remove sticky parameters from
+ // the given data, if they exist
+ normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );
+
+ // Correct the invert state for effective selection
+ if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
+ delete normalizedData.params.invert;
+ }
+
+ model.cleanupHighlights( normalizedData );
+
+ id = String( id );
+
+ // Skip the addNewQuery method because we don't want to unnecessarily manipulate
+ // the given saved queries unless we literally intend to (like in backwards compat fixes)
+ // And the addNewQuery method also uses a minimization routine that checks for the
+ // validity of items and minimizes the query. This isn't necessary for queries loaded
+ // from the backend, and has the risk of removing values if they're temporarily
+ // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
+ model.addItems( [
+ new SavedQueryItemModel(
+ id,
+ obj.label,
+ normalizedData,
+ { default: isDefault }
+ )
+ ] );
+
+ if ( isDefault ) {
+ model.default = id;
+ }
+ }
+ } );
+
+ this.emit( 'initialize' );
+ };
+
+ /**
+ * Clean up highlight parameters.
+ * 'highlight' used to be stored, it's not inferred based on the presence of absence of
+ * filter colors.
+ *
+ * @param {Object} data Saved query data
+ */
+ SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
+ if (
+ data.params.highlight === '0' &&
+ data.highlights && Object.keys( data.highlights ).length
+ ) {
+ data.highlights = {};
+ }
+ delete data.params.highlight;
+ };
+
+ /**
+ * Convert from representation of filters to representation of parameters
+ *
+ * @param {Object} data Query data
+ * @return {Object} New converted query data
+ */
+ SavedQueriesModel.prototype.convertToParameters = function ( data ) {
+ var newData = {},
+ defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
+ fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
+ highlightEnabled = data.highlights.highlight;
+
+ delete data.highlights.highlight;
+
+ // Filters
+ newData.params = this.filtersModel.getMinimizedParamRepresentation(
+ this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
+ );
+
+ // Highlights: appending _color to keys
+ newData.highlights = {};
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( data.highlights, function ( highlightedFilterName, value ) {
+ if ( value ) {
+ newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
+ }
+ } );
+
+ // Add highlight
+ newData.params.highlight = String( Number( highlightEnabled || 0 ) );
+
+ return newData;
+ };
+
+ /**
+ * Add a query item
+ *
+ * @param {string} label Label for the new query
+ * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
+ * @param {boolean} isDefault Item is default
+ * @param {string} [id] Query ID, if exists. If this isn't given, a random
+ * new ID will be created.
+ * @return {string} ID of the newly added query
+ */
+ SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
+ var normalizedData = { params: {}, highlights: {} },
+ highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
+ randomID = String( id || ( new Date() ).getTime() ),
+ data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
+
+ // Split highlight/params
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( data, function ( param, value ) {
+ if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
+ normalizedData.highlights[ param ] = value;
+ } else {
+ normalizedData.params[ param ] = value;
+ }
+ } );
+
+ // Correct the invert state for effective selection
+ if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
+ delete normalizedData.params.invert;
+ }
+
+ // Add item
+ this.addItems( [
+ new SavedQueryItemModel(
+ randomID,
+ label,
+ normalizedData,
+ { default: isDefault }
+ )
+ ] );
+
+ if ( isDefault ) {
+ this.setDefault( randomID );
+ }
+
+ return randomID;
+ };
+
+ /**
+ * Remove query from model
+ *
+ * @param {string} queryID Query ID
+ */
+ SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
+ var query = this.getItemByID( queryID );
+
+ if ( query ) {
+ // Check if this item was the default
+ if ( String( this.getDefault() ) === String( queryID ) ) {
+ // Nulify the default
+ this.setDefault( null );
+ }
+
+ this.removeItems( [ query ] );
+ }
+ };
+
+ /**
+ * Get an item that matches the requested query
+ *
+ * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
+ * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
+ */
+ SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
+ // Minimize before comparison
+ fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
+
+ // Correct the invert state for effective selection
+ if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
+ delete fullQueryComparison.invert;
+ }
+
+ return this.getItems().filter( function ( item ) {
+ return OO.compare(
+ item.getCombinedData(),
+ fullQueryComparison
+ );
+ } )[ 0 ];
+ };
+
+ /**
+ * Get query by its identifier
+ *
+ * @param {string} queryID Query identifier
+ * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
+ * the search. Undefined if not found.
+ */
+ SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
+ return this.getItems().filter( function ( item ) {
+ return item.getID() === queryID;
+ } )[ 0 ];
+ };
+
+ /**
+ * Get the full data representation of the default query, if it exists
+ *
+ * @return {Object|null} Representation of the default params if exists.
+ * Null if default doesn't exist or if the user is not logged in.
+ */
+ SavedQueriesModel.prototype.getDefaultParams = function () {
+ return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
+ };
+
+ /**
+ * Get a full parameter representation of an item data
+ *
+ * @param {Object} queryID Query ID
+ * @return {Object} Parameter representation
+ */
+ SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
+ var item = this.getItemByID( queryID ),
+ data = item ? item.getData() : {};
+
+ return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
+ };
+
+ /**
+ * Build a full parameter representation given item data and model sticky values state
+ *
+ * @param {Object} data Item data
+ * @return {Object} Full param representation
+ */
+ SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
+ data = data || {};
+ // Return parameter representation
+ return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
+ data.params,
+ data.highlights
+ ) );
+ };
+
+ /**
+ * Get the object representing the state of the entire model and items
+ *
+ * @return {Object} Object representing the state of the model and items
+ */
+ SavedQueriesModel.prototype.getState = function () {
+ var obj = { queries: {}, version: '2' };
+
+ // Translate the items to the saved object
+ this.getItems().forEach( function ( item ) {
+ obj.queries[ item.getID() ] = item.getState();
+ } );
+
+ if ( this.getDefault() ) {
+ obj.default = this.getDefault();
+ }
+
+ return obj;
+ };
+
+ /**
+ * Set a default query. Null to unset default.
+ *
+ * @param {string} itemID Query identifier
+ * @fires default
+ */
+ SavedQueriesModel.prototype.setDefault = function ( itemID ) {
+ if ( this.default !== itemID ) {
+ this.default = itemID;
+
+ // Set for individual itens
+ this.getItems().forEach( function ( item ) {
+ item.toggleDefault( item.getID() === itemID );
+ } );
+
+ this.emit( 'default', itemID );
+ }
+ };
+
+ /**
+ * Get the default query ID
+ *
+ * @return {string} Default query identifier
+ */
+ SavedQueriesModel.prototype.getDefault = function () {
+ return this.default;
+ };
+
+ /**
+ * Check if the saved queries were converted
+ *
+ * @return {boolean} Saved queries were converted from the previous
+ * version to the new version
+ */
+ SavedQueriesModel.prototype.isConverted = function () {
+ return this.converted;
+ };
+
+ module.exports = SavedQueriesModel;
+}() );
--- /dev/null
+( function () {
+ /**
+ * View model for a single saved query
+ *
+ * @class mw.rcfilters.dm.SavedQueryItemModel
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {string} id Unique identifier
+ * @param {string} label Saved query label
+ * @param {Object} data Saved query data
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [default] This item is the default
+ */
+ var SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
+ config = config || {};
+
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+
+ this.id = id;
+ this.label = label;
+ this.data = data;
+ this.default = !!config.default;
+ };
+
+ /* Initialization */
+
+ OO.initClass( SavedQueryItemModel );
+ OO.mixinClass( SavedQueryItemModel, OO.EventEmitter );
+
+ /* Events */
+
+ /**
+ * @event update
+ *
+ * Model has been updated
+ */
+
+ /* Methods */
+
+ /**
+ * Get an object representing the state of this item
+ *
+ * @return {Object} Object representing the current data state
+ * of the object
+ */
+ SavedQueryItemModel.prototype.getState = function () {
+ return {
+ data: this.getData(),
+ label: this.getLabel()
+ };
+ };
+
+ /**
+ * Get the query's identifier
+ *
+ * @return {string} Query identifier
+ */
+ SavedQueryItemModel.prototype.getID = function () {
+ return this.id;
+ };
+
+ /**
+ * Get query label
+ *
+ * @return {string} Query label
+ */
+ SavedQueryItemModel.prototype.getLabel = function () {
+ return this.label;
+ };
+
+ /**
+ * Update the query label
+ *
+ * @param {string} newLabel New label
+ */
+ SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
+ if ( newLabel && this.label !== newLabel ) {
+ this.label = newLabel;
+ this.emit( 'update' );
+ }
+ };
+
+ /**
+ * Get query data
+ *
+ * @return {Object} Object representing parameter and highlight data
+ */
+ SavedQueryItemModel.prototype.getData = function () {
+ return this.data;
+ };
+
+ /**
+ * Get the combined data of this item as a flat object of parameters
+ *
+ * @return {Object} Combined parameter data
+ */
+ SavedQueryItemModel.prototype.getCombinedData = function () {
+ return $.extend( true, {}, this.data.params, this.data.highlights );
+ };
+
+ /**
+ * Check whether this item is the default
+ *
+ * @return {boolean} Query is set to be default
+ */
+ SavedQueryItemModel.prototype.isDefault = function () {
+ return this.default;
+ };
+
+ /**
+ * Toggle the default state of this query item
+ *
+ * @param {boolean} isDefault Query is default
+ */
+ SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
+ isDefault = isDefault === undefined ? !this.default : isDefault;
+
+ if ( this.default !== isDefault ) {
+ this.default = isDefault;
+ this.emit( 'update' );
+ }
+ };
+
+ module.exports = SavedQueryItemModel;
+}() );
+++ /dev/null
-( function () {
- /**
- * View model for the changes list
- *
- * @mixins OO.EventEmitter
- *
- * @param {jQuery} $initialFieldset The initial server-generated legacy form content
- * @constructor
- */
- mw.rcfilters.dm.ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
- // Mixin constructor
- OO.EventEmitter.call( this );
-
- this.valid = true;
- this.newChangesExist = false;
- this.liveUpdate = false;
- this.unseenWatchedChanges = false;
-
- this.extractNextFrom( $initialFieldset );
- };
-
- /* Initialization */
- OO.initClass( mw.rcfilters.dm.ChangesListViewModel );
- OO.mixinClass( mw.rcfilters.dm.ChangesListViewModel, OO.EventEmitter );
-
- /* Events */
-
- /**
- * @event invalidate
- *
- * The list of changes is now invalid (out of date)
- */
-
- /**
- * @event update
- * @param {jQuery|string} $changesListContent List of changes
- * @param {jQuery} $fieldset Server-generated form
- * @param {string} noResultsDetails Type of no result error
- * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
- * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
- *
- * The list of changes has been updated
- */
-
- /**
- * @event newChangesExist
- * @param {boolean} newChangesExist
- *
- * The existence of changes newer than those currently displayed has changed.
- */
-
- /**
- * @event liveUpdateChange
- * @param {boolean} enable
- *
- * The state of the 'live update' feature has changed.
- */
-
- /* Methods */
-
- /**
- * Invalidate the list of changes
- *
- * @fires invalidate
- */
- mw.rcfilters.dm.ChangesListViewModel.prototype.invalidate = function () {
- if ( this.valid ) {
- this.valid = false;
- this.emit( 'invalidate' );
- }
- };
-
- /**
- * Update the model with an updated list of changes
- *
- * @param {jQuery|string} changesListContent
- * @param {jQuery} $fieldset
- * @param {string} noResultsDetails Type of no result error
- * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
- * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
- * @fires update
- */
- mw.rcfilters.dm.ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
- var from = this.nextFrom;
- this.valid = true;
- this.extractNextFrom( $fieldset );
- this.checkForUnseenWatchedChanges( changesListContent );
- this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
- };
-
- /**
- * Specify whether new changes exist
- *
- * @param {boolean} newChangesExist
- * @fires newChangesExist
- */
- mw.rcfilters.dm.ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
- if ( newChangesExist !== this.newChangesExist ) {
- this.newChangesExist = newChangesExist;
- this.emit( 'newChangesExist', newChangesExist );
- }
- };
-
- /**
- * @return {boolean} Whether new changes exist
- */
- mw.rcfilters.dm.ChangesListViewModel.prototype.getNewChangesExist = function () {
- return this.newChangesExist;
- };
-
- /**
- * Extract the value of the 'from' parameter from a link in the field set
- *
- * @param {jQuery} $fieldset
- */
- mw.rcfilters.dm.ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
- var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
- if ( data && data.from ) {
- this.nextFrom = data.from;
- }
- };
-
- /**
- * @return {string} The 'from' parameter that can be used to query new changes
- */
- mw.rcfilters.dm.ChangesListViewModel.prototype.getNextFrom = function () {
- return this.nextFrom;
- };
-
- /**
- * Toggle the 'live update' feature on/off
- *
- * @param {boolean} enable
- */
- mw.rcfilters.dm.ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
- enable = enable === undefined ? !this.liveUpdate : enable;
- if ( enable !== this.liveUpdate ) {
- this.liveUpdate = enable;
- this.emit( 'liveUpdateChange', this.liveUpdate );
- }
- };
-
- /**
- * @return {boolean} The 'live update' feature is enabled
- */
- mw.rcfilters.dm.ChangesListViewModel.prototype.getLiveUpdate = function () {
- return this.liveUpdate;
- };
-
- /**
- * Check if some of the given changes watched and unseen
- *
- * @param {jQuery|string} changeslistContent
- */
- mw.rcfilters.dm.ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
- this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
- changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
- };
-
- /**
- * @return {boolean} Whether some of the current changes are watched and unseen
- */
- mw.rcfilters.dm.ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
- return this.unseenWatchedChanges;
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * View model for a filter group
- *
- * @mixins OO.EventEmitter
- * @mixins OO.EmitterList
- *
- * @constructor
- * @param {string} name Group name
- * @param {Object} [config] Configuration options
- * @cfg {string} [type='send_unselected_if_any'] Group type
- * @cfg {string} [view='default'] Name of the display group this group
- * is a part of.
- * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
- * with a preference, does not participate in Saved Queries, and is
- * not shown in the active filters area.
- * @cfg {string} [title] Group title
- * @cfg {boolean} [hidden] This group is hidden from the regular menu views
- * and the active filters area.
- * @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 {number} [range] An object defining minimum and maximum values for numeric
- * groups. { min: x, max: y }
- * @cfg {number} [minValue] Minimum value for numeric groups
- * @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
- * @cfg {Object} [conflicts] Defines the conflicts for this filter group
- * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
- * group. If the prefix has 'invert' state, the parameter is expected to be an object
- * with 'default' and 'inverted' as keys.
- * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
- * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
- * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
- * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
- * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
- * @cfg {boolean} [visible=true] The visibility of the group
- */
- mw.rcfilters.dm.FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
- config = config || {};
-
- // Mixin constructor
- OO.EventEmitter.call( this );
- OO.EmitterList.call( this );
-
- this.name = name;
- this.type = config.type || 'send_unselected_if_any';
- this.view = config.view || 'default';
- this.sticky = !!config.sticky;
- this.title = config.title || name;
- this.hidden = !!config.hidden;
- this.allowArbitrary = !!config.allowArbitrary;
- this.numericRange = config.range;
- this.separator = config.separator || '|';
- this.labelPrefixKey = config.labelPrefixKey;
- this.visible = config.visible === undefined ? true : !!config.visible;
-
- this.currSelected = null;
- this.active = !!config.active;
- this.fullCoverage = !!config.fullCoverage;
-
- this.whatsThis = config.whatsThis || {};
-
- this.conflicts = config.conflicts || {};
- this.defaultParams = {};
- this.defaultFilters = {};
-
- this.aggregate( { update: 'filterItemUpdate' } );
- this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
- };
-
- /* Initialization */
- OO.initClass( mw.rcfilters.dm.FilterGroup );
- OO.mixinClass( mw.rcfilters.dm.FilterGroup, OO.EventEmitter );
- OO.mixinClass( mw.rcfilters.dm.FilterGroup, OO.EmitterList );
-
- /* Events */
-
- /**
- * @event update
- *
- * Group state has been updated
- */
-
- /* Methods */
-
- /**
- * Initialize the group and create its filter items
- *
- * @param {Object} filterDefinition Filter definition for this group
- * @param {string|Object} [groupDefault] Definition of the group default
- */
- mw.rcfilters.dm.FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
- var defaultParam,
- supersetMap = {},
- model = this,
- items = [];
-
- filterDefinition.forEach( function ( filter ) {
- // Instantiate an item
- var subsetNames = [],
- filterItem = new mw.rcfilters.dm.FilterItem( filter.name, model, {
- group: model.getName(),
- label: filter.label || filter.name,
- description: filter.description || '',
- labelPrefixKey: model.labelPrefixKey,
- cssClass: filter.cssClass,
- identifiers: filter.identifiers,
- defaultHighlightColor: filter.defaultHighlightColor
- } );
-
- if ( filter.subset ) {
- filter.subset = filter.subset.map( function ( el ) {
- return el.filter;
- } );
-
- subsetNames = [];
-
- filter.subset.forEach( function ( subsetFilterName ) {
- // Subsets (unlike conflicts) are always inside the same group
- // We can re-map the names of the filters we are getting from
- // the subsets with the group prefix
- var subsetName = model.getPrefixedName( subsetFilterName );
- // For convenience, we should store each filter's "supersets" -- these are
- // the filters that have that item in their subset list. This will just
- // make it easier to go through whether the item has any other items
- // that affect it (and are selected) at any given time
- supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
- mw.rcfilters.utils.addArrayElementsUnique(
- supersetMap[ subsetName ],
- filterItem.getName()
- );
-
- // Translate subset param name to add the group name, so we
- // get consistent naming. We know that subsets are only within
- // the same group
- subsetNames.push( subsetName );
- } );
-
- // Set translated subset
- filterItem.setSubset( subsetNames );
- }
-
- items.push( filterItem );
-
- // Store default parameter state; in this case, default is defined per filter
- if (
- model.getType() === 'send_unselected_if_any' ||
- model.getType() === 'boolean'
- ) {
- // Store the default parameter state
- // For this group type, parameter values are direct
- // We need to convert from a boolean to a string ('1' and '0')
- model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
- } else if ( model.getType() === 'any_value' ) {
- model.defaultParams[ filter.name ] = filter.default;
- }
- } );
-
- // Add items
- this.addItems( items );
-
- // Now that we have all items, we can apply the superset map
- this.getItems().forEach( function ( filterItem ) {
- filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
- } );
-
- // Store default parameter state; in this case, default is defined per the
- // entire group, given by groupDefault method parameter
- if ( this.getType() === 'string_options' ) {
- // Store the default parameter group state
- // For this group, the parameter is group name and value is the names
- // of selected items
- this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
- // Current values
- groupDefault ?
- groupDefault.split( this.getSeparator() ) :
- [],
- // Legal values
- this.getItems().map( function ( item ) {
- return item.getParamName();
- } )
- ).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: default or first item
- this.defaultParams[ this.getName() ] = defaultParam;
- }
-
- // add highlights to defaultParams
- this.getItems().forEach( function ( filterItem ) {
- if ( filterItem.isHighlighted() ) {
- this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
- }
- }.bind( this ) );
-
- // Store default filter state based on default params
- this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
-
- // Check for filters that should be initially selected by their default value
- if ( this.isSticky() ) {
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.defaultFilters, function ( filterName, filterValue ) {
- model.getItemByName( filterName ).toggleSelected( filterValue );
- } );
- }
-
- // Verify that single_option group has at least one item selected
- if (
- this.getType() === 'single_option' &&
- this.findSelectedItems().length === 0
- ) {
- defaultParam = groupDefault !== undefined ?
- groupDefault : this.getItems()[ 0 ].getParamName();
-
- // 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 );
- }
- };
-
- /**
- * Respond to filterItem update event
- *
- * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
- * @fires update
- */
- mw.rcfilters.dm.FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
- // Update state
- var changed = false,
- active = this.areAnySelected(),
- model = this;
-
- if ( this.getType() === 'single_option' ) {
- // This group must have one item selected always
- // and must never have more than one item selected at a time
- if ( this.findSelectedItems().length === 0 ) {
- // Nothing is selected anymore
- // Select the default or the first item
- this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
- this.getItems()[ 0 ];
- this.currSelected.toggleSelected( true );
- changed = true;
- } else if ( this.findSelectedItems().length > 1 ) {
- // There is more than one item selected
- // This should only happen if the item given
- // is the one that is selected, so unselect
- // all items that is not it
- this.findSelectedItems().forEach( function ( itemModel ) {
- // Note that in case the given item is actually
- // not selected, this loop will end up unselecting
- // all items, which would trigger the case above
- // when the last item is unselected anyways
- var selected = itemModel.getName() === item.getName() &&
- item.isSelected();
-
- itemModel.toggleSelected( selected );
- if ( selected ) {
- model.currSelected = itemModel;
- }
- } );
- changed = true;
- }
- }
-
- if ( this.isSticky() ) {
- // If this group is sticky, then change the default according to the
- // current selection.
- this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
- }
-
- if (
- changed ||
- this.active !== active ||
- this.currSelected !== item
- ) {
- this.active = active;
- this.currSelected = item;
-
- this.emit( 'update' );
- }
- };
-
- /**
- * Get group active state
- *
- * @return {boolean} Active state
- */
- mw.rcfilters.dm.FilterGroup.prototype.isActive = function () {
- return this.active;
- };
-
- /**
- * Get group hidden state
- *
- * @return {boolean} Hidden state
- */
- mw.rcfilters.dm.FilterGroup.prototype.isHidden = function () {
- 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 maximum value for numeric groups
- *
- * @return {number|null} Group max value
- */
- mw.rcfilters.dm.FilterGroup.prototype.getMaxValue = function () {
- return this.numericRange && this.numericRange.max !== undefined ?
- this.numericRange.max : null;
- };
-
- /**
- * Get group minimum value for numeric groups
- *
- * @return {number|null} Group max value
- */
- mw.rcfilters.dm.FilterGroup.prototype.getMinValue = function () {
- return this.numericRange && this.numericRange.min !== undefined ?
- this.numericRange.min : null;
- };
-
- /**
- * Get group name
- *
- * @return {string} Group name
- */
- mw.rcfilters.dm.FilterGroup.prototype.getName = function () {
- return this.name;
- };
-
- /**
- * Get the default param state of this group
- *
- * @return {Object} Default param state
- */
- mw.rcfilters.dm.FilterGroup.prototype.getDefaultParams = function () {
- return this.defaultParams;
- };
-
- /**
- * Get the default filter state of this group
- *
- * @return {Object} Default filter state
- */
- mw.rcfilters.dm.FilterGroup.prototype.getDefaultFilters = function () {
- return this.defaultFilters;
- };
-
- /**
- * 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} What's this messages
- */
- mw.rcfilters.dm.FilterGroup.prototype.getWhatsThis = function () {
- return this.whatsThis;
- };
-
- /**
- * Check whether this group has a 'what's this' message
- *
- * @return {boolean} This group has a what's this message
- */
- mw.rcfilters.dm.FilterGroup.prototype.hasWhatsThis = function () {
- return !!this.whatsThis.body;
- };
-
- /**
- * Get the conflicts associated with the entire group.
- * Conflict object is set up by filter name keys and conflict
- * definition. For example:
- * [
- * {
- * filterName: {
- * filter: filterName,
- * group: group1
- * }
- * },
- * {
- * filterName2: {
- * filter: filterName2,
- * group: group2
- * }
- * }
- * ]
- * @return {Object} Conflict definition
- */
- mw.rcfilters.dm.FilterGroup.prototype.getConflicts = function () {
- return this.conflicts;
- };
-
- /**
- * Set conflicts for this group. See #getConflicts for the expected
- * structure of the definition.
- *
- * @param {Object} conflicts Conflicts for this group
- */
- mw.rcfilters.dm.FilterGroup.prototype.setConflicts = function ( conflicts ) {
- this.conflicts = conflicts;
- };
-
- /**
- * Set conflicts for each filter item in the group based on the
- * given conflict map
- *
- * @param {Object} conflicts Object representing the conflict map,
- * keyed by the item name, where its value is an object for all its conflicts
- */
- mw.rcfilters.dm.FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
- this.getItems().forEach( function ( filterItem ) {
- if ( conflicts[ filterItem.getName() ] ) {
- filterItem.setConflicts( conflicts[ filterItem.getName() ] );
- }
- } );
- };
-
- /**
- * Check whether this item has a potential conflict with the given item
- *
- * This checks whether the given item is in the list of conflicts of
- * the current item, but makes no judgment about whether the conflict
- * is currently at play (either one of the items may not be selected)
- *
- * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
- * @return {boolean} This item has a conflict with the given item
- */
- mw.rcfilters.dm.FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
- return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
- };
-
- /**
- * Check whether there are any items selected
- *
- * @return {boolean} Any items in the group are selected
- */
- mw.rcfilters.dm.FilterGroup.prototype.areAnySelected = function () {
- return this.getItems().some( function ( filterItem ) {
- return filterItem.isSelected();
- } );
- };
-
- /**
- * Check whether all items selected
- *
- * @return {boolean} All items are selected
- */
- mw.rcfilters.dm.FilterGroup.prototype.areAllSelected = function () {
- var selected = [],
- unselected = [];
-
- this.getItems().forEach( function ( filterItem ) {
- if ( filterItem.isSelected() ) {
- selected.push( filterItem );
- } else {
- unselected.push( filterItem );
- }
- } );
-
- if ( unselected.length === 0 ) {
- return true;
- }
-
- // check if every unselected is a subset of a selected
- return unselected.every( function ( unselectedFilterItem ) {
- return selected.some( function ( selectedFilterItem ) {
- return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
- } );
- } );
- };
-
- /**
- * Get all selected items in this group
- *
- * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
- * @return {mw.rcfilters.dm.FilterItem[]} Selected items
- */
- mw.rcfilters.dm.FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
- var excludeName = ( excludeItem && excludeItem.getName() ) || '';
-
- return this.getItems().filter( function ( item ) {
- return item.getName() !== excludeName && item.isSelected();
- } );
- };
-
- /**
- * Check whether all selected items are in conflict with the given item
- *
- * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
- * @return {boolean} All selected items are in conflict with this item
- */
- mw.rcfilters.dm.FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
- var selectedItems = this.findSelectedItems( filterItem );
-
- return selectedItems.length > 0 &&
- (
- // The group as a whole is in conflict with this item
- this.existsInConflicts( filterItem ) ||
- // All selected items are in conflict individually
- selectedItems.every( function ( selectedFilter ) {
- return selectedFilter.existsInConflicts( filterItem );
- } )
- );
- };
-
- /**
- * Check whether any of the selected items are in conflict with the given item
- *
- * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
- * @return {boolean} Any of the selected items are in conflict with this item
- */
- mw.rcfilters.dm.FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
- var selectedItems = this.findSelectedItems( filterItem );
-
- return selectedItems.length > 0 && (
- // The group as a whole is in conflict with this item
- this.existsInConflicts( filterItem ) ||
- // Any selected items are in conflict individually
- selectedItems.some( function ( selectedFilter ) {
- return selectedFilter.existsInConflicts( filterItem );
- } )
- );
- };
-
- /**
- * Get the parameter representation from this group
- *
- * @param {Object} [filterRepresentation] An object defining the state
- * of the filters in this group, keyed by their name and current selected
- * state value.
- * @return {Object} Parameter representation
- */
- mw.rcfilters.dm.FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
- var values,
- areAnySelected = false,
- buildFromCurrentState = !filterRepresentation,
- defaultFilters = this.getDefaultFilters(),
- result = {},
- model = this,
- filterParamNames = {},
- getSelectedParameter = function ( filters ) {
- var item,
- selected = [];
-
- // Find if any are selected
- // eslint-disable-next-line jquery/no-each-util
- $.each( filters, function ( name, value ) {
- if ( value ) {
- selected.push( name );
- }
- } );
-
- item = model.getItemByName( selected[ 0 ] );
- return ( item && item.getParamName() ) || '';
- };
-
- filterRepresentation = filterRepresentation || {};
-
- // Create or complete the filterRepresentation definition
- this.getItems().forEach( function ( item ) {
- // Map filter names to their parameter names
- filterParamNames[ item.getName() ] = item.getParamName();
-
- if ( buildFromCurrentState ) {
- // This means we have not been given a filter representation
- // so we are building one based on current state
- filterRepresentation[ item.getName() ] = item.getValue();
- } else if ( filterRepresentation[ item.getName() ] === undefined ) {
- // We are given a filter representation, but we have to make
- // sure that we fill in the missing filters if there are any
- // we will assume they are all falsey
- if ( model.isSticky() ) {
- filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
- } else {
- filterRepresentation[ item.getName() ] = false;
- }
- }
-
- if ( filterRepresentation[ item.getName() ] ) {
- areAnySelected = true;
- }
- } );
-
- // Build result
- if (
- this.getType() === 'send_unselected_if_any' ||
- this.getType() === 'boolean' ||
- this.getType() === 'any_value'
- ) {
- // First, check if any of the items are selected at all.
- // If none is selected, we're treating it as if they are
- // all false
-
- // Go over the items and define the correct values
- // eslint-disable-next-line jquery/no-each-util
- $.each( filterRepresentation, function ( name, value ) {
- // We must store all parameter values as strings '0' or '1'
- if ( model.getType() === 'send_unselected_if_any' ) {
- result[ filterParamNames[ name ] ] = areAnySelected ?
- String( Number( !value ) ) :
- '0';
- } else if ( model.getType() === 'boolean' ) {
- // Representation is straight-forward and direct from
- // the parameter value to the filter state
- result[ filterParamNames[ name ] ] = String( Number( !!value ) );
- } else if ( model.getType() === 'any_value' ) {
- result[ filterParamNames[ name ] ] = value;
- }
- } );
- } else if ( this.getType() === 'string_options' ) {
- values = [];
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( filterRepresentation, function ( name, value ) {
- // Collect values
- if ( value ) {
- values.push( filterParamNames[ name ] );
- }
- } );
-
- result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
- 'all' : values.join( this.getSeparator() );
- } else if ( this.getType() === 'single_option' ) {
- result[ this.getName() ] = getSelectedParameter( filterRepresentation );
- }
-
- return result;
- };
-
- /**
- * Get the filter representation this group would provide
- * based on given parameter states.
- *
- * @param {Object} [paramRepresentation] An object defining a parameter
- * state to translate the filter state from. If not given, an object
- * representing all filters as falsey is returned; same as if the parameter
- * given were an empty object, or had some of the filters missing.
- * @return {Object} Filter representation
- */
- mw.rcfilters.dm.FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
- var areAnySelected, paramValues, item, currentValue,
- oneWasSelected = false,
- defaultParams = this.getDefaultParams(),
- expandedParams = $.extend( true, {}, paramRepresentation ),
- model = this,
- paramToFilterMap = {},
- result = {};
-
- if ( this.isSticky() ) {
- // If the group is sticky, check if all parameters are represented
- // and for those that aren't represented, add them with their default
- // values
- paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
- }
-
- paramRepresentation = paramRepresentation || {};
- if (
- this.getType() === 'send_unselected_if_any' ||
- this.getType() === 'boolean' ||
- this.getType() === 'any_value'
- ) {
- // Go over param representation; map and check for selections
- this.getItems().forEach( function ( filterItem ) {
- var paramName = filterItem.getParamName();
-
- expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
- paramToFilterMap[ paramName ] = filterItem;
-
- if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
- areAnySelected = true;
- }
- } );
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( expandedParams, function ( paramName, paramValue ) {
- var filterItem = paramToFilterMap[ paramName ];
-
- if ( model.getType() === 'send_unselected_if_any' ) {
- // Flip the definition between the parameter
- // state and the filter state
- // This is what the 'toggleSelected' value of the filter is
- result[ filterItem.getName() ] = areAnySelected ?
- !Number( paramValue ) :
- // Otherwise, there are no selected items in the
- // group, which means the state is false
- false;
- } else if ( model.getType() === 'boolean' ) {
- // Straight-forward definition of state
- result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
- } else if ( model.getType() === 'any_value' ) {
- result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
- }
- } );
- } else if ( this.getType() === 'string_options' ) {
- currentValue = paramRepresentation[ this.getName() ] || '';
-
- // Normalize the given parameter values
- paramValues = mw.rcfilters.utils.normalizeParamOptions(
- // Given
- currentValue.split(
- this.getSeparator()
- ),
- // Allowed values
- this.getItems().map( function ( filterItem ) {
- return filterItem.getParamName();
- } )
- );
- // Translate the parameter values into a filter selection state
- this.getItems().forEach( function ( filterItem ) {
- // All true (either because all values are written or the term 'all' is written)
- // is the same as all filters set to true
- result[ filterItem.getName() ] = (
- // If it is the word 'all'
- paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
- // All values are written
- paramValues.length === model.getItemCount()
- ) ?
- true :
- // Otherwise, the filter is selected only if it appears in the parameter values
- paramValues.indexOf( filterItem.getParamName() ) > -1;
- } );
- } else if ( this.getType() === 'single_option' ) {
- // There is parameter that fits a single filter and if not, get the default
- this.getItems().forEach( function ( filterItem ) {
- var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];
-
- result[ filterItem.getName() ] = selected;
- oneWasSelected = oneWasSelected || selected;
- } );
- }
-
- // Go over result and make sure all filters are represented.
- // If any filters are missing, they will get a falsey value
- this.getItems().forEach( function ( filterItem ) {
- if ( result[ filterItem.getName() ] === undefined ) {
- result[ filterItem.getName() ] = this.getFalsyValue();
- }
- }.bind( this ) );
-
- // 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
- ) {
- item = this.getItems()[ 0 ];
- if ( defaultParams[ this.getName() ] ) {
- item = this.getItemByParamName( defaultParams[ this.getName() ] );
- }
-
- result[ item.getName() ] = true;
- }
-
- return result;
- };
-
- /**
- * @return {*} The appropriate falsy value for this group type
- */
- mw.rcfilters.dm.FilterGroup.prototype.getFalsyValue = function () {
- return this.getType() === 'any_value' ? '' : false;
- };
-
- /**
- * Get current selected state of all filter items in this group
- *
- * @return {Object} Selected state
- */
- mw.rcfilters.dm.FilterGroup.prototype.getSelectedState = function () {
- var state = {};
-
- this.getItems().forEach( function ( filterItem ) {
- state[ filterItem.getName() ] = filterItem.getValue();
- } );
-
- return state;
- };
-
- /**
- * Get item by its filter name
- *
- * @param {string} filterName Filter name
- * @return {mw.rcfilters.dm.FilterItem} Filter item
- */
- mw.rcfilters.dm.FilterGroup.prototype.getItemByName = function ( filterName ) {
- return this.getItems().filter( function ( item ) {
- return item.getName() === filterName;
- } )[ 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() === String( paramName ) );
- } );
- };
-
- /**
- * Get item by its parameter name
- *
- * @param {string} paramName Parameter name
- * @return {mw.rcfilters.dm.FilterItem} Filter item
- */
- mw.rcfilters.dm.FilterGroup.prototype.getItemByParamName = function ( paramName ) {
- return this.getItems().filter( function ( item ) {
- return item.getParamName() === String( paramName );
- } )[ 0 ];
- };
-
- /**
- * Get group type
- *
- * @return {string} Group type
- */
- mw.rcfilters.dm.FilterGroup.prototype.getType = function () {
- return this.type;
- };
-
- /**
- * Check whether this group is represented by a single parameter
- * or whether each item is its own parameter
- *
- * @return {boolean} This group is a single parameter
- */
- mw.rcfilters.dm.FilterGroup.prototype.isPerGroupRequestParameter = function () {
- return (
- this.getType() === 'string_options' ||
- this.getType() === 'single_option'
- );
- };
-
- /**
- * Get display group
- *
- * @return {string} Display group
- */
- mw.rcfilters.dm.FilterGroup.prototype.getView = function () {
- return this.view;
- };
-
- /**
- * Get the prefix used for the filter names inside this group.
- *
- * @param {string} [name] Filter name to prefix
- * @return {string} Group prefix
- */
- mw.rcfilters.dm.FilterGroup.prototype.getNamePrefix = function () {
- return this.getName() + '__';
- };
-
- /**
- * Get a filter name with the prefix used for the filter names inside this group.
- *
- * @param {string} name Filter name to prefix
- * @return {string} Group prefix
- */
- mw.rcfilters.dm.FilterGroup.prototype.getPrefixedName = function ( name ) {
- return this.getNamePrefix() + name;
- };
-
- /**
- * Get group's title
- *
- * @return {string} Title
- */
- mw.rcfilters.dm.FilterGroup.prototype.getTitle = function () {
- return this.title;
- };
-
- /**
- * Get group's values separator
- *
- * @return {string} Values separator
- */
- mw.rcfilters.dm.FilterGroup.prototype.getSeparator = function () {
- return this.separator;
- };
-
- /**
- * Check whether the group is defined as full coverage
- *
- * @return {boolean} Group is full coverage
- */
- mw.rcfilters.dm.FilterGroup.prototype.isFullCoverage = function () {
- return this.fullCoverage;
- };
-
- /**
- * Check whether the group is defined as sticky default
- *
- * @return {boolean} Group is sticky default
- */
- mw.rcfilters.dm.FilterGroup.prototype.isSticky = function () {
- return this.sticky;
- };
-
- /**
- * Normalize a value given to this group. This is mostly for correcting
- * arbitrary values for 'single option' groups, given by the user settings
- * or the URL that can go outside the limits that are allowed.
- *
- * @param {string} value Given value
- * @return {string} Corrected value
- */
- mw.rcfilters.dm.FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
- if (
- this.getType() === 'single_option' &&
- this.isAllowArbitrary()
- ) {
- if (
- this.getMaxValue() !== null &&
- value > this.getMaxValue()
- ) {
- // Change the value to the actual max value
- return String( this.getMaxValue() );
- } else if (
- this.getMinValue() !== null &&
- value < this.getMinValue()
- ) {
- // Change the value to the actual min value
- return String( this.getMinValue() );
- }
- }
-
- return value;
- };
-
- /**
- * Toggle the visibility of this group
- *
- * @param {boolean} [isVisible] Item is visible
- */
- mw.rcfilters.dm.FilterGroup.prototype.toggleVisible = function ( isVisible ) {
- isVisible = isVisible === undefined ? !this.visible : isVisible;
-
- if ( this.visible !== isVisible ) {
- this.visible = isVisible;
- this.emit( 'update' );
- }
- };
-
- /**
- * Check whether the group is visible
- *
- * @return {boolean} Group is visible
- */
- mw.rcfilters.dm.FilterGroup.prototype.isVisible = function () {
- return this.visible;
- };
-
- /**
- * Set the visibility of the items under this group by the given items array
- *
- * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
- */
- mw.rcfilters.dm.FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
- this.getItems().forEach( function ( itemModel ) {
- itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
- } );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Filter item model
- *
- * @extends mw.rcfilters.dm.ItemModel
- *
- * @constructor
- * @param {string} param Filter param name
- * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
- * @param {Object} config Configuration object
- * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
- * selected, makes inactive.
- * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
- * @cfg {Object} [conflicts] Defines the conflicts for this filter
- * @cfg {boolean} [visible=true] The visibility of the group
- */
- mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
- config = config || {};
-
- this.groupModel = groupModel;
-
- // Parent
- mw.rcfilters.dm.FilterItem.parent.call( this, param, $.extend( {
- namePrefix: this.groupModel.getNamePrefix()
- }, config ) );
- // Mixin constructor
- OO.EventEmitter.call( this );
-
- // Interaction definitions
- this.subset = config.subset || [];
- this.conflicts = config.conflicts || {};
- this.superset = [];
- this.visible = config.visible === undefined ? true : !!config.visible;
-
- // Interaction states
- this.included = false;
- this.conflicted = false;
- this.fullyCovered = false;
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.dm.FilterItem, mw.rcfilters.dm.ItemModel );
-
- /* Methods */
-
- /**
- * Return the representation of the state of this item.
- *
- * @return {Object} State of the object
- */
- mw.rcfilters.dm.FilterItem.prototype.getState = function () {
- return {
- selected: this.isSelected(),
- included: this.isIncluded(),
- conflicted: this.isConflicted(),
- fullyCovered: this.isFullyCovered()
- };
- };
-
- /**
- * Get the message for the display area for the currently active conflict
- *
- * @private
- * @return {string} Conflict result message key
- */
- mw.rcfilters.dm.FilterItem.prototype.getCurrentConflictResultMessage = function () {
- var details = {};
-
- // First look in filter's own conflicts
- details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
- if ( !details.message ) {
- // Fall back onto conflicts in the group
- details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
- }
-
- return details.message;
- };
-
- /**
- * Get the details of the active conflict on this filter
- *
- * @private
- * @param {Object} conflicts Conflicts to examine
- * @param {string} [key='contextDescription'] Message key
- * @return {Object} Object with conflict message and conflict items
- * @return {string} return.message Conflict message
- * @return {string[]} return.names Conflicting item labels
- */
- mw.rcfilters.dm.FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
- var group,
- conflictMessage = '',
- itemLabels = [];
-
- key = key || 'contextDescription';
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( conflicts, function ( filterName, conflict ) {
- if ( !conflict.item.isSelected() ) {
- return;
- }
-
- if ( !conflictMessage ) {
- conflictMessage = conflict[ key ];
- group = conflict.group;
- }
-
- if ( group === conflict.group ) {
- itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
- }
- } );
-
- return {
- message: conflictMessage,
- names: itemLabels
- };
-
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.dm.FilterItem.prototype.getStateMessage = function () {
- var messageKey, details, superset,
- affectingItems = [];
-
- if ( this.isSelected() ) {
- if ( this.isConflicted() ) {
- // First look in filter's own conflicts
- details = this.getConflictDetails( this.getOwnConflicts() );
- if ( !details.message ) {
- // Fall back onto conflicts in the group
- details = this.getConflictDetails( this.getGroupModel().getConflicts() );
- }
-
- messageKey = details.message;
- affectingItems = details.names;
- } else if ( this.isIncluded() && !this.isHighlighted() ) {
- // We only show the 'no effect' full-coverage message
- // if the item is also not highlighted. See T161273
- superset = this.getSuperset();
- // For this message we need to collect the affecting superset
- affectingItems = this.getGroupModel().findSelectedItems( this )
- .filter( function ( item ) {
- return superset.indexOf( item.getName() ) !== -1;
- } )
- .map( function ( item ) {
- return mw.msg( 'quotation-marks', item.getLabel() );
- } );
-
- messageKey = 'rcfilters-state-message-subset';
- } else if ( this.isFullyCovered() && !this.isHighlighted() ) {
- affectingItems = this.getGroupModel().findSelectedItems( this )
- .map( function ( item ) {
- return mw.msg( 'quotation-marks', item.getLabel() );
- } );
-
- messageKey = 'rcfilters-state-message-fullcoverage';
- }
- }
-
- if ( messageKey ) {
- // Build message
- return mw.msg(
- messageKey,
- mw.language.listToText( affectingItems ),
- affectingItems.length
- );
- }
-
- // Display description
- return this.getDescription();
- };
-
- /**
- * Get the model of the group this filter belongs to
- *
- * @return {mw.rcfilters.dm.FilterGroup} Filter group model
- */
- mw.rcfilters.dm.FilterItem.prototype.getGroupModel = function () {
- return this.groupModel;
- };
-
- /**
- * Get the group name this filter belongs to
- *
- * @return {string} Filter group name
- */
- mw.rcfilters.dm.FilterItem.prototype.getGroupName = function () {
- return this.groupModel.getName();
- };
-
- /**
- * Get filter subset
- * This is a list of filter names that are defined to be included
- * when this filter is selected.
- *
- * @return {string[]} Filter subset
- */
- mw.rcfilters.dm.FilterItem.prototype.getSubset = function () {
- return this.subset;
- };
-
- /**
- * Get filter superset
- * This is a generated list of filters that define this filter
- * to be included when either of them is selected.
- *
- * @return {string[]} Filter superset
- */
- mw.rcfilters.dm.FilterItem.prototype.getSuperset = function () {
- return this.superset;
- };
-
- /**
- * Check whether the filter is currently in a conflict state
- *
- * @return {boolean} Filter is in conflict state
- */
- mw.rcfilters.dm.FilterItem.prototype.isConflicted = function () {
- return this.conflicted;
- };
-
- /**
- * Check whether the filter is currently in an already included subset
- *
- * @return {boolean} Filter is in an already-included subset
- */
- mw.rcfilters.dm.FilterItem.prototype.isIncluded = function () {
- return this.included;
- };
-
- /**
- * Check whether the filter is currently fully covered
- *
- * @return {boolean} Filter is in fully-covered state
- */
- mw.rcfilters.dm.FilterItem.prototype.isFullyCovered = function () {
- return this.fullyCovered;
- };
-
- /**
- * Get all conflicts associated with this filter or its group
- *
- * Conflict object is set up by filter name keys and conflict
- * definition. For example:
- *
- * {
- * filterName: {
- * filter: filterName,
- * group: group1,
- * label: itemLabel,
- * item: itemModel
- * }
- * filterName2: {
- * filter: filterName2,
- * group: group2
- * label: itemLabel2,
- * item: itemModel2
- * }
- * }
- *
- * @return {Object} Filter conflicts
- */
- mw.rcfilters.dm.FilterItem.prototype.getConflicts = function () {
- return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
- };
-
- /**
- * Get the conflicts associated with this filter
- *
- * @return {Object} Filter conflicts
- */
- mw.rcfilters.dm.FilterItem.prototype.getOwnConflicts = function () {
- return this.conflicts;
- };
-
- /**
- * Set conflicts for this filter. See #getConflicts for the expected
- * structure of the definition.
- *
- * @param {Object} conflicts Conflicts for this filter
- */
- mw.rcfilters.dm.FilterItem.prototype.setConflicts = function ( conflicts ) {
- this.conflicts = conflicts || {};
- };
-
- /**
- * Set filter superset
- *
- * @param {string[]} superset Filter superset
- */
- mw.rcfilters.dm.FilterItem.prototype.setSuperset = function ( superset ) {
- this.superset = superset || [];
- };
-
- /**
- * Set filter subset
- *
- * @param {string[]} subset Filter subset
- */
- mw.rcfilters.dm.FilterItem.prototype.setSubset = function ( subset ) {
- this.subset = subset || [];
- };
-
- /**
- * Check whether a filter exists in the subset list for this filter
- *
- * @param {string} filterName Filter name
- * @return {boolean} Filter name is in the subset list
- */
- mw.rcfilters.dm.FilterItem.prototype.existsInSubset = function ( filterName ) {
- return this.subset.indexOf( filterName ) > -1;
- };
-
- /**
- * Check whether this item has a potential conflict with the given item
- *
- * This checks whether the given item is in the list of conflicts of
- * the current item, but makes no judgment about whether the conflict
- * is currently at play (either one of the items may not be selected)
- *
- * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
- * @return {boolean} This item has a conflict with the given item
- */
- mw.rcfilters.dm.FilterItem.prototype.existsInConflicts = function ( filterItem ) {
- return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
- };
-
- /**
- * Set the state of this filter as being conflicted
- * (This means any filters in its conflicts are selected)
- *
- * @param {boolean} [conflicted] Filter is in conflict state
- * @fires update
- */
- mw.rcfilters.dm.FilterItem.prototype.toggleConflicted = function ( conflicted ) {
- conflicted = conflicted === undefined ? !this.conflicted : conflicted;
-
- if ( this.conflicted !== conflicted ) {
- this.conflicted = conflicted;
- this.emit( 'update' );
- }
- };
-
- /**
- * Set the state of this filter as being already included
- * (This means any filters in its superset are selected)
- *
- * @param {boolean} [included] Filter is included as part of a subset
- * @fires update
- */
- mw.rcfilters.dm.FilterItem.prototype.toggleIncluded = function ( included ) {
- included = included === undefined ? !this.included : included;
-
- if ( this.included !== included ) {
- this.included = included;
- this.emit( 'update' );
- }
- };
-
- /**
- * Toggle the fully covered state of the item
- *
- * @param {boolean} [isFullyCovered] Filter is fully covered
- * @fires update
- */
- mw.rcfilters.dm.FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
- isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
-
- if ( this.fullyCovered !== isFullyCovered ) {
- this.fullyCovered = isFullyCovered;
- this.emit( 'update' );
- }
- };
-
- /**
- * Toggle the visibility of this item
- *
- * @param {boolean} [isVisible] Item is visible
- */
- mw.rcfilters.dm.FilterItem.prototype.toggleVisible = function ( isVisible ) {
- isVisible = isVisible === undefined ? !this.visible : !!isVisible;
-
- if ( this.visible !== isVisible ) {
- this.visible = isVisible;
- this.emit( 'update' );
- }
- };
-
- /**
- * Check whether the item is visible
- *
- * @return {boolean} Item is visible
- */
- mw.rcfilters.dm.FilterItem.prototype.isVisible = function () {
- return this.visible;
- };
-
-}() );
+++ /dev/null
-( function () {
- /**
- * View model for the filters selection and display
- *
- * @mixins OO.EventEmitter
- * @mixins OO.EmitterList
- *
- * @constructor
- */
- mw.rcfilters.dm.FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
- // Mixin constructor
- OO.EventEmitter.call( this );
- OO.EmitterList.call( this );
-
- this.groups = {};
- this.defaultParams = {};
- this.highlightEnabled = false;
- this.parameterMap = {};
- this.emptyParameterState = null;
-
- this.views = {};
- this.currentView = 'default';
- this.searchQuery = null;
-
- // Events
- this.aggregate( { update: 'filterItemUpdate' } );
- this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
- };
-
- /* Initialization */
- OO.initClass( mw.rcfilters.dm.FiltersViewModel );
- OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EventEmitter );
- OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EmitterList );
-
- /* Events */
-
- /**
- * @event initialize
- *
- * Filter list is initialized
- */
-
- /**
- * @event update
- *
- * Model has been updated
- */
-
- /**
- * @event itemUpdate
- * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
- *
- * Filter item has changed
- */
-
- /**
- * @event highlightChange
- * @param {boolean} Highlight feature is enabled
- *
- * Highlight feature has been toggled enabled or disabled
- */
-
- /* Methods */
-
- /**
- * Re-assess the states of filter items based on the interactions between them
- *
- * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
- * method will go over the state of all items
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
- var allSelected,
- model = this,
- iterationItems = item !== undefined ? [ item ] : this.getItems();
-
- iterationItems.forEach( function ( checkedItem ) {
- var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
- groupModel = checkedItem.getGroupModel();
-
- // Check for subsets (included filters) plus the item itself:
- allCheckedItems.forEach( function ( filterItemName ) {
- var itemInSubset = model.getItemByName( filterItemName );
-
- itemInSubset.toggleIncluded(
- // If any of itemInSubset's supersets are selected, this item
- // is included
- itemInSubset.getSuperset().some( function ( supersetName ) {
- return ( model.getItemByName( supersetName ).isSelected() );
- } )
- );
- } );
-
- // Update coverage for the changed group
- if ( groupModel.isFullCoverage() ) {
- allSelected = groupModel.areAllSelected();
- groupModel.getItems().forEach( function ( filterItem ) {
- filterItem.toggleFullyCovered( allSelected );
- } );
- }
- } );
-
- // Check for conflicts
- // In this case, we must go over all items, since
- // conflicts are bidirectional and depend not only on
- // individual items, but also on the selected states of
- // the groups they're in.
- this.getItems().forEach( function ( filterItem ) {
- var inConflict = false,
- filterItemGroup = filterItem.getGroupModel();
-
- // For each item, see if that item is still conflicting
- // eslint-disable-next-line jquery/no-each-util
- $.each( model.groups, function ( groupName, groupModel ) {
- if ( filterItem.getGroupName() === groupName ) {
- // Check inside the group
- inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
- } else {
- // According to the spec, if two items conflict from two different
- // groups, the conflict only lasts if the groups **only have selected
- // items that are conflicting**. If a group has selected items that
- // are conflicting and non-conflicting, the scope of the result has
- // expanded enough to completely remove the conflict.
-
- // For example, see two groups with conflicts:
- // userExpLevel: [
- // {
- // name: 'experienced',
- // conflicts: [ 'unregistered' ]
- // }
- // ],
- // registration: [
- // {
- // name: 'registered',
- // },
- // {
- // name: 'unregistered',
- // }
- // ]
- // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
- // because, inherently, 'experienced' filter only includes registered users, and so
- // both filters are in conflict with one another.
- // However, the minute we select 'registered', the scope of our results
- // has expanded to no longer have a conflict with 'experienced' filter, and
- // so the conflict is removed.
-
- // In our case, we need to check if the entire group conflicts with
- // the entire item's group, so we follow the above spec
- inConflict = (
- // The foreign group is in conflict with this item
- groupModel.areAllSelectedInConflictWith( filterItem ) &&
- // Every selected member of the item's own group is also
- // in conflict with the other group
- filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
- return groupModel.areAllSelectedInConflictWith( otherGroupItem );
- } )
- );
- }
-
- // If we're in conflict, this will return 'false' which
- // will break the loop. Otherwise, we're not in conflict
- // and the loop continues
- return !inConflict;
- } );
-
- // Toggle the item state
- filterItem.toggleConflicted( inConflict );
- } );
- };
-
- /**
- * Get whether the model has any conflict in its items
- *
- * @return {boolean} There is a conflict
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.hasConflict = function () {
- return this.getItems().some( function ( filterItem ) {
- return filterItem.isSelected() && filterItem.isConflicted();
- } );
- };
-
- /**
- * Get the first item with a current conflict
- *
- * @return {mw.rcfilters.dm.FilterItem} Conflicted item
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getFirstConflictedItem = function () {
- var conflictedItem;
-
- this.getItems().forEach( function ( filterItem ) {
- if ( filterItem.isSelected() && filterItem.isConflicted() ) {
- conflictedItem = filterItem;
- return false;
- }
- } );
-
- return conflictedItem;
- };
-
- /**
- * Set filters and preserve a group relationship based on
- * the definition given by an object
- *
- * @param {Array} filterGroups Filters definition
- * @param {Object} [views] Extra views definition
- * Expected in the following format:
- * {
- * namespaces: {
- * label: 'namespaces', // Message key
- * trigger: ':',
- * groups: [
- * {
- * // Group info
- * name: 'namespaces' // Parameter name
- * title: 'namespaces' // Message key
- * type: 'string_options',
- * separator: ';',
- * labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
- * fullCoverage: true
- * items: []
- * }
- * ]
- * }
- * }
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
- var filterConflictResult, groupConflictResult,
- allViews = {},
- model = this,
- items = [],
- groupConflictMap = {},
- filterConflictMap = {},
- /*!
- * Expand a conflict definition from group name to
- * the list of all included filters in that group.
- * We do this so that the direct relationship in the
- * models are consistently item->items rather than
- * mixing item->group with item->item.
- *
- * @param {Object} obj Conflict definition
- * @return {Object} Expanded conflict definition
- */
- expandConflictDefinitions = function ( obj ) {
- var result = {};
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( obj, function ( key, conflicts ) {
- var filterName,
- adjustedConflicts = {};
-
- conflicts.forEach( function ( conflict ) {
- var filter;
-
- if ( conflict.filter ) {
- filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
- filter = model.getItemByName( filterName );
-
- // Rename
- adjustedConflicts[ filterName ] = $.extend(
- {},
- conflict,
- {
- filter: filterName,
- item: filter
- }
- );
- } else {
- // This conflict is for an entire group. Split it up to
- // represent each filter
-
- // Get the relevant group items
- model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
- // Rebuild the conflict
- adjustedConflicts[ groupItem.getName() ] = $.extend(
- {},
- conflict,
- {
- filter: groupItem.getName(),
- item: groupItem
- }
- );
- } );
- }
- } );
-
- result[ key ] = adjustedConflicts;
- } );
-
- return result;
- };
-
- // Reset
- this.clearItems();
- this.groups = {};
- this.views = {};
-
- // Clone
- filterGroups = OO.copy( filterGroups );
-
- // Normalize definition from the server
- filterGroups.forEach( function ( data ) {
- var i;
- // What's this information needs to be normalized
- data.whatsThis = {
- body: data.whatsThisBody,
- header: data.whatsThisHeader,
- linkText: data.whatsThisLinkText,
- url: data.whatsThisUrl
- };
-
- // Title is a msg-key
- data.title = data.title ? mw.msg( data.title ) : data.name;
-
- // Filters are given to us with msg-keys, we need
- // to translate those before we hand them off
- for ( i = 0; i < data.filters.length; i++ ) {
- data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
- data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
- }
- } );
-
- // Collect views
- allViews = $.extend( true, {
- default: {
- title: mw.msg( 'rcfilters-filterlist-title' ),
- groups: filterGroups
- }
- }, views );
-
- // Go over all views
- // eslint-disable-next-line jquery/no-each-util
- $.each( allViews, function ( viewName, viewData ) {
- // Define the view
- model.views[ viewName ] = {
- name: viewData.name,
- title: viewData.title,
- trigger: viewData.trigger
- };
-
- // Go over groups
- viewData.groups.forEach( function ( groupData ) {
- var group = groupData.name;
-
- if ( !model.groups[ group ] ) {
- model.groups[ group ] = new mw.rcfilters.dm.FilterGroup(
- group,
- $.extend( true, {}, groupData, { view: viewName } )
- );
- }
-
- model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
- items = items.concat( model.groups[ group ].getItems() );
-
- // Prepare conflicts
- if ( groupData.conflicts ) {
- // Group conflicts
- groupConflictMap[ group ] = groupData.conflicts;
- }
-
- groupData.filters.forEach( function ( itemData ) {
- var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
- // Filter conflicts
- if ( itemData.conflicts ) {
- filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
- }
- } );
- } );
- } );
-
- // Add item references to the model, for lookup
- this.addItems( items );
-
- // Expand conflicts
- groupConflictResult = expandConflictDefinitions( groupConflictMap );
- filterConflictResult = expandConflictDefinitions( filterConflictMap );
-
- // Set conflicts for groups
- // eslint-disable-next-line jquery/no-each-util
- $.each( groupConflictResult, function ( group, conflicts ) {
- model.groups[ group ].setConflicts( conflicts );
- } );
-
- // Set conflicts for items
- // eslint-disable-next-line jquery/no-each-util
- $.each( filterConflictResult, function ( filterName, conflicts ) {
- var filterItem = model.getItemByName( filterName );
- // set conflicts for items in the group
- filterItem.setConflicts( conflicts );
- } );
-
- // Create a map between known parameters and their models
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.groups, function ( group, groupModel ) {
- if (
- groupModel.getType() === 'send_unselected_if_any' ||
- groupModel.getType() === 'boolean' ||
- groupModel.getType() === 'any_value'
- ) {
- // Individual filters
- groupModel.getItems().forEach( function ( filterItem ) {
- model.parameterMap[ filterItem.getParamName() ] = filterItem;
- } );
- } else if (
- groupModel.getType() === 'string_options' ||
- groupModel.getType() === 'single_option'
- ) {
- // Group
- model.parameterMap[ groupModel.getName() ] = groupModel;
- }
- } );
-
- this.setSearch( '' );
-
- this.updateHighlightedState();
-
- // Finish initialization
- this.emit( 'initialize' );
- };
-
- /**
- * Update filter view model state based on a parameter object
- *
- * @param {Object} params Parameters object
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
- var filtersValue;
- // For arbitrary numeric single_option values make sure the values
- // are normalized to fit within the limits
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
- params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
- } );
-
- // Update filter values
- filtersValue = this.getFiltersFromParameters( params );
- Object.keys( filtersValue ).forEach( function ( filterName ) {
- this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
- }.bind( this ) );
-
- // Update highlight state
- this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
- var color = params[ filterItem.getName() + '_color' ];
- if ( color ) {
- filterItem.setHighlightColor( color );
- } else {
- filterItem.clearHighlightColor();
- }
- } );
- this.updateHighlightedState();
-
- // Check all filter interactions
- this.reassessFilterInteractions();
- };
-
- /**
- * Get a representation of an empty (falsey) parameter state
- *
- * @return {Object} Empty parameter state
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyParameterState = function () {
- if ( !this.emptyParameterState ) {
- this.emptyParameterState = $.extend(
- true,
- {},
- this.getParametersFromFilters( {} ),
- this.getEmptyHighlightParameters()
- );
- }
- return this.emptyParameterState;
- };
-
- /**
- * Get a representation of only the non-falsey parameters
- *
- * @param {Object} [parameters] A given parameter state to minimize. If not given the current
- * state of the system will be used.
- * @return {Object} Empty parameter state
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
- var result = {};
-
- parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
-
- // Params
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.getEmptyParameterState(), function ( param, value ) {
- if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
- result[ param ] = parameters[ param ];
- }
- } );
-
- // Highlights
- Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
- if ( parameters[ param ] ) {
- // If a highlight parameter is not undefined and not null
- // add it to the result
- result[ param ] = parameters[ param ];
- }
- } );
-
- return result;
- };
-
- /**
- * Get a representation of the full parameter list, including all base values
- *
- * @return {Object} Full parameter representation
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
- return $.extend(
- true,
- {},
- this.getEmptyParameterState(),
- this.getCurrentParameterState()
- );
- };
-
- /**
- * Get a parameter representation of the current state of the model
- *
- * @param {boolean} [removeStickyParams] Remove sticky filters from final result
- * @return {Object} Parameter representation of the current state of the model
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
- var state = this.getMinimizedParamRepresentation( $.extend(
- true,
- {},
- this.getParametersFromFilters( this.getSelectedState() ),
- this.getHighlightParameters()
- ) );
-
- if ( removeStickyParams ) {
- state = this.removeStickyParams( state );
- }
-
- return state;
- };
-
- /**
- * Delete sticky parameters from given object.
- *
- * @param {Object} paramState Parameter state
- * @return {Object} Parameter state without sticky parameters
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
- this.getStickyParams().forEach( function ( paramName ) {
- delete paramState[ paramName ];
- } );
-
- return paramState;
- };
-
- /**
- * Turn the highlight feature on or off
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.updateHighlightedState = function () {
- this.toggleHighlight( this.getHighlightedItems().length > 0 );
- };
-
- /**
- * Get the object that defines groups by their name.
- *
- * @return {Object} Filter groups
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
- return this.groups;
- };
-
- /**
- * Get the object that defines groups that match a certain view by their name.
- *
- * @param {string} [view] Requested view. If not given, uses current view
- * @return {Object} Filter groups matching a display group
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
- var result = {};
-
- view = view || this.getCurrentView();
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.groups, function ( groupName, groupModel ) {
- if ( groupModel.getView() === view ) {
- result[ groupName ] = groupModel;
- }
- } );
-
- return result;
- };
-
- /**
- * Get an array of filters matching the given display group.
- *
- * @param {string} [view] Requested view. If not given, uses current view
- * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersByView = function ( view ) {
- var groups,
- result = [];
-
- view = view || this.getCurrentView();
-
- groups = this.getFilterGroupsByView( view );
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( groups, function ( groupName, groupModel ) {
- result = result.concat( groupModel.getItems() );
- } );
-
- return result;
- };
-
- /**
- * Get the trigger for the requested view.
- *
- * @param {string} view View name
- * @return {string} View trigger, if exists
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getViewTrigger = function ( view ) {
- return ( this.views[ view ] && this.views[ view ].trigger ) || '';
- };
-
- /**
- * Get the value of a specific parameter
- *
- * @param {string} name Parameter name
- * @return {number|string} Parameter value
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
- return this.parameters[ name ];
- };
-
- /**
- * Get the current selected state of the filters
- *
- * @param {boolean} [onlySelected] return an object containing only the filters with a value
- * @return {Object} Filters selected state
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
- var i,
- items = this.getItems(),
- result = {};
-
- for ( i = 0; i < items.length; i++ ) {
- if ( !onlySelected || items[ i ].getValue() ) {
- result[ items[ i ].getName() ] = items[ i ].getValue();
- }
- }
-
- return result;
- };
-
- /**
- * Get the current full state of the filters
- *
- * @return {Object} Filters full state
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
- var i,
- items = this.getItems(),
- result = {};
-
- for ( i = 0; i < items.length; i++ ) {
- result[ items[ i ].getName() ] = {
- selected: items[ i ].isSelected(),
- conflicted: items[ i ].isConflicted(),
- included: items[ i ].isIncluded()
- };
- }
-
- return result;
- };
-
- /**
- * Get an object representing default parameters state
- *
- * @return {Object} Default parameter values
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
- var result = {};
-
- // Get default filter state
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.groups, function ( name, model ) {
- if ( !model.isSticky() ) {
- $.extend( true, result, model.getDefaultParams() );
- }
- } );
-
- return result;
- };
-
- /**
- * Get a parameter representation of all sticky parameters
- *
- * @return {Object} Sticky parameter values
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParams = function () {
- var result = [];
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.groups, function ( name, model ) {
- if ( model.isSticky() ) {
- if ( model.isPerGroupRequestParameter() ) {
- result.push( name );
- } else {
- // Each filter is its own param
- result = result.concat( model.getItems().map( function ( filterItem ) {
- return filterItem.getParamName();
- } ) );
- }
- }
- } );
-
- return result;
- };
-
- /**
- * Get a parameter representation of all sticky parameters
- *
- * @return {Object} Sticky parameter values
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParamsValues = function () {
- var result = {};
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.groups, function ( name, model ) {
- if ( model.isSticky() ) {
- $.extend( true, result, model.getParamRepresentation() );
- }
- } );
-
- return result;
- };
-
- /**
- * Analyze the groups and their filters and output an object representing
- * the state of the parameters they represent.
- *
- * @param {Object} [filterDefinition] An object defining the filter values,
- * keyed by filter names.
- * @return {Object} Parameter state object
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
- var groupItemDefinition,
- result = {},
- groupItems = this.getFilterGroups();
-
- if ( filterDefinition ) {
- groupItemDefinition = {};
- // Filter definition is "flat", but in effect
- // each group needs to tell us its result based
- // on the values in it. We need to split this list
- // back into groupings so we can "feed" it to the
- // loop below, and we need to expand it so it includes
- // all filters (set to false)
- this.getItems().forEach( function ( filterItem ) {
- groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
- groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
- } );
- }
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( groupItems, function ( group, model ) {
- $.extend(
- result,
- model.getParamRepresentation(
- groupItemDefinition ?
- groupItemDefinition[ group ] : null
- )
- );
- } );
-
- return result;
- };
-
- /**
- * This is the opposite of the #getParametersFromFilters method; this goes over
- * the given parameters and translates into a selected/unselected value in the filters.
- *
- * @param {Object} params Parameters query object
- * @return {Object} Filter state object
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
- var groupMap = {},
- model = this,
- result = {};
-
- // Go over the given parameters, break apart to groupings
- // The resulting object represents the group with its parameter
- // values. For example:
- // {
- // group1: {
- // param1: "1",
- // param2: "0",
- // param3: "1"
- // },
- // group2: "param4|param5"
- // }
- // eslint-disable-next-line jquery/no-each-util
- $.each( params, function ( paramName, paramValue ) {
- var groupName,
- itemOrGroup = model.parameterMap[ paramName ];
-
- if ( itemOrGroup ) {
- groupName = itemOrGroup instanceof mw.rcfilters.dm.FilterItem ?
- itemOrGroup.getGroupName() : itemOrGroup.getName();
-
- groupMap[ groupName ] = groupMap[ groupName ] || {};
- groupMap[ groupName ][ paramName ] = paramValue;
- }
- } );
-
- // Go over all groups, so we make sure we get the complete output
- // even if the parameters don't include a certain group
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.groups, function ( groupName, groupModel ) {
- result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
- } );
-
- return result;
- };
-
- /**
- * Get the highlight parameters based on current filter configuration
- *
- * @return {Object} Object where keys are `<filter name>_color` and values
- * are the selected highlight colors.
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () {
- var highlightEnabled = this.isHighlightEnabled(),
- result = {};
-
- this.getItems().forEach( function ( filterItem ) {
- if ( filterItem.isHighlightSupported() ) {
- result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
- filterItem.getHighlightColor() :
- null;
- }
- } );
-
- return result;
- };
-
- /**
- * Get an object representing the complete empty state of highlights
- *
- * @return {Object} Object containing all the highlight parameters set to their negative value
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
- var result = {};
-
- this.getItems().forEach( function ( filterItem ) {
- if ( filterItem.isHighlightSupported() ) {
- result[ filterItem.getName() + '_color' ] = null;
- }
- } );
-
- return result;
- };
-
- /**
- * Get an array of currently applied highlight colors
- *
- * @return {string[]} Currently applied highlight colors
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
- var result = [];
-
- if ( this.isHighlightEnabled() ) {
- this.getHighlightedItems().forEach( function ( filterItem ) {
- var color = filterItem.getHighlightColor();
-
- if ( result.indexOf( color ) === -1 ) {
- result.push( color );
- }
- } );
- }
-
- return result;
- };
-
- /**
- * Sanitize value group of a string_option groups type
- * Remove duplicates and make sure to only use valid
- * values.
- *
- * @private
- * @param {string} groupName Group name
- * @param {string[]} valueArray Array of values
- * @return {string[]} Array of valid values
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
- var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
- return filterItem.getParamName();
- } );
-
- return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
- };
-
- /**
- * Check whether no visible filter is selected.
- *
- * Filter groups that are hidden or sticky are not shown in the
- * active filters area and therefore not included in this check.
- *
- * @return {boolean} No visible filter is selected
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
- // Check if there are either any selected items or any items
- // that have highlight enabled
- return !this.getItems().some( function ( filterItem ) {
- var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
- active = ( filterItem.isSelected() || filterItem.isHighlighted() );
- return visible && active;
- } );
- };
-
- /**
- * Check whether the invert state is a valid one. A valid invert state is one where
- * there are actual namespaces selected.
- *
- * This is done to compare states to previous ones that may have had the invert model
- * selected but effectively had no namespaces, so are not effectively different than
- * ones where invert is not selected.
- *
- * @return {boolean} Invert is effectively selected
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
- return this.getInvertModel().isSelected() &&
- this.findSelectedItems().some( function ( itemModel ) {
- return itemModel.getGroupModel().getName() === 'namespace';
- } );
- };
-
- /**
- * Get the item that matches the given name
- *
- * @param {string} name Filter name
- * @return {mw.rcfilters.dm.FilterItem} Filter item
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
- return this.getItems().filter( function ( item ) {
- return name === item.getName();
- } )[ 0 ];
- };
-
- /**
- * Set all filters to false or empty/all
- * This is equivalent to display all.
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
- this.getItems().forEach( function ( filterItem ) {
- if ( !filterItem.getGroupModel().isSticky() ) {
- this.toggleFilterSelected( filterItem.getName(), false );
- }
- }.bind( this ) );
- };
-
- /**
- * Toggle selected state of one item
- *
- * @param {string} name Name of the filter item
- * @param {boolean} [isSelected] Filter selected state
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
- var item = this.getItemByName( name );
-
- if ( item ) {
- item.toggleSelected( isSelected );
- }
- };
-
- /**
- * Toggle selected state of items by their names
- *
- * @param {Object} filterDef Filter definitions
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
- Object.keys( filterDef ).forEach( function ( name ) {
- this.toggleFilterSelected( name, filterDef[ name ] );
- }.bind( this ) );
- };
-
- /**
- * Get a group model from its name
- *
- * @param {string} groupName Group name
- * @return {mw.rcfilters.dm.FilterGroup} Group model
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
- return this.groups[ groupName ];
- };
-
- /**
- * Get all filters within a specified group by its name
- *
- * @param {string} groupName Group name
- * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
- return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
- };
-
- /**
- * Find items whose labels match the given string
- *
- * @param {string} query Search string
- * @param {boolean} [returnFlat] Return a flat array. If false, the result
- * is an object whose keys are the group names and values are an array of
- * filters per group. If set to true, returns an array of filters regardless
- * of their groups.
- * @return {Object} An object of items to show
- * arranged by their group names
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
- var i, searchIsEmpty,
- groupTitle,
- result = {},
- flatResult = [],
- view = this.getViewByTrigger( query.substr( 0, 1 ) ),
- items = this.getFiltersByView( view );
-
- // Normalize so we can search strings regardless of case and view
- query = query.trim().toLowerCase();
- if ( view !== 'default' ) {
- query = query.substr( 1 );
- }
- // Trim again to also intercept cases where the spaces were after the trigger
- // eg: '# str'
- query = query.trim();
-
- // Check if the search if actually empty; this can be a problem when
- // we use prefixes to denote different views
- searchIsEmpty = query.length === 0;
-
- // item label starting with the query string
- for ( i = 0; i < items.length; i++ ) {
- if (
- searchIsEmpty ||
- items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
- (
- // For tags, we want the parameter name to be included in the search
- view === 'tags' &&
- items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
- )
- ) {
- result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
- result[ items[ i ].getGroupName() ].push( items[ i ] );
- flatResult.push( items[ i ] );
- }
- }
-
- if ( $.isEmptyObject( result ) ) {
- // item containing the query string in their label, description, or group title
- for ( i = 0; i < items.length; i++ ) {
- groupTitle = items[ i ].getGroupModel().getTitle();
- if (
- searchIsEmpty ||
- items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
- items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
- groupTitle.toLowerCase().indexOf( query ) > -1 ||
- (
- // For tags, we want the parameter name to be included in the search
- view === 'tags' &&
- items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
- )
- ) {
- result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
- result[ items[ i ].getGroupName() ].push( items[ i ] );
- flatResult.push( items[ i ] );
- }
- }
- }
-
- return returnFlat ? flatResult : result;
- };
-
- /**
- * Get items that are highlighted
- *
- * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightedItems = function () {
- return this.getItems().filter( function ( filterItem ) {
- return filterItem.isHighlightSupported() &&
- filterItem.getHighlightColor();
- } );
- };
-
- /**
- * Get items that allow highlights even if they're not currently highlighted
- *
- * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
- return this.getItems().filter( function ( filterItem ) {
- return filterItem.isHighlightSupported();
- } );
- };
-
- /**
- * Get all selected items
- *
- * @return {mw.rcfilters.dm.FilterItem[]} Selected items
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.findSelectedItems = function () {
- var allSelected = [];
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
- allSelected = allSelected.concat( groupModel.findSelectedItems() );
- } );
-
- return allSelected;
- };
-
- /**
- * Get the current view
- *
- * @return {string} Current view
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentView = function () {
- return this.currentView;
- };
-
- /**
- * Get the label for the current view
- *
- * @param {string} viewName View name
- * @return {string} Label for the current view
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
- viewName = viewName || this.getCurrentView();
-
- return this.views[ viewName ] && this.views[ viewName ].title;
- };
-
- /**
- * Get the view that fits the given trigger
- *
- * @param {string} trigger Trigger
- * @return {string} Name of view
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
- var result = 'default';
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.views, function ( name, data ) {
- if ( data.trigger === trigger ) {
- result = name;
- }
- } );
-
- return result;
- };
-
- /**
- * Return a version of the given string that is without any
- * view triggers.
- *
- * @param {string} str Given string
- * @return {string} Result
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
- if ( this.getViewFromString( str ) !== 'default' ) {
- str = str.substr( 1 );
- }
-
- return str;
- };
-
- /**
- * Get the view from the given string by a trigger, if it exists
- *
- * @param {string} str Given string
- * @return {string} View name
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getViewFromString = function ( str ) {
- return this.getViewByTrigger( str.substr( 0, 1 ) );
- };
-
- /**
- * Set the current search for the system.
- * This also dictates what items and groups are visible according
- * to the search in #findMatches
- *
- * @param {string} searchQuery Search query, including triggers
- * @fires searchChange
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
- var visibleGroups, visibleGroupNames;
-
- if ( this.searchQuery !== searchQuery ) {
- // Check if the view changed
- this.switchView( this.getViewFromString( searchQuery ) );
-
- visibleGroups = this.findMatches( searchQuery );
- visibleGroupNames = Object.keys( visibleGroups );
-
- // Update visibility of items and groups
- // eslint-disable-next-line jquery/no-each-util
- $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
- // Check if the group is visible at all
- groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
- groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
- } );
-
- this.searchQuery = searchQuery;
- this.emit( 'searchChange', this.searchQuery );
- }
- };
-
- /**
- * Get the current search
- *
- * @return {string} Current search query
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getSearch = function () {
- return this.searchQuery;
- };
-
- /**
- * Switch the current view
- *
- * @private
- * @param {string} view View name
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.switchView = function ( view ) {
- if ( this.views[ view ] && this.currentView !== view ) {
- this.currentView = view;
- }
- };
-
- /**
- * Toggle the highlight feature on and off.
- * Propagate the change to filter items.
- *
- * @param {boolean} enable Highlight should be enabled
- * @fires highlightChange
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
- enable = enable === undefined ? !this.highlightEnabled : enable;
-
- if ( this.highlightEnabled !== enable ) {
- this.highlightEnabled = enable;
- this.emit( 'highlightChange', this.highlightEnabled );
- }
- };
-
- /**
- * Check if the highlight feature is enabled
- * @return {boolean}
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.isHighlightEnabled = function () {
- return !!this.highlightEnabled;
- };
-
- /**
- * Toggle the inverted namespaces property on and off.
- * Propagate the change to namespace filter items.
- *
- * @param {boolean} enable Inverted property is enabled
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
- this.toggleFilterSelected( this.getInvertModel().getName(), enable );
- };
-
- /**
- * Get the model object that represents the 'invert' filter
- *
- * @return {mw.rcfilters.dm.FilterItem}
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getInvertModel = function () {
- return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
- };
-
- /**
- * Set highlight color for a specific filter item
- *
- * @param {string} filterName Name of the filter item
- * @param {string} color Selected color
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
- this.getItemByName( filterName ).setHighlightColor( color );
- };
-
- /**
- * Clear highlight for a specific filter item
- *
- * @param {string} filterName Name of the filter item
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
- this.getItemByName( filterName ).clearHighlightColor();
- };
-
-}() );
+++ /dev/null
-( function () {
- /**
- * RCFilter base item model
- *
- * @mixins OO.EventEmitter
- *
- * @constructor
- * @param {string} param Filter param name
- * @param {Object} config Configuration object
- * @cfg {string} [label] The label for the filter
- * @cfg {string} [description] The description of the filter
- * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
- * group. If the prefix has 'invert' state, the parameter is expected to be an object
- * with 'default' and 'inverted' as keys.
- * @cfg {boolean} [active=true] The filter is active and affecting the result
- * @cfg {boolean} [selected] The item is selected
- * @cfg {*} [value] The value of this item
- * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
- * identifier
- * @cfg {string} [cssClass] The class identifying the results that match this filter
- * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
- * added and considered in the view.
- * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
- */
- mw.rcfilters.dm.ItemModel = function MwRcfiltersDmItemModel( param, config ) {
- config = config || {};
-
- // Mixin constructor
- OO.EventEmitter.call( this );
-
- this.param = param;
- this.namePrefix = config.namePrefix || 'item_';
- this.name = this.namePrefix + param;
-
- this.label = config.label || this.name;
- this.labelPrefixKey = config.labelPrefixKey;
- this.description = config.description || '';
- this.setValue( config.value || config.selected );
-
- this.identifiers = config.identifiers || [];
-
- // Highlight
- this.cssClass = config.cssClass;
- this.highlightColor = config.defaultHighlightColor || null;
- };
-
- /* Initialization */
-
- OO.initClass( mw.rcfilters.dm.ItemModel );
- OO.mixinClass( mw.rcfilters.dm.ItemModel, OO.EventEmitter );
-
- /* Events */
-
- /**
- * @event update
- *
- * The state of this filter has changed
- */
-
- /* Methods */
-
- /**
- * Return the representation of the state of this item.
- *
- * @return {Object} State of the object
- */
- mw.rcfilters.dm.ItemModel.prototype.getState = function () {
- return {
- selected: this.isSelected()
- };
- };
-
- /**
- * Get the name of this filter
- *
- * @return {string} Filter name
- */
- mw.rcfilters.dm.ItemModel.prototype.getName = function () {
- return this.name;
- };
-
- /**
- * Get the message key to use to wrap the label. This message takes the label as a parameter.
- *
- * @param {boolean} inverted Whether this item should be considered inverted
- * @return {string|null} Message key, or null if no message
- */
- mw.rcfilters.dm.ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
- if ( this.labelPrefixKey ) {
- if ( typeof this.labelPrefixKey === 'string' ) {
- return this.labelPrefixKey;
- }
- return this.labelPrefixKey[
- // Only use inverted-prefix if the item is selected
- // Highlight-only an inverted item makes no sense
- inverted && this.isSelected() ?
- 'inverted' : 'default'
- ];
- }
- return null;
- };
-
- /**
- * Get the param name or value of this filter
- *
- * @return {string} Filter param name
- */
- mw.rcfilters.dm.ItemModel.prototype.getParamName = function () {
- return this.param;
- };
-
- /**
- * Get the message representing the state of this model.
- *
- * @return {string} State message
- */
- mw.rcfilters.dm.ItemModel.prototype.getStateMessage = function () {
- // Display description
- return this.getDescription();
- };
-
- /**
- * Get the label of this filter
- *
- * @return {string} Filter label
- */
- mw.rcfilters.dm.ItemModel.prototype.getLabel = function () {
- return this.label;
- };
-
- /**
- * Get the description of this filter
- *
- * @return {string} Filter description
- */
- mw.rcfilters.dm.ItemModel.prototype.getDescription = function () {
- return this.description;
- };
-
- /**
- * Get the default value of this filter
- *
- * @return {boolean} Filter default
- */
- mw.rcfilters.dm.ItemModel.prototype.getDefault = function () {
- return this.default;
- };
-
- /**
- * Get the selected state of this filter
- *
- * @return {boolean} Filter is selected
- */
- mw.rcfilters.dm.ItemModel.prototype.isSelected = function () {
- return !!this.value;
- };
-
- /**
- * Toggle the selected state of the item
- *
- * @param {boolean} [isSelected] Filter is selected
- * @fires update
- */
- mw.rcfilters.dm.ItemModel.prototype.toggleSelected = function ( isSelected ) {
- isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
- this.setValue( isSelected );
- };
-
- /**
- * Get the value
- *
- * @return {*}
- */
- mw.rcfilters.dm.ItemModel.prototype.getValue = function () {
- return this.value;
- };
-
- /**
- * Convert a given value to the appropriate representation based on group type
- *
- * @param {*} value
- * @return {*}
- */
- mw.rcfilters.dm.ItemModel.prototype.coerceValue = function ( value ) {
- return this.getGroupModel().getType() === 'any_value' ? value : !!value;
- };
-
- /**
- * Set the value
- *
- * @param {*} newValue
- */
- mw.rcfilters.dm.ItemModel.prototype.setValue = function ( newValue ) {
- newValue = this.coerceValue( newValue );
- if ( this.value !== newValue ) {
- this.value = newValue;
- this.emit( 'update' );
- }
- };
-
- /**
- * Set the highlight color
- *
- * @param {string|null} highlightColor
- */
- mw.rcfilters.dm.ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
- if ( !this.isHighlightSupported() ) {
- return;
- }
- // If the highlight color on the item and in the parameter is null/undefined, return early.
- if ( !this.highlightColor && !highlightColor ) {
- return;
- }
-
- if ( this.highlightColor !== highlightColor ) {
- this.highlightColor = highlightColor;
- this.emit( 'update' );
- }
- };
-
- /**
- * Clear the highlight color
- */
- mw.rcfilters.dm.ItemModel.prototype.clearHighlightColor = function () {
- this.setHighlightColor( null );
- };
-
- /**
- * Get the highlight color, or null if none is configured
- *
- * @return {string|null}
- */
- mw.rcfilters.dm.ItemModel.prototype.getHighlightColor = function () {
- return this.highlightColor;
- };
-
- /**
- * Get the CSS class that matches changes that fit this filter
- * or null if none is configured
- *
- * @return {string|null}
- */
- mw.rcfilters.dm.ItemModel.prototype.getCssClass = function () {
- return this.cssClass;
- };
-
- /**
- * Get the item's identifiers
- *
- * @return {string[]}
- */
- mw.rcfilters.dm.ItemModel.prototype.getIdentifiers = function () {
- return this.identifiers;
- };
-
- /**
- * Check if the highlight feature is supported for this filter
- *
- * @return {boolean}
- */
- mw.rcfilters.dm.ItemModel.prototype.isHighlightSupported = function () {
- return !!this.getCssClass();
- };
-
- /**
- * Check if the filter is currently highlighted
- *
- * @return {boolean}
- */
- mw.rcfilters.dm.ItemModel.prototype.isHighlighted = function () {
- return !!this.getHighlightColor();
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * View model for saved queries
- *
- * @class
- * @mixins OO.EventEmitter
- * @mixins OO.EmitterList
- *
- * @constructor
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
- * @param {Object} [config] Configuration options
- * @cfg {string} [default] Default query ID
- */
- mw.rcfilters.dm.SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
- config = config || {};
-
- // Mixin constructor
- OO.EventEmitter.call( this );
- OO.EmitterList.call( this );
-
- this.default = config.default;
- this.filtersModel = filtersModel;
- this.converted = false;
-
- // Events
- this.aggregate( { update: 'itemUpdate' } );
- };
-
- /* Initialization */
-
- OO.initClass( mw.rcfilters.dm.SavedQueriesModel );
- OO.mixinClass( mw.rcfilters.dm.SavedQueriesModel, OO.EventEmitter );
- OO.mixinClass( mw.rcfilters.dm.SavedQueriesModel, OO.EmitterList );
-
- /* Events */
-
- /**
- * @event initialize
- *
- * Model is initialized
- */
-
- /**
- * @event itemUpdate
- * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
- *
- * An item has changed
- */
-
- /**
- * @event default
- * @param {string} New default ID
- *
- * The default has changed
- */
-
- /* Methods */
-
- /**
- * Initialize the saved queries model by reading it from the user's settings.
- * The structure of the saved queries is:
- * {
- * version: (string) Version number; if version 2, the query represents
- * parameters. Otherwise, the older version represented filters
- * and needs to be readjusted,
- * default: (string) Query ID
- * queries:{
- * query_id_1: {
- * data:{
- * filters: (Object) Minimal definition of the filters
- * highlights: (Object) Definition of the highlights
- * },
- * label: (optional) Name of this query
- * }
- * }
- * }
- *
- * @param {Object} [savedQueries] An object with the saved queries with
- * the above structure.
- * @fires initialize
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
- var model = this;
-
- savedQueries = savedQueries || {};
-
- this.clearItems();
- this.default = null;
- this.converted = false;
-
- if ( savedQueries.version !== '2' ) {
- // Old version dealt with filter names. We need to migrate to the new structure
- // The new structure:
- // {
- // version: (string) '2',
- // default: (string) Query ID,
- // queries: {
- // query_id: {
- // label: (string) Name of the query
- // data: {
- // params: (object) Representing all the parameter states
- // highlights: (object) Representing all the filter highlight states
- // }
- // }
- // }
- // eslint-disable-next-line jquery/no-each-util
- $.each( savedQueries.queries || {}, function ( id, obj ) {
- if ( obj.data && obj.data.filters ) {
- obj.data = model.convertToParameters( obj.data );
- }
- } );
-
- this.converted = true;
- savedQueries.version = '2';
- }
-
- // Initialize the query items
- // eslint-disable-next-line jquery/no-each-util
- $.each( savedQueries.queries || {}, function ( id, obj ) {
- var normalizedData = obj.data,
- isDefault = String( savedQueries.default ) === String( id );
-
- if ( normalizedData && normalizedData.params ) {
- // Backwards-compat fix: Remove sticky parameters from
- // the given data, if they exist
- normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );
-
- // Correct the invert state for effective selection
- if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
- delete normalizedData.params.invert;
- }
-
- model.cleanupHighlights( normalizedData );
-
- id = String( id );
-
- // Skip the addNewQuery method because we don't want to unnecessarily manipulate
- // the given saved queries unless we literally intend to (like in backwards compat fixes)
- // And the addNewQuery method also uses a minimization routine that checks for the
- // validity of items and minimizes the query. This isn't necessary for queries loaded
- // from the backend, and has the risk of removing values if they're temporarily
- // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
- model.addItems( [
- new mw.rcfilters.dm.SavedQueryItemModel(
- id,
- obj.label,
- normalizedData,
- { default: isDefault }
- )
- ] );
-
- if ( isDefault ) {
- model.default = id;
- }
- }
- } );
-
- this.emit( 'initialize' );
- };
-
- /**
- * Clean up highlight parameters.
- * 'highlight' used to be stored, it's not inferred based on the presence of absence of
- * filter colors.
- *
- * @param {Object} data Saved query data
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
- if (
- data.params.highlight === '0' &&
- data.highlights && Object.keys( data.highlights ).length
- ) {
- data.highlights = {};
- }
- delete data.params.highlight;
- };
-
- /**
- * Convert from representation of filters to representation of parameters
- *
- * @param {Object} data Query data
- * @return {Object} New converted query data
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.convertToParameters = function ( data ) {
- var newData = {},
- defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
- fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
- highlightEnabled = data.highlights.highlight;
-
- delete data.highlights.highlight;
-
- // Filters
- newData.params = this.filtersModel.getMinimizedParamRepresentation(
- this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
- );
-
- // Highlights: appending _color to keys
- newData.highlights = {};
- // eslint-disable-next-line jquery/no-each-util
- $.each( data.highlights, function ( highlightedFilterName, value ) {
- if ( value ) {
- newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
- }
- } );
-
- // Add highlight
- newData.params.highlight = String( Number( highlightEnabled || 0 ) );
-
- return newData;
- };
-
- /**
- * Add a query item
- *
- * @param {string} label Label for the new query
- * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
- * @param {boolean} isDefault Item is default
- * @param {string} [id] Query ID, if exists. If this isn't given, a random
- * new ID will be created.
- * @return {string} ID of the newly added query
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
- var normalizedData = { params: {}, highlights: {} },
- highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
- randomID = String( id || ( new Date() ).getTime() ),
- data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
-
- // Split highlight/params
- // eslint-disable-next-line jquery/no-each-util
- $.each( data, function ( param, value ) {
- if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
- normalizedData.highlights[ param ] = value;
- } else {
- normalizedData.params[ param ] = value;
- }
- } );
-
- // Correct the invert state for effective selection
- if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
- delete normalizedData.params.invert;
- }
-
- // Add item
- this.addItems( [
- new mw.rcfilters.dm.SavedQueryItemModel(
- randomID,
- label,
- normalizedData,
- { default: isDefault }
- )
- ] );
-
- if ( isDefault ) {
- this.setDefault( randomID );
- }
-
- return randomID;
- };
-
- /**
- * Remove query from model
- *
- * @param {string} queryID Query ID
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
- var query = this.getItemByID( queryID );
-
- if ( query ) {
- // Check if this item was the default
- if ( String( this.getDefault() ) === String( queryID ) ) {
- // Nulify the default
- this.setDefault( null );
- }
-
- this.removeItems( [ query ] );
- }
- };
-
- /**
- * Get an item that matches the requested query
- *
- * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
- * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
- // Minimize before comparison
- fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
-
- // Correct the invert state for effective selection
- if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
- delete fullQueryComparison.invert;
- }
-
- return this.getItems().filter( function ( item ) {
- return OO.compare(
- item.getCombinedData(),
- fullQueryComparison
- );
- } )[ 0 ];
- };
-
- /**
- * Get query by its identifier
- *
- * @param {string} queryID Query identifier
- * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
- * the search. Undefined if not found.
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
- return this.getItems().filter( function ( item ) {
- return item.getID() === queryID;
- } )[ 0 ];
- };
-
- /**
- * Get the full data representation of the default query, if it exists
- *
- * @return {Object|null} Representation of the default params if exists.
- * Null if default doesn't exist or if the user is not logged in.
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.getDefaultParams = function () {
- return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
- };
-
- /**
- * Get a full parameter representation of an item data
- *
- * @param {Object} queryID Query ID
- * @return {Object} Parameter representation
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
- var item = this.getItemByID( queryID ),
- data = item ? item.getData() : {};
-
- return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
- };
-
- /**
- * Build a full parameter representation given item data and model sticky values state
- *
- * @param {Object} data Item data
- * @return {Object} Full param representation
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
- data = data || {};
- // Return parameter representation
- return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
- data.params,
- data.highlights
- ) );
- };
-
- /**
- * Get the object representing the state of the entire model and items
- *
- * @return {Object} Object representing the state of the model and items
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.getState = function () {
- var obj = { queries: {}, version: '2' };
-
- // Translate the items to the saved object
- this.getItems().forEach( function ( item ) {
- obj.queries[ item.getID() ] = item.getState();
- } );
-
- if ( this.getDefault() ) {
- obj.default = this.getDefault();
- }
-
- return obj;
- };
-
- /**
- * Set a default query. Null to unset default.
- *
- * @param {string} itemID Query identifier
- * @fires default
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.setDefault = function ( itemID ) {
- if ( this.default !== itemID ) {
- this.default = itemID;
-
- // Set for individual itens
- this.getItems().forEach( function ( item ) {
- item.toggleDefault( item.getID() === itemID );
- } );
-
- this.emit( 'default', itemID );
- }
- };
-
- /**
- * Get the default query ID
- *
- * @return {string} Default query identifier
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.getDefault = function () {
- return this.default;
- };
-
- /**
- * Check if the saved queries were converted
- *
- * @return {boolean} Saved queries were converted from the previous
- * version to the new version
- */
- mw.rcfilters.dm.SavedQueriesModel.prototype.isConverted = function () {
- return this.converted;
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * View model for a single saved query
- *
- * @class
- * @mixins OO.EventEmitter
- *
- * @constructor
- * @param {string} id Unique identifier
- * @param {string} label Saved query label
- * @param {Object} data Saved query data
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [default] This item is the default
- */
- mw.rcfilters.dm.SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
- config = config || {};
-
- // Mixin constructor
- OO.EventEmitter.call( this );
-
- this.id = id;
- this.label = label;
- this.data = data;
- this.default = !!config.default;
- };
-
- /* Initialization */
-
- OO.initClass( mw.rcfilters.dm.SavedQueryItemModel );
- OO.mixinClass( mw.rcfilters.dm.SavedQueryItemModel, OO.EventEmitter );
-
- /* Events */
-
- /**
- * @event update
- *
- * Model has been updated
- */
-
- /* Methods */
-
- /**
- * Get an object representing the state of this item
- *
- * @return {Object} Object representing the current data state
- * of the object
- */
- mw.rcfilters.dm.SavedQueryItemModel.prototype.getState = function () {
- return {
- data: this.getData(),
- label: this.getLabel()
- };
- };
-
- /**
- * Get the query's identifier
- *
- * @return {string} Query identifier
- */
- mw.rcfilters.dm.SavedQueryItemModel.prototype.getID = function () {
- return this.id;
- };
-
- /**
- * Get query label
- *
- * @return {string} Query label
- */
- mw.rcfilters.dm.SavedQueryItemModel.prototype.getLabel = function () {
- return this.label;
- };
-
- /**
- * Update the query label
- *
- * @param {string} newLabel New label
- */
- mw.rcfilters.dm.SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
- if ( newLabel && this.label !== newLabel ) {
- this.label = newLabel;
- this.emit( 'update' );
- }
- };
-
- /**
- * Get query data
- *
- * @return {Object} Object representing parameter and highlight data
- */
- mw.rcfilters.dm.SavedQueryItemModel.prototype.getData = function () {
- return this.data;
- };
-
- /**
- * Get the combined data of this item as a flat object of parameters
- *
- * @return {Object} Combined parameter data
- */
- mw.rcfilters.dm.SavedQueryItemModel.prototype.getCombinedData = function () {
- return $.extend( true, {}, this.data.params, this.data.highlights );
- };
-
- /**
- * Check whether this item is the default
- *
- * @return {boolean} Query is set to be default
- */
- mw.rcfilters.dm.SavedQueryItemModel.prototype.isDefault = function () {
- return this.default;
- };
-
- /**
- * Toggle the default state of this query item
- *
- * @param {boolean} isDefault Query is default
- */
- mw.rcfilters.dm.SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
- isDefault = isDefault === undefined ? !this.default : isDefault;
-
- if ( this.default !== isDefault ) {
- this.default = isDefault;
- this.emit( 'update' );
- }
- };
-}() );
+++ /dev/null
-( function () {
-
- var byteLength = require( 'mediawiki.String' ).byteLength;
-
- /* 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
- * @param {Object} config Additional configuration
- * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
- * @cfg {string} daysPreferenceName Preference name for the days filter
- * @cfg {string} limitPreferenceName Preference name for the limit filter
- * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
- * the active filters area
- * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
- * title normalization to separate title subpage/parts into the target= url
- * parameter
- */
- mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
- this.filtersModel = filtersModel;
- this.changesListModel = changesListModel;
- this.savedQueriesModel = savedQueriesModel;
- this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
- this.daysPreferenceName = config.daysPreferenceName;
- this.limitPreferenceName = config.limitPreferenceName;
- this.collapsedPreferenceName = config.collapsedPreferenceName;
- this.normalizeTarget = !!config.normalizeTarget;
-
- this.requestCounter = {};
- this.baseFilterState = {};
- this.uriProcessor = null;
- this.initialized = false;
- this.wereSavedQueriesSaved = false;
-
- this.prevLoggedItems = [];
-
- this.FILTER_CHANGE = 'filterChange';
- this.SHOW_NEW_CHANGES = 'showNewChanges';
- this.LIVE_UPDATE = 'liveUpdate';
- };
-
- /* Initialization */
- OO.initClass( mw.rcfilters.Controller );
-
- /**
- * Initialize the filter and parameter states
- *
- * @param {Array} filterStructure Filter definition and structure for the model
- * @param {Object} [namespaceStructure] Namespace definition
- * @param {Object} [tagList] Tag definition
- * @param {Object} [conditionalViews] Conditional view definition
- */
- mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
- var parsedSavedQueries, pieces,
- displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
- defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
- controller = this,
- views = $.extend( true, {}, conditionalViews ),
- items = [],
- uri = new mw.Uri();
-
- // Prepare views
- if ( namespaceStructure ) {
- items = [];
- // eslint-disable-next-line jquery/no-each-util
- $.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: [
- mw.Title.isTalkNamespace( namespaceID ) ?
- 'talk' : 'subject'
- ],
- 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
- } ]
- };
- views.invert = {
- groups: [
- {
- name: 'invertGroup',
- type: 'boolean',
- hidden: true,
- filters: [ {
- name: 'invert',
- default: '0'
- } ]
- } ]
- };
- }
- 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,
- // FIXME: $.isNumeric is deprecated
- validate: $.isNumeric,
- range: {
- min: 0, // The server normalizes negative numbers to 0 results
- max: 1000
- },
- sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
- default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
- sticky: true,
- filters: displayConfig.limitArray.map( function ( num ) {
- return controller._createFilterDataFromNumber( num, num );
- } )
- },
- {
- name: 'days',
- type: 'single_option',
- title: '', // Because it's a hidden group, this title actually appears nowhere
- hidden: true,
- allowArbitrary: true,
- // FIXME: $.isNumeric is deprecated
- validate: $.isNumeric,
- range: {
- min: 0,
- max: displayConfig.maxDays
- },
- sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
- numToLabelFunc: function ( i ) {
- return Number( i ) < 1 ?
- ( Number( i ) * 24 ).toFixed( 2 ) :
- Number( i );
- },
- default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
- sticky: true,
- filters: [
- // Hours (1, 2, 6, 12)
- 0.04166, 0.0833, 0.25, 0.5
- // Days
- ].concat( displayConfig.daysArray )
- .map( function ( num ) {
- return controller._createFilterDataFromNumber(
- num,
- // Convert fractions of days to number of hours for the labels
- num < 1 ? Math.round( num * 24 ) : num
- );
- } )
- }
- ]
- };
-
- views.display = {
- groups: [
- {
- name: 'display',
- type: 'boolean',
- title: '', // Because it's a hidden group, this title actually appears nowhere
- hidden: true,
- sticky: true,
- filters: [
- {
- name: 'enhanced',
- default: String( mw.user.options.get( 'usenewrc', 0 ) )
- }
- ]
- }
- ]
- };
-
- // 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:
- // eslint-disable-next-line jquery/no-each-util
- $.each( views, function ( viewName, viewData ) {
- viewData.groups.forEach( function ( groupData ) {
- 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 the default value isn't in the group, add it
- if ( groupData.default !== undefined ) {
- extraValues.push( String( groupData.default ) );
- }
- controller.addNumberValuesToGroup( groupData, extraValues );
- }
- } );
- } );
-
- // Initialize the model
- this.filtersModel.initializeFilters( filterStructure, views );
-
- this.uriProcessor = new mw.rcfilters.UriProcessor(
- this.filtersModel,
- { normalizeTarget: this.normalizeTarget }
- );
-
- if ( !mw.user.isAnon() ) {
- try {
- parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
- } catch ( err ) {
- parsedSavedQueries = {};
- }
-
- // Initialize saved queries
- this.savedQueriesModel.initialize( parsedSavedQueries );
- if ( this.savedQueriesModel.isConverted() ) {
- // Since we know we converted, we're going to re-save
- // the queries so they are now migrated to the new format
- this._saveSavedQueries();
- }
- }
-
- if ( defaultSavedQueryExists ) {
- // This came from the server, meaning that we have a default
- // saved query, but the server could not load it, probably because
- // it was pre-conversion to the new format.
- // We need to load this query again
- this.applySavedQuery( this.savedQueriesModel.getDefault() );
- } else {
- // There are either recognized parameters in the URL
- // or there are none, but there is also no default
- // saved query (so defaults are from the backend)
- // We want to update the state but not fetch results
- // again
- this.updateStateFromUrl( false );
-
- pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
-
- // Update the changes list with the existing data
- // so it gets processed
- this.changesListModel.update(
- pieces.changes,
- pieces.fieldset,
- pieces.noResultsDetails,
- true // We're using existing DOM elements
- );
- }
-
- this.initialized = true;
- this.switchView( 'default' );
-
- this.pollingRate = mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' );
- if ( this.pollingRate ) {
- this._scheduleLiveUpdate();
- }
- };
-
- /**
- * Check if the controller has finished initializing.
- * @return {boolean} Controller is initialized
- */
- mw.rcfilters.Controller.prototype.isInitialized = function () {
- return this.initialized;
- };
-
- /**
- * Extracts information from the changes list DOM
- *
- * @param {jQuery} $root Root DOM to find children from
- * @param {boolean} [statusCode] Server response status code
- * @return {Object} Information about changes list
- * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
- * (either normally or as an error)
- * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
- * 'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
- * @return {jQuery} return.fieldset Fieldset
- */
- mw.rcfilters.Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
- var info,
- $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
- areResults = !!$changesListContents.length,
- checkForLogout = !areResults && statusCode === 200;
-
- // We check if user logged out on different tab/browser or the session has expired.
- // 205 status code returned from the server, which indicates that we need to reload the page
- // is not usable on WL page, because we get redirected to login page, which gives 200 OK
- // status code (if everything else goes well).
- // Bug: T177717
- if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
- location.reload( false );
- return;
- }
-
- info = {
- changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
- fieldset: $root.find( 'fieldset.cloptions' ).first()
- };
-
- if ( !areResults ) {
- if ( $root.find( '.mw-changeslist-timeout' ).length ) {
- info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
- } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
- info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
- } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
- info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
- } else {
- info.noResultsDetails = 'NO_RESULTS_NORMAL';
- }
- }
-
- return info;
- };
-
- /**
- * 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,
- normalizeWithinRange = function ( range, val ) {
- if ( val < range.min ) {
- return range.min; // Min
- } else if ( val >= range.max ) {
- return range.max; // Max
- }
- return val;
- };
-
- arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
-
- // Normalize the arbitrary values and the default value for a range
- if ( groupData.range ) {
- arbitraryValues = arbitraryValues.map( function ( val ) {
- return normalizeWithinRange( groupData.range, val );
- } );
-
- // Normalize the default, since that's user defined
- if ( groupData.default !== undefined ) {
- groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
- }
- }
-
- // 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 String( filterData.name );
- } )
- .indexOf( String( 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 );
- }
- }
- } );
- };
-
- /**
- * Reset to default filters
- */
- mw.rcfilters.Controller.prototype.resetToDefaults = function () {
- var params = this._getDefaultParams();
- if ( this.applyParamChange( params ) ) {
- // Only update the changes list if there was a change to actual filters
- this.updateChangesList();
- } else {
- this.uriProcessor.updateURL( params );
- }
- };
-
- /**
- * Check whether the default values of the filters are all false.
- *
- * @return {boolean} Defaults are all false
- */
- mw.rcfilters.Controller.prototype.areDefaultsEmpty = function () {
- return $.isEmptyObject( this._getDefaultParams() );
- };
-
- /**
- * Empty all selected filters
- */
- mw.rcfilters.Controller.prototype.emptyFilters = function () {
- var highlightedFilterNames = this.filtersModel.getHighlightedItems()
- .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
-
- if ( this.applyParamChange( {} ) ) {
- // Only update the changes list if there was a change to actual filters
- this.updateChangesList();
- } else {
- this.uriProcessor.updateURL();
- }
-
- if ( highlightedFilterNames ) {
- this._trackHighlight( 'clearAll', highlightedFilterNames );
- }
- };
-
- /**
- * Update the selected state of a filter
- *
- * @param {string} filterName Filter name
- * @param {boolean} [isSelected] Filter selected state
- */
- mw.rcfilters.Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
- var filterItem = this.filtersModel.getItemByName( filterName );
-
- if ( !filterItem ) {
- // If no filter was found, break
- return;
- }
-
- isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
-
- if ( filterItem.isSelected() !== isSelected ) {
- this.filtersModel.toggleFilterSelected( filterName, isSelected );
-
- this.updateChangesList();
-
- // Check filter interactions
- this.filtersModel.reassessFilterInteractions( filterItem );
- }
- };
-
- /**
- * Clear both highlight and selection of a filter
- *
- * @param {string} filterName Name of the filter item
- */
- mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) {
- var filterItem = this.filtersModel.getItemByName( filterName ),
- isHighlighted = filterItem.isHighlighted(),
- isSelected = filterItem.isSelected();
-
- if ( isSelected || isHighlighted ) {
- this.filtersModel.clearHighlightColor( filterName );
- this.filtersModel.toggleFilterSelected( filterName, false );
-
- if ( isSelected ) {
- // Only update the changes list if the filter changed
- // its selection state. If it only changed its highlight
- // then don't reload
- this.updateChangesList();
- }
-
- this.filtersModel.reassessFilterInteractions( filterItem );
-
- // Log filter grouping
- this.trackFilterGroupings( 'removefilter' );
- }
-
- if ( isHighlighted ) {
- this._trackHighlight( 'clear', filterName );
- }
- };
-
- /**
- * Toggle the highlight feature on and off
- */
- mw.rcfilters.Controller.prototype.toggleHighlight = function () {
- this.filtersModel.toggleHighlight();
- this.uriProcessor.updateURL();
-
- if ( this.filtersModel.isHighlightEnabled() ) {
- mw.hook( 'RcFilters.highlight.enable' ).fire();
- }
- };
-
- /**
- * Toggle the namespaces inverted feature on and off
- */
- mw.rcfilters.Controller.prototype.toggleInvertedNamespaces = function () {
- this.filtersModel.toggleInvertedNamespaces();
- if (
- this.filtersModel.getFiltersByView( 'namespaces' ).filter(
- function ( filterItem ) { return filterItem.isSelected(); }
- ).length
- ) {
- // Only re-fetch results if there are namespace items that are actually selected
- this.updateChangesList();
- } else {
- this.uriProcessor.updateURL();
- }
- };
-
- /**
- * Set the value of the 'showlinkedto' parameter
- * @param {boolean} value
- */
- mw.rcfilters.Controller.prototype.setShowLinkedTo = function ( value ) {
- var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
- showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
-
- this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
- this.uriProcessor.updateURL();
- // reload the results only when target is set
- if ( targetItem.getValue() ) {
- this.updateChangesList();
- }
- };
-
- /**
- * Set the target page
- * @param {string} page
- */
- mw.rcfilters.Controller.prototype.setTargetPage = function ( page ) {
- var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
- targetItem.setValue( page );
- this.uriProcessor.updateURL();
- this.updateChangesList();
- };
-
- /**
- * Set the highlight color for a filter item
- *
- * @param {string} filterName Name of the filter item
- * @param {string} color Selected color
- */
- mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) {
- this.filtersModel.setHighlightColor( filterName, color );
- this.uriProcessor.updateURL();
- this._trackHighlight( 'set', { name: filterName, color: color } );
- };
-
- /**
- * Clear highlight for a filter item
- *
- * @param {string} filterName Name of the filter item
- */
- mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) {
- this.filtersModel.clearHighlightColor( filterName );
- this.uriProcessor.updateURL();
- 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 ) {
- this.changesListModel.toggleLiveUpdate( enable );
- if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
- this.updateChangesList( null, this.LIVE_UPDATE );
- }
- };
-
- /**
- * Set a timeout for the next live update.
- * @private
- */
- mw.rcfilters.Controller.prototype._scheduleLiveUpdate = function () {
- setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
- };
-
- /**
- * Perform a live update.
- * @private
- */
- mw.rcfilters.Controller.prototype._doLiveUpdate = function () {
- if ( !this._shouldCheckForNewChanges() ) {
- // skip this turn and check back later
- this._scheduleLiveUpdate();
- return;
- }
-
- this._checkForNewChanges()
- .then( function ( statusCode ) {
- // no result is 204 with the 'peek' param
- // logged out is 205
- var newChanges = statusCode === 200;
-
- if ( !this._shouldCheckForNewChanges() ) {
- // by the time the response is received,
- // it may not be appropriate anymore
- return;
- }
-
- // 205 is the status code returned from server when user's logged in/out
- // status is not matching while fetching live update changes.
- // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
- // Bug: T177717
- if ( statusCode === 205 ) {
- location.reload( false );
- return;
- }
-
- if ( newChanges ) {
- if ( this.changesListModel.getLiveUpdate() ) {
- return this.updateChangesList( null, this.LIVE_UPDATE );
- } 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 () {
- return !document.hidden &&
- !this.filtersModel.hasConflict() &&
- !this.changesListModel.getNewChangesExist() &&
- !this.updatingChangesList &&
- this.changesListModel.getNextFrom();
- };
-
- /**
- * Check if new changes, newer than those currently shown, are available
- *
- * @return {jQuery.Promise} Promise object that resolves with a bool
- * specifying if there are new changes or not
- *
- * @private
- */
- mw.rcfilters.Controller.prototype._checkForNewChanges = function () {
- var params = {
- limit: 1,
- peek: 1, // bypasses ChangesList specific UI
- from: this.changesListModel.getNextFrom(),
- isAnon: mw.user.isAnon()
- };
- return this._queryChangesList( 'liveUpdate', params ).then(
- function ( data ) {
- return data.status;
- }
- );
- };
-
- /**
- * 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( null, this.SHOW_NEW_CHANGES );
- };
-
- /**
- * 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, setAsDefault ) {
- // Add item
- this.savedQueriesModel.addNewQuery(
- label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
- this.filtersModel.getCurrentParameterState( true ),
- setAsDefault
- );
-
- // Save item
- this._saveSavedQueries();
- };
-
- /**
- * Remove a saved query
- *
- * @param {string} queryID Query id
- */
- mw.rcfilters.Controller.prototype.removeSavedQuery = function ( queryID ) {
- this.savedQueriesModel.removeQuery( queryID );
-
- this._saveSavedQueries();
- };
-
- /**
- * Rename a saved query
- *
- * @param {string} queryID Query id
- * @param {string} newLabel New label for the query
- */
- mw.rcfilters.Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
- var queryItem = this.savedQueriesModel.getItemByID( queryID );
-
- if ( queryItem ) {
- queryItem.updateLabel( newLabel );
- }
- this._saveSavedQueries();
- };
-
- /**
- * Set a saved query as default
- *
- * @param {string} queryID Query Id. If null is given, default
- * query is reset.
- */
- mw.rcfilters.Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
- this.savedQueriesModel.setDefault( queryID );
- this._saveSavedQueries();
- };
-
- /**
- * Load a saved query
- *
- * @param {string} queryID Query id
- */
- mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
- var currentMatchingQuery,
- params = this.savedQueriesModel.getItemParams( queryID );
-
- currentMatchingQuery = this.findQueryMatchingCurrentState();
-
- if (
- currentMatchingQuery &&
- currentMatchingQuery.getID() === queryID
- ) {
- // If the query we want to load is the one that is already
- // loaded, don't reload it
- return;
- }
-
- if ( this.applyParamChange( params ) ) {
- // Update changes list only if there was a difference in filter selection
- this.updateChangesList();
- } else {
- this.uriProcessor.updateURL( params );
- }
-
- // Log filter grouping
- this.trackFilterGroupings( 'savedfilters' );
- };
-
- /**
- * Check whether the current filter and highlight state exists
- * in the saved queries model.
- *
- * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
- */
- mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
- return this.savedQueriesModel.findMatchingQuery(
- this.filtersModel.getCurrentParameterState( true )
- );
- };
-
- /**
- * Save the current state of the saved queries model with all
- * query item representation in the user settings.
- */
- mw.rcfilters.Controller.prototype._saveSavedQueries = function () {
- var stringified, oldPrefValue,
- backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
- state = this.savedQueriesModel.getState();
-
- // Stringify state
- stringified = JSON.stringify( state );
-
- if ( byteLength( stringified ) > 65535 ) {
- // Sanity check, since the preference can only hold that.
- return;
- }
-
- if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
- // The queries were converted from the previous version
- // Keep the old string in the [prefname]-versionbackup
- oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
-
- // Save the old preference in the backup preference
- new mw.Api().saveOption( backupPrefName, oldPrefValue );
- // Update the preference for this session
- mw.user.options.set( backupPrefName, oldPrefValue );
- }
-
- // Save the preference
- new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
- // Update the preference for this session
- mw.user.options.set( this.savedQueriesPreferenceName, stringified );
-
- // Tag as already saved so we don't do this again
- this.wereSavedQueriesSaved = true;
- };
-
- /**
- * 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' ).findSelectedItems()[ 0 ].getParamName() );
- this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );
-
- // TODO: Make these automatic by having the model go over sticky
- // items and update their default values automatically
- };
-
- /**
- * Update the limit default value
- *
- * @param {number} newValue New value
- */
- mw.rcfilters.Controller.prototype.updateLimitDefault = function ( newValue ) {
- this.updateNumericPreference( this.limitPreferenceName, newValue );
- };
-
- /**
- * Update the days default value
- *
- * @param {number} newValue New value
- */
- mw.rcfilters.Controller.prototype.updateDaysDefault = function ( newValue ) {
- this.updateNumericPreference( this.daysPreferenceName, newValue );
- };
-
- /**
- * Update the group by page default value
- *
- * @param {boolean} newValue New value
- */
- mw.rcfilters.Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
- this.updateNumericPreference( 'usenewrc', Number( newValue ) );
- };
-
- /**
- * Update the collapsed state value
- *
- * @param {boolean} isCollapsed Filter area is collapsed
- */
- mw.rcfilters.Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
- this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
- };
-
- /**
- * Update a numeric preference with a new value
- *
- * @param {string} prefName Preference name
- * @param {number|string} newValue New value
- */
- mw.rcfilters.Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
- // FIXME: $.isNumeric is deprecated
- // eslint-disable-next-line jquery/no-is-numeric
- if ( !$.isNumeric( newValue ) ) {
- return;
- }
-
- newValue = Number( newValue );
-
- if ( mw.user.options.get( prefName ) !== newValue ) {
- // Save the preference
- new mw.Api().saveOption( prefName, newValue );
- // Update the preference for this session
- mw.user.options.set( prefName, newValue );
- }
- };
-
- /**
- * Synchronize the URL with the current state of the filters
- * without adding an history entry.
- */
- mw.rcfilters.Controller.prototype.replaceUrl = function () {
- this.uriProcessor.updateURL();
- };
-
- /**
- * Update filter state (selection and highlighting) based
- * on current URL values.
- *
- * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
- * list based on the updated model.
- */
- mw.rcfilters.Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
- fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
-
- this.uriProcessor.updateModelBasedOnQuery();
-
- // 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 {Object} [params] Extra parameters to add to the API call
- * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
- * @return {jQuery.Promise} Promise that is resolved when the update is complete
- */
- mw.rcfilters.Controller.prototype.updateChangesList = function ( params, updateMode ) {
- updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
-
- if ( updateMode === this.FILTER_CHANGE ) {
- this.uriProcessor.updateURL( params );
- }
- if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
- 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,
- pieces.noResultsDetails,
- false,
- // separator between old and new changes
- updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
- );
- }.bind( this )
- // Do nothing for failure
- )
- .always( function () {
- this.updatingChangesList = false;
- }.bind( this ) );
- };
-
- /**
- * 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 () {
- if ( this.savedQueriesModel.getDefault() ) {
- return this.savedQueriesModel.getDefaultParams();
- } else {
- return this.filtersModel.getDefaultParams();
- }
- };
-
- /**
- * Query the list of changes from the server for the current filters
- *
- * @param {string} counterId 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 resolved with { content, status }
- */
- mw.rcfilters.Controller.prototype._queryChangesList = function ( counterId, params ) {
- var uri = this.uriProcessor.getUpdatedUri(),
- stickyParams = this.filtersModel.getStickyParamsValues(),
- requestId,
- latestRequest;
-
- params = params || {};
- params.action = 'render'; // bypasses MW chrome
-
- 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(
- function ( content, message, jqXHR ) {
- if ( !latestRequest() ) {
- return $.Deferred().reject();
- }
- return {
- content: content,
- status: jqXHR.status
- };
- },
- // RC returns 404 when there is no results
- function ( jqXHR ) {
- if ( latestRequest() ) {
- return $.Deferred().resolve(
- {
- content: jqXHR.responseText,
- status: jqXHR.status
- }
- ).promise();
- }
- }
- );
- };
-
- /**
- * Fetch the list of changes from the server for the current filters
- *
- * @return {jQuery.Promise} Promise object that will resolve with the changes list
- * and the fieldset.
- */
- mw.rcfilters.Controller.prototype._fetchChangesList = function () {
- return this._queryChangesList( 'updateChangesList' )
- .then(
- function ( data ) {
- var $parsed;
-
- // Status code 0 is not HTTP status code,
- // but is valid value of XMLHttpRequest status.
- // It is used for variety of network errors, for example
- // when an AJAX call was cancelled before getting the response
- if ( data && data.status === 0 ) {
- return {
- changes: 'NO_RESULTS',
- // We need empty result set, to avoid exceptions because of undefined value
- fieldset: $( [] ),
- noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
- };
- }
-
- $parsed = $( '<div>' ).append( $( $.parseHTML(
- data ? data.content : ''
- ) ) );
-
- return this._extractChangesListInfo( $parsed, data.status );
- }.bind( this )
- );
- };
-
- /**
- * Track usage of highlight feature
- *
- * @param {string} action
- * @param {Array|Object|string} filters
- */
- mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) {
- filters = typeof filters === 'string' ? { name: filters } : filters;
- filters = !Array.isArray( filters ) ? [ filters ] : filters;
- mw.track(
- 'event.ChangesListHighlights',
- {
- action: action,
- filters: filters,
- userId: mw.user.getId()
- }
- );
- };
-
- /**
- * 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.findSelectedItems().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;
- }
- };
-
- /**
- * Apply a change of parameters to the model state, and check whether
- * the new state is different than the old state.
- *
- * @param {Object} newParamState New parameter state to apply
- * @return {boolean} New applied model state is different than the previous state
- */
- mw.rcfilters.Controller.prototype.applyParamChange = function ( newParamState ) {
- var after,
- before = this.filtersModel.getSelectedState();
-
- this.filtersModel.updateStateFromParams( newParamState );
-
- after = this.filtersModel.getSelectedState();
-
- return !OO.compare( before, after );
- };
-
- /**
- * Mark all changes as seen on Watchlist
- */
- mw.rcfilters.Controller.prototype.markAllChangesAsSeen = function () {
- var api = new mw.Api();
- api.postWithToken( 'csrf', {
- formatversion: 2,
- action: 'setnotificationtimestamp',
- entirewatchlist: true
- } ).then( function () {
- this.updateChangesList( null, 'markSeen' );
- }.bind( this ) );
- };
-
- /**
- * Set the current search for the system.
- *
- * @param {string} searchQuery Search query, including triggers
- */
- mw.rcfilters.Controller.prototype.setSearch = function ( searchQuery ) {
- this.filtersModel.setSearch( searchQuery );
- };
-
- /**
- * Switch the view by changing the search query trigger
- * without changing the search term
- *
- * @param {string} view View to change to
- */
- mw.rcfilters.Controller.prototype.switchView = function ( view ) {
- this.setSearch(
- this.filtersModel.getViewTrigger( view ) +
- this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
- );
- };
-
- /**
- * Reset the search for a specific view. This means we null the search query
- * and replace it with the relevant trigger for the requested view
- *
- * @param {string} [view='default'] View to change to
- */
- mw.rcfilters.Controller.prototype.resetSearchForView = function ( view ) {
- view = view || 'default';
-
- this.setSearch(
- this.filtersModel.getViewTrigger( view )
- );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Supported highlight colors.
- * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
- *
- * @member mw.rcfilters
- * @property {string[]}
- */
- mw.rcfilters.HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];
-}() );
+++ /dev/null
-( function () {
- /* eslint no-underscore-dangle: "off" */
- /**
- * URI Processor for RCFilters
- *
- * @class
- *
- * @constructor
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
- * @param {Object} [config] Configuration object
- * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
- * title normalization to separate title subpage/parts into the target= url
- * parameter
- */
- mw.rcfilters.UriProcessor = function MwRcfiltersController( filtersModel, config ) {
- config = config || {};
- this.filtersModel = filtersModel;
-
- this.normalizeTarget = !!config.normalizeTarget;
- };
-
- /* Initialization */
- OO.initClass( mw.rcfilters.UriProcessor );
-
- /* Static methods */
-
- /**
- * Replace the url history through replaceState
- *
- * @param {mw.Uri} newUri New URI to replace
- */
- mw.rcfilters.UriProcessor.static.replaceState = function ( newUri ) {
- window.history.replaceState(
- { tag: 'rcfilters' },
- document.title,
- newUri.toString()
- );
- };
-
- /**
- * Push the url to history through pushState
- *
- * @param {mw.Uri} newUri New URI to push
- */
- mw.rcfilters.UriProcessor.static.pushState = function ( newUri ) {
- window.history.pushState(
- { tag: 'rcfilters' },
- document.title,
- newUri.toString()
- );
- };
-
- /* Methods */
-
- /**
- * Get the version that this URL query is tagged with.
- *
- * @param {Object} [uriQuery] URI query
- * @return {number} URL version
- */
- mw.rcfilters.UriProcessor.prototype.getVersion = function ( uriQuery ) {
- uriQuery = uriQuery || new mw.Uri().query;
-
- return Number( uriQuery.urlversion || 1 );
- };
-
- /**
- * Get an updated mw.Uri object based on the model state
- *
- * @param {mw.Uri} [uri] An external URI to build the new uri
- * with. This is mainly for tests, to be able to supply external query
- * parameters and make sure they are retained.
- * @return {mw.Uri} Updated Uri
- */
- mw.rcfilters.UriProcessor.prototype.getUpdatedUri = function ( uri ) {
- var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
- unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );
-
- normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
- $.extend(
- true,
- {},
- normalizedUri.query,
- // The representation must be expanded so it can
- // override the uri query params but we then output
- // a minimized version for the entire URI representation
- // for the method
- this.filtersModel.getExpandedParamRepresentation()
- )
- );
-
- // Reapply unrecognized params and url version
- normalizedUri.query = $.extend(
- true,
- {},
- normalizedUri.query,
- unrecognizedParams,
- { urlversion: '2' }
- );
-
- return normalizedUri;
- };
-
- /**
- * Move the subpage to the target parameter
- *
- * @param {mw.Uri} uri
- * @return {mw.Uri}
- * @private
- */
- mw.rcfilters.UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
- var parts,
- // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
- re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
-
- if ( !this.normalizeTarget ) {
- return uri;
- }
-
- // target in title param
- if ( uri.query.title ) {
- parts = uri.query.title.match( re );
- if ( parts ) {
- uri.query.title = parts[ 1 ];
- uri.query.target = parts[ 2 ];
- }
- }
-
- // target in path
- parts = mw.Uri.decode( uri.path ).match( re );
- if ( parts ) {
- uri.path = parts[ 1 ];
- uri.query.target = parts[ 2 ];
- }
-
- return uri;
- };
-
- /**
- * Get an object representing given parameters that are unrecognized by the model
- *
- * @param {Object} params Full params object
- * @return {Object} Unrecognized params
- */
- mw.rcfilters.UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
- // Start with full representation
- var givenParamNames = Object.keys( params ),
- unrecognizedParams = $.extend( true, {}, params );
-
- // Extract unrecognized parameters
- Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
- // Remove recognized params
- if ( givenParamNames.indexOf( paramName ) > -1 ) {
- delete unrecognizedParams[ paramName ];
- }
- } );
-
- return unrecognizedParams;
- };
-
- /**
- * Update the URL of the page to reflect current filters
- *
- * This should not be called directly from outside the controller.
- * If an action requires changing the URL, it should either use the
- * highlighting actions below, or call #updateChangesList which does
- * the uri corrections already.
- *
- * @param {Object} [params] Extra parameters to add to the API call
- */
- mw.rcfilters.UriProcessor.prototype.updateURL = function ( params ) {
- var currentUri = new mw.Uri(),
- updatedUri = this.getUpdatedUri();
-
- updatedUri.extend( params || {} );
-
- if (
- this.getVersion( currentUri.query ) !== 2 ||
- this.isNewState( currentUri.query, updatedUri.query )
- ) {
- this.constructor.static.replaceState( updatedUri );
- }
- };
-
- /**
- * Update the filters model based on the URI query
- * This happens on initialization, and from this moment on,
- * we consider the system synchronized, and the model serves
- * as the source of truth for the URL.
- *
- * This methods should only be called once on initialization.
- * After initialization, the model updates the URL, not the
- * other way around.
- *
- * @param {Object} [uriQuery] URI query
- */
- mw.rcfilters.UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
- uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
- this.filtersModel.updateStateFromParams(
- this._getNormalizedQueryParams( uriQuery )
- );
- };
-
- /**
- * Compare two URI queries to decide whether they are different
- * enough to represent a new state.
- *
- * @param {Object} currentUriQuery Current Uri query
- * @param {Object} updatedUriQuery Updated Uri query
- * @return {boolean} This is a new state
- */
- mw.rcfilters.UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
- var currentParamState, updatedParamState,
- notEquivalent = function ( obj1, obj2 ) {
- var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
- return keys.some( function ( key ) {
- return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
- } );
- };
-
- // Compare states instead of parameters
- // This will allow us to always have a proper check of whether
- // the requested new url is one to change or not, regardless of
- // actual parameter visibility/representation in the URL
- currentParamState = $.extend(
- true,
- {},
- this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
- this.getUnrecognizedParams( currentUriQuery )
- );
- updatedParamState = $.extend(
- true,
- {},
- this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
- this.getUnrecognizedParams( updatedUriQuery )
- );
-
- return notEquivalent( currentParamState, updatedParamState );
- };
-
- /**
- * Check whether the given query has parameters that are
- * recognized as parameters we should load the system with
- *
- * @param {mw.Uri} [uriQuery] Given URI query
- * @return {boolean} Query contains valid recognized parameters
- */
- mw.rcfilters.UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
- var anyValidInUrl,
- validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );
-
- uriQuery = uriQuery || new mw.Uri().query;
-
- anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
- return validParameterNames.indexOf( parameter ) > -1;
- } );
-
- // URL version 2 is allowed to be empty or within nonrecognized params
- return anyValidInUrl || this.getVersion( uriQuery ) === 2;
- };
-
- /**
- * Get the adjusted URI params based on the url version
- * If the urlversion is not 2, the parameters are merged with
- * the model's defaults.
- * Always merge in the hidden parameter defaults.
- *
- * @private
- * @param {Object} uriQuery Current URI query
- * @return {Object} Normalized parameters
- */
- mw.rcfilters.UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
- // Check whether we are dealing with urlversion=2
- // If we are, we do not merge the initial request with
- // defaults. Not having urlversion=2 means we need to
- // reproduce the server-side request and merge the
- // requested parameters (or starting state) with the
- // 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();
-
- return $.extend(
- true,
- {},
- this.filtersModel.getMinimizedParamRepresentation(
- $.extend( true, {}, base, uriQuery )
- ),
- { urlversion: '2' }
- );
- };
-}() );
* JavaScript for Special:RecentChanges
*/
( function () {
- var rcfilters = {
- /**
- * @member mw.rcfilters
- * @private
- */
- init: function () {
- var $topSection,
- mainWrapperWidget,
- conditionalViews = {},
- $initialFieldset = $( 'fieldset.cloptions' ),
- savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
- daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
- limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
- activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
- initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
- filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
- changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
- savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
- specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
- controller = new mw.rcfilters.Controller(
- filtersModel, changesListModel, savedQueriesModel,
- {
- savedQueriesPreferenceName: savedQueriesPreferenceName,
- daysPreferenceName: daysPreferenceName,
- limitPreferenceName: limitPreferenceName,
- collapsedPreferenceName: activeFiltersCollapsedName,
- normalizeTarget: specialPage === 'Recentchangeslinked'
- }
- );
-
- // TODO: The changesListWrapperWidget should be able to initialize
- // after the model is ready.
-
- if ( specialPage === 'Recentchanges' ) {
- $topSection = $( '.mw-recentchanges-toplinks' ).detach();
- } else if ( specialPage === 'Watchlist' ) {
- $( '#contentSub, form#mw-watchlist-resetbutton' ).remove();
- $topSection = $( '.watchlistDetails' ).detach().contents();
- } else if ( specialPage === 'Recentchangeslinked' ) {
- conditionalViews.recentChangesLinked = {
- groups: [
- {
- name: 'page',
- type: 'any_value',
- title: '',
- hidden: true,
- sticky: true,
- filters: [
- {
- name: 'target',
- default: ''
- }
- ]
- },
- {
- name: 'toOrFrom',
- type: 'boolean',
- title: '',
- hidden: true,
- sticky: true,
- filters: [
- {
- name: 'showlinkedto',
- default: false
- }
- ]
- }
- ]
- };
- }
- mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
- controller,
- filtersModel,
- savedQueriesModel,
- changesListModel,
+ mw.rcfilters.HighlightColors = require( './HighlightColors.js' );
+ mw.rcfilters.ui.MainWrapperWidget = require( './ui/MainWrapperWidget.js' );
+
+ /**
+ * Get list of namespaces and remove unused ones
+ *
+ * @member mw.rcfilters
+ * @private
+ *
+ * @param {Array} unusedNamespaces Names of namespaces to remove
+ * @return {Array} Filtered array of namespaces
+ */
+ function getNamespaces( unusedNamespaces ) {
+ var i, length, name, id,
+ namespaceIds = mw.config.get( 'wgNamespaceIds' ),
+ namespaces = mw.config.get( 'wgFormattedNamespaces' );
+
+ for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
+ name = unusedNamespaces[ i ];
+ id = namespaceIds[ name.toLowerCase() ];
+ delete namespaces[ id ];
+ }
+
+ return namespaces;
+ }
+
+ /**
+ * @member mw.rcfilters
+ * @private
+ */
+ function init() {
+ var $topSection,
+ mainWrapperWidget,
+ conditionalViews = {},
+ $initialFieldset = $( 'fieldset.cloptions' ),
+ savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
+ daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
+ limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
+ activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
+ initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
+ filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+ changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
+ savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
+ specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
+ controller = new mw.rcfilters.Controller(
+ filtersModel, changesListModel, savedQueriesModel,
{
- $wrapper: $( 'body' ),
- $topSection: $topSection,
- $filtersContainer: $( '.rcfilters-container' ),
- $changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
- $formContainer: $initialFieldset,
- collapsed: initialCollapsedState
+ savedQueriesPreferenceName: savedQueriesPreferenceName,
+ daysPreferenceName: daysPreferenceName,
+ limitPreferenceName: limitPreferenceName,
+ collapsedPreferenceName: activeFiltersCollapsedName,
+ normalizeTarget: specialPage === 'Recentchangeslinked'
}
);
- // Remove the -loading class that may have been added on the server side.
- // If we are in fact going to load a default saved query, this .initialize()
- // call will do that and add the -loading class right back.
- $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
-
- controller.initialize(
- mw.config.get( 'wgStructuredChangeFilters' ),
- // All namespaces without Media namespace
- rcfilters.getNamespaces( [ 'Media' ] ),
- mw.config.get( 'wgRCFiltersChangeTags' ),
- conditionalViews
- );
+ // TODO: The changesListWrapperWidget should be able to initialize
+ // after the model is ready.
+
+ if ( specialPage === 'Recentchanges' ) {
+ $topSection = $( '.mw-recentchanges-toplinks' ).detach();
+ } else if ( specialPage === 'Watchlist' ) {
+ $( '#contentSub, form#mw-watchlist-resetbutton' ).remove();
+ $topSection = $( '.watchlistDetails' ).detach().contents();
+ } else if ( specialPage === 'Recentchangeslinked' ) {
+ conditionalViews.recentChangesLinked = {
+ groups: [
+ {
+ name: 'page',
+ type: 'any_value',
+ title: '',
+ hidden: true,
+ sticky: true,
+ filters: [
+ {
+ name: 'target',
+ default: ''
+ }
+ ]
+ },
+ {
+ name: 'toOrFrom',
+ type: 'boolean',
+ title: '',
+ hidden: true,
+ sticky: true,
+ filters: [
+ {
+ name: 'showlinkedto',
+ default: false
+ }
+ ]
+ }
+ ]
+ };
+ }
+
+ mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
+ controller,
+ filtersModel,
+ savedQueriesModel,
+ changesListModel,
+ {
+ $wrapper: $( 'body' ),
+ $topSection: $topSection,
+ $filtersContainer: $( '.rcfilters-container' ),
+ $changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
+ $formContainer: $initialFieldset,
+ collapsed: initialCollapsedState
+ }
+ );
- mainWrapperWidget.initFormWidget( specialPage );
+ // Remove the -loading class that may have been added on the server side.
+ // If we are in fact going to load a default saved query, this .initialize()
+ // call will do that and add the -loading class right back.
+ $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
- $( 'a.mw-helplink' ).attr(
- 'href',
- 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
- );
+ controller.initialize(
+ mw.config.get( 'wgStructuredChangeFilters' ),
+ // All namespaces without Media namespace
+ getNamespaces( [ 'Media' ] ),
+ mw.config.get( 'wgRCFiltersChangeTags' ),
+ conditionalViews
+ );
+
+ mainWrapperWidget.initFormWidget( specialPage );
- controller.replaceUrl();
+ $( 'a.mw-helplink' ).attr(
+ 'href',
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
+ );
- mainWrapperWidget.setTopSection( specialPage );
+ controller.replaceUrl();
- /**
- * Fired when initialization of the filtering interface for changes list is complete.
- *
- * @event structuredChangeFilters_ui_initialized
- * @member mw.hook
- */
- mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
- },
+ mainWrapperWidget.setTopSection( specialPage );
/**
- * Get list of namespaces and remove unused ones
+ * Fired when initialization of the filtering interface for changes list is complete.
*
- * @member mw.rcfilters
- * @private
- *
- * @param {Array} unusedNamespaces Names of namespaces to remove
- * @return {Array} Filtered array of namespaces
+ * @event structuredChangeFilters_ui_initialized
+ * @member mw.hook
*/
- getNamespaces: function ( unusedNamespaces ) {
- var i, length, name, id,
- namespaceIds = mw.config.get( 'wgNamespaceIds' ),
- namespaces = mw.config.get( 'wgFormattedNamespaces' );
-
- for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
- name = unusedNamespaces[ i ];
- id = namespaceIds[ name.toLowerCase() ];
- delete namespaces[ id ];
- }
-
- return namespaces;
- }
- };
+ mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
+ }
// Import i18n messages from config
mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) );
// Early execute of init
if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
- rcfilters.init();
+ init();
} else {
- $( rcfilters.init );
+ $( init );
}
- module.exports = rcfilters;
+ module.exports = mw.rcfilters;
}() );
* @singleton
*/
mw.rcfilters = {
- dm: {},
+ Controller: require( './Controller.js' ),
+ UriProcessor: require( './UriProcessor.js' ),
+ dm: {
+ ChangesListViewModel: require( './dm/ChangesListViewModel.js' ),
+ FilterGroup: require( './dm/FilterGroup.js' ),
+ FilterItem: require( './dm/FilterItem.js' ),
+ FiltersViewModel: require( './dm/FiltersViewModel.js' ),
+ ItemModel: require( './dm/ItemModel.js' ),
+ SavedQueriesModel: require( './dm/SavedQueriesModel.js' ),
+ SavedQueryItemModel: require( './dm/SavedQueryItemModel.js' )
+ },
ui: {},
utils: {
addArrayElementsUnique: function ( arr, elements ) {
}
}
};
+
+ module.exports = mw.rcfilters;
}() );
--- /dev/null
+( function () {
+ var ChangesLimitPopupWidget = require( './ChangesLimitPopupWidget.js' ),
+ DatePopupWidget = require( './DatePopupWidget.js' ),
+ ChangesLimitAndDateButtonWidget;
+
+ /**
+ * Widget defining the button controlling the popup for the number of results
+ *
+ * @class mw.rcfilters.ui.ChangesLimitAndDateButtonWidget
+ * @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
+ */
+ ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
+ config = config || {};
+
+ // Parent
+ ChangesLimitAndDateButtonWidget.parent.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+
+ this.$overlay = config.$overlay || this.$element;
+
+ this.button = null;
+ this.limitGroupModel = null;
+ this.groupByPageItemModel = null;
+ this.daysGroupModel = null;
+
+ this.model.connect( this, {
+ initialize: 'onModelInitialize'
+ } );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( ChangesLimitAndDateButtonWidget, OO.ui.Widget );
+
+ /**
+ * Respond to model initialize event
+ */
+ ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
+ var changesLimitPopupWidget, selectedItem, currentValue, datePopupWidget,
+ displayGroupModel = this.model.getGroup( 'display' );
+
+ this.limitGroupModel = this.model.getGroup( 'limit' );
+ this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
+ 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.limitGroupModel && this.daysGroupModel ) {
+ changesLimitPopupWidget = new ChangesLimitPopupWidget(
+ this.limitGroupModel,
+ this.groupByPageItemModel
+ );
+
+ datePopupWidget = new DatePopupWidget(
+ this.daysGroupModel,
+ {
+ label: mw.msg( 'rcfilters-date-popup-title' )
+ }
+ );
+
+ selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
+ currentValue = ( selectedItem && selectedItem.getLabel() ) ||
+ mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );
+
+ this.button = new OO.ui.PopupButtonWidget( {
+ icon: 'settings',
+ indicator: 'down',
+ label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
+ $overlay: this.$overlay,
+ popup: {
+ width: 300,
+ padded: false,
+ anchor: false,
+ align: 'backwards',
+ $autoCloseIgnore: this.$overlay,
+ $content: $( '<div>' ).append(
+ // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
+ changesLimitPopupWidget.$element,
+ datePopupWidget.$element
+ )
+ }
+ } );
+ this.updateButtonLabel();
+
+ // Events
+ this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
+ this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
+ changesLimitPopupWidget.connect( this, {
+ limit: 'onPopupLimit',
+ groupByPage: 'onPopupGroupByPage'
+ } );
+ datePopupWidget.connect( this, { days: 'onPopupDays' } );
+
+ this.$element.append( this.button.$element );
+ }
+ };
+
+ /**
+ * Respond to popup limit change event
+ *
+ * @param {string} filterName Chosen filter name
+ */
+ ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
+ var item = this.limitGroupModel.getItemByName( filterName );
+
+ this.controller.toggleFilterSelect( filterName, true );
+ this.controller.updateLimitDefault( item.getParamName() );
+ this.button.popup.toggle( false );
+ };
+
+ /**
+ * Respond to popup limit change event
+ *
+ * @param {boolean} isGrouped The result set is grouped by page
+ */
+ ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
+ this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
+ this.controller.updateGroupByPageDefault( isGrouped );
+ this.button.popup.toggle( false );
+ };
+
+ /**
+ * Respond to popup limit change event
+ *
+ * @param {string} filterName Chosen filter name
+ */
+ ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
+ var item = this.daysGroupModel.getItemByName( filterName );
+
+ this.controller.toggleFilterSelect( filterName, true );
+ this.controller.updateDaysDefault( item.getParamName() );
+ this.button.popup.toggle( false );
+ };
+
+ /**
+ * Respond to limit choose event
+ *
+ * @param {string} filterName Filter name
+ */
+ ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
+ var message,
+ limit = this.limitGroupModel.findSelectedItems()[ 0 ],
+ label = limit && limit.getLabel(),
+ days = this.daysGroupModel.findSelectedItems()[ 0 ],
+ daysParamName = Number( days.getParamName() ) < 1 ?
+ 'rcfilters-days-show-hours' :
+ 'rcfilters-days-show-days';
+
+ // Update the label
+ if ( label && days ) {
+ message = mw.msg( 'rcfilters-limit-and-date-label', label,
+ mw.msg( daysParamName, days.getLabel() )
+ );
+ this.button.setLabel( message );
+ }
+ };
+
+ module.exports = ChangesLimitAndDateButtonWidget;
+
+}() );
--- /dev/null
+( function () {
+ var ValuePickerWidget = require( './ValuePickerWidget.js' ),
+ ChangesLimitPopupWidget;
+
+ /**
+ * Widget defining the popup to choose number of results
+ *
+ * @class mw.rcfilters.ui.ChangesLimitPopupWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
+ * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
+ * @param {Object} [config] Configuration object
+ */
+ ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
+ config = config || {};
+
+ // Parent
+ ChangesLimitPopupWidget.parent.call( this, config );
+
+ this.limitModel = limitModel;
+ this.groupByPageItemModel = groupByPageItemModel;
+
+ this.valuePicker = new ValuePickerWidget(
+ this.limitModel,
+ {
+ label: mw.msg( 'rcfilters-limit-title' )
+ }
+ );
+
+ this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
+ selected: this.groupByPageItemModel.isSelected()
+ } );
+
+ // Events
+ this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
+ this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
+ this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
+
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
+ .append(
+ this.valuePicker.$element,
+ new OO.ui.FieldLayout(
+ this.groupByPageCheckbox,
+ {
+ align: 'inline',
+ label: mw.msg( 'rcfilters-group-results-by-page' )
+ }
+ ).$element
+ );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget );
+
+ /* Events */
+
+ /**
+ * @event limit
+ * @param {string} name Item name
+ *
+ * A limit item was chosen
+ */
+
+ /**
+ * @event groupByPage
+ * @param {boolean} isGrouped The results are grouped by page
+ *
+ * Results are grouped by page
+ */
+
+ /**
+ * Respond to group by page model update
+ */
+ ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
+ this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
+ };
+
+ module.exports = ChangesLimitPopupWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * List of changes
+ *
+ * @class mw.rcfilters.ui.ChangesListWrapperWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
+ * @param {mw.rcfilters.Controller} controller
+ * @param {jQuery} $changesListRoot Root element of the changes list to attach to
+ * @param {Object} [config] Configuration object
+ */
+ var ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
+ filtersViewModel,
+ changesListViewModel,
+ controller,
+ $changesListRoot,
+ config
+ ) {
+ config = $.extend( {}, config, {
+ $element: $changesListRoot
+ } );
+
+ // Parent
+ ChangesListWrapperWidget.parent.call( this, config );
+
+ this.filtersViewModel = filtersViewModel;
+ this.changesListViewModel = changesListViewModel;
+ this.controller = controller;
+ this.highlightClasses = null;
+
+ // Events
+ this.filtersViewModel.connect( this, {
+ itemUpdate: 'onItemUpdate',
+ highlightChange: 'onHighlightChange'
+ } );
+ this.changesListViewModel.connect( this, {
+ invalidate: 'onModelInvalidate',
+ update: 'onModelUpdate'
+ } );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
+ // We handle our own display/hide of the empty results message
+ // We keep the timeout class here and remove it later, since at this
+ // stage it is still needed to identify that the timeout occurred.
+ .removeClass( 'mw-changeslist-empty' );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( ChangesListWrapperWidget, OO.ui.Widget );
+
+ /**
+ * Get all available highlight classes
+ *
+ * @return {string[]} An array of available highlight class names
+ */
+ ChangesListWrapperWidget.prototype.getHighlightClasses = function () {
+ if ( !this.highlightClasses || !this.highlightClasses.length ) {
+ this.highlightClasses = this.filtersViewModel.getItemsSupportingHighlights()
+ .map( function ( filterItem ) {
+ return filterItem.getCssClass();
+ } );
+ }
+
+ return this.highlightClasses;
+ };
+
+ /**
+ * Respond to the highlight feature being toggled on and off
+ *
+ * @param {boolean} highlightEnabled
+ */
+ ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
+ if ( highlightEnabled ) {
+ this.applyHighlight();
+ } else {
+ this.clearHighlight();
+ }
+ };
+
+ /**
+ * Respond to a filter item model update
+ */
+ ChangesListWrapperWidget.prototype.onItemUpdate = function () {
+ if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
+ // this.controller.isInitialized() is still false during page load,
+ // we don't want to clear/apply highlights at this stage.
+ this.clearHighlight();
+ this.applyHighlight();
+ }
+ };
+
+ /**
+ * Respond to changes list model invalidate
+ */
+ ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
+ $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
+ };
+
+ /**
+ * Respond to changes list model update
+ *
+ * @param {jQuery|string} $changesListContent The content of the updated changes list
+ * @param {jQuery} $fieldset The content of the updated fieldset
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
+ * @param {boolean} from Timestamp of the new changes
+ */
+ ChangesListWrapperWidget.prototype.onModelUpdate = function (
+ $changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
+ ) {
+ var conflictItem,
+ $message = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
+ isEmpty = $changesListContent === 'NO_RESULTS',
+ // For enhanced mode, we have to load these modules, which are
+ // not loaded for the 'regular' mode in the backend
+ loaderPromise = mw.user.options.get( 'usenewrc' ) ?
+ mw.loader.using( [ 'mediawiki.special.changeslist.enhanced', 'mediawiki.icon' ] ) :
+ $.Deferred().resolve(),
+ widget = this;
+
+ this.$element.toggleClass( 'mw-changeslist', !isEmpty );
+ if ( isEmpty ) {
+ this.$element.empty();
+
+ if ( this.filtersViewModel.hasConflict() ) {
+ conflictItem = this.filtersViewModel.getFirstConflictedItem();
+
+ $message
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
+ .text( mw.message( 'rcfilters-noresults-conflict' ).text() ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
+ .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() )
+ );
+ } else {
+ $message
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
+ .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
+ );
+
+ // remove all classes matching mw-changeslist-*
+ this.$element.removeClass( function ( elementIndex, allClasses ) {
+ return allClasses
+ .split( ' ' )
+ .filter( function ( className ) {
+ return className.indexOf( 'mw-changeslist-' ) === 0;
+ } )
+ .join( ' ' );
+ } );
+ }
+
+ this.$element.append( $message );
+ } else {
+ if ( !isInitialDOM ) {
+ this.$element.empty().append( $changesListContent );
+
+ if ( from ) {
+ this.emphasizeNewChanges( from );
+ }
+ }
+
+ // Apply highlight
+ this.applyHighlight();
+
+ }
+
+ this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
+
+ loaderPromise.done( function () {
+ if ( !isInitialDOM && !isEmpty ) {
+ // Make sure enhanced RC re-initializes correctly
+ mw.hook( 'wikipage.content' ).fire( widget.$element );
+ }
+
+ $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
+ } );
+ };
+
+ /** Toggles overlay class on changes list
+ *
+ * @param {boolean} isVisible True if overlay should be visible
+ */
+ ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
+ this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
+ };
+
+ /**
+ * Map a reason for having no results to its message key
+ *
+ * @param {string} reason One of the NO_RESULTS_* "constant" that represent
+ * a reason for having no results
+ * @return {string} Key for the message that explains why there is no results in this case
+ */
+ ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
+ var reasonMsgKeyMap = {
+ NO_RESULTS_NORMAL: 'recentchanges-noresult',
+ NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
+ NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
+ NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
+ NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
+ };
+ return reasonMsgKeyMap[ reason ];
+ };
+
+ /**
+ * Emphasize the elements (or groups) newer than the 'from' parameter
+ * @param {string} from Anything newer than this is considered 'new'
+ */
+ ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
+ var $firstNew,
+ $indicator,
+ $newChanges = $( [] ),
+ selector = this.inEnhancedMode() ?
+ 'table.mw-enhanced-rc[data-mw-ts]' :
+ 'li[data-mw-ts]',
+ set = this.$element.find( selector ),
+ length = set.length;
+
+ set.each( function ( index ) {
+ var $this = $( this ),
+ ts = $this.data( 'mw-ts' );
+
+ if ( ts >= from ) {
+ $newChanges = $newChanges.add( $this );
+ $firstNew = $this;
+
+ // guards against putting the marker after the last element
+ if ( index === ( length - 1 ) ) {
+ $firstNew = null;
+ }
+ }
+ } );
+
+ if ( $firstNew ) {
+ $indicator = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
+
+ $firstNew.after( $indicator );
+ }
+
+ // FIXME: Use CSS transition
+ // eslint-disable-next-line jquery/no-fade
+ $newChanges
+ .hide()
+ .fadeIn( 1000 );
+ };
+
+ /**
+ * In enhanced mode, we need to check whether the grouped results all have the
+ * same active highlights in order to see whether the "parent" of the group should
+ * be grey or highlighted normally.
+ *
+ * This is called every time highlights are applied.
+ */
+ ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
+ var activeHighlightClasses,
+ $enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc.mw-collapsible' );
+
+ activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
+ return 'mw-rcfilters-highlight-color-' + color;
+ } );
+
+ // Go over top pages and their children, and figure out if all sub-pages have the
+ // same highlights between themselves. If they do, the parent should be highlighted
+ // with all colors. If classes are different, the parent should receive a grey
+ // background
+ $enhancedTopPageCell.each( function () {
+ var firstChildClasses, $rowsWithDifferentHighlights,
+ $table = $( this );
+
+ // Collect the relevant classes from the first nested child
+ firstChildClasses = activeHighlightClasses.filter( function ( className ) {
+ return $table.find( 'tr:nth-child(2)' ).hasClass( className );
+ } );
+ // Filter the non-head rows and see if they all have the same classes
+ // to the first row
+ $rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
+ var classesInThisRow,
+ $this = $( this );
+
+ classesInThisRow = activeHighlightClasses.filter( function ( className ) {
+ return $this.hasClass( className );
+ } );
+
+ return !OO.compare( firstChildClasses, classesInThisRow );
+ } );
+
+ // If classes are different, tag the row for using grey color
+ $table.find( 'tr:first-child' )
+ .toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
+ } );
+ };
+
+ /**
+ * @return {boolean} Whether the changes are grouped by page
+ */
+ ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
+ var uri = new mw.Uri();
+ return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
+ ( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
+ };
+
+ /**
+ * Apply color classes based on filters highlight configuration
+ */
+ ChangesListWrapperWidget.prototype.applyHighlight = function () {
+ if ( !this.filtersViewModel.isHighlightEnabled() ) {
+ return;
+ }
+
+ this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
+ var $elements = this.$element.find( '.' + filterItem.getCssClass() );
+
+ // Add highlight class to all highlighted list items
+ $elements
+ .addClass(
+ 'mw-rcfilters-highlighted ' +
+ 'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
+ );
+
+ // Track the filters for each item in .data( 'highlightedFilters' )
+ $elements.each( function () {
+ var filters = $( this ).data( 'highlightedFilters' );
+ if ( !filters ) {
+ filters = [];
+ $( this ).data( 'highlightedFilters', filters );
+ }
+ if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
+ filters.push( filterItem.getLabel() );
+ }
+ } );
+ }.bind( this ) );
+ // Apply a title to each highlighted item, with a list of filters
+ this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
+ var filters = $( this ).data( 'highlightedFilters' );
+
+ if ( filters && filters.length ) {
+ $( this ).attr( 'title', mw.msg(
+ 'rcfilters-highlighted-filters-list',
+ filters.join( mw.msg( 'comma-separator' ) )
+ ) );
+ }
+
+ } );
+ if ( this.inEnhancedMode() ) {
+ this.updateEnhancedParentHighlight();
+ }
+
+ // Turn on highlights
+ this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
+ };
+
+ /**
+ * Remove all color classes
+ */
+ ChangesListWrapperWidget.prototype.clearHighlight = function () {
+ // Remove highlight classes
+ mw.rcfilters.HighlightColors.forEach( function ( color ) {
+ this.$element
+ .find( '.mw-rcfilters-highlight-color-' + color )
+ .removeClass( 'mw-rcfilters-highlight-color-' + color );
+ }.bind( this ) );
+
+ this.$element.find( '.mw-rcfilters-highlighted' )
+ .removeAttr( 'title' )
+ .removeData( 'highlightedFilters' )
+ .removeClass( 'mw-rcfilters-highlighted' );
+
+ // Remove grey from enhanced rows
+ this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
+ .removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );
+
+ // Turn off highlights
+ this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
+ };
+
+ module.exports = ChangesListWrapperWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * A widget representing a single toggle filter
+ *
+ * @class mw.rcfilters.ui.CheckboxInputWidget
+ * @extends OO.ui.CheckboxInputWidget
+ *
+ * @constructor
+ * @param {Object} config Configuration object
+ */
+ var CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
+ config = config || {};
+
+ // Parent
+ CheckboxInputWidget.parent.call( this, config );
+
+ // Event
+ this.$input
+ // HACK: This widget just pretends to be a checkbox for visual purposes.
+ // In reality, all actions - setting to true or false, etc - are
+ // decided by the model, and executed by the controller. This means
+ // that we want to let the controller and model make the decision
+ // of whether to check/uncheck this checkboxInputWidget, and for that,
+ // we have to bypass the browser action that checks/unchecks it during
+ // click.
+ .on( 'click', false )
+ .on( 'change', this.onUserChange.bind( this ) );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget );
+
+ /* Events */
+
+ /**
+ * @event userChange
+ * @param {boolean} Current state of the checkbox
+ *
+ * The user has checked or unchecked this checkbox
+ */
+
+ /* Methods */
+
+ /**
+ * @inheritdoc
+ */
+ CheckboxInputWidget.prototype.onEdit = function () {
+ // Similarly to preventing defaults in 'click' event, we want
+ // to prevent this widget from deciding anything about its own
+ // state; it emits a change event and the model and controller
+ // make a decision about what its select state is.
+ // onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
+ // so we really want to prevent that from messing with what
+ // the model decides the state of the widget is.
+ };
+
+ /**
+ * Respond to checkbox change by a user and emit 'userChange'.
+ */
+ CheckboxInputWidget.prototype.onUserChange = function () {
+ this.emit( 'userChange', this.$input.prop( 'checked' ) );
+ };
+
+ module.exports = CheckboxInputWidget;
+}() );
--- /dev/null
+( function () {
+ var ValuePickerWidget = require( './ValuePickerWidget.js' ),
+ DatePopupWidget;
+
+ /**
+ * Widget defining the popup to choose date for the results
+ *
+ * @class mw.rcfilters.ui.DatePopupWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days'
+ * @param {Object} [config] Configuration object
+ */
+ DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) {
+ config = config || {};
+
+ // Parent
+ DatePopupWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.LabelElement.call( this, config );
+
+ this.model = model;
+
+ this.hoursValuePicker = new 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 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.$label
+ .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
+ this.hoursValuePicker.$element,
+ this.daysValuePicker.$element
+ );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( DatePopupWidget, OO.ui.Widget );
+ OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement );
+
+ /* Events */
+
+ /**
+ * @event days
+ * @param {string} name Item name
+ *
+ * A days item was chosen
+ */
+
+ module.exports = DatePopupWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * A button to configure highlight for a filter item
+ *
+ * @class mw.rcfilters.ui.FilterItemHighlightButton
+ * @extends OO.ui.PopupButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FilterItem} model Filter item model
+ * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
+ * @param {Object} [config] Configuration object
+ */
+ var FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
+ config = config || {};
+
+ // Parent
+ FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
+ icon: 'highlight',
+ indicator: 'down'
+ } ) );
+
+ this.controller = controller;
+ this.model = model;
+ this.popup = highlightPopup;
+
+ // Event
+ this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+ // This lives inside a MenuOptionWidget, which intercepts mousedown
+ // to select the item. We want to prevent that when we click the highlight
+ // button
+ this.$element.on( 'mousedown', function ( e ) {
+ e.stopPropagation();
+ } );
+
+ this.updateUiBasedOnModel();
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( FilterItemHighlightButton, OO.ui.PopupButtonWidget );
+
+ /* Static Properties */
+
+ /**
+ * @static
+ */
+ FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;
+
+ /* Methods */
+
+ FilterItemHighlightButton.prototype.onAction = function () {
+ this.popup.setAssociatedButton( this );
+ this.popup.setFilterItem( this.model );
+
+ // Parent method
+ FilterItemHighlightButton.parent.prototype.onAction.call( this );
+ };
+
+ /**
+ * Respond to item model update event
+ */
+ FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
+ var currentColor = this.model.getHighlightColor(),
+ widget = this;
+
+ this.$icon.toggleClass(
+ 'mw-rcfilters-ui-filterItemHighlightButton-circle',
+ currentColor !== null
+ );
+
+ mw.rcfilters.HighlightColors.forEach( function ( c ) {
+ widget.$icon
+ .toggleClass(
+ 'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
+ c === currentColor
+ );
+ } );
+ };
+
+ module.exports = FilterItemHighlightButton;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Menu header for the RCFilters filters menu
+ *
+ * @class mw.rcfilters.ui.FilterMenuHeaderWidget
+ * @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
+ */
+ var FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
+ config = config || {};
+
+ this.controller = controller;
+ this.model = model;
+ this.$overlay = config.$overlay || this.$element;
+
+ // Parent
+ FilterMenuHeaderWidget.parent.call( this, config );
+ OO.ui.mixin.LabelElement.call( this, $.extend( {
+ label: mw.msg( 'rcfilters-filterlist-title' ),
+ $label: $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
+ }, config ) );
+
+ // "Back" to default view button
+ this.backButton = new OO.ui.ButtonWidget( {
+ icon: 'previous',
+ framed: false,
+ title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
+ classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
+ } );
+ this.backButton.toggle( this.model.getCurrentView() !== 'default' );
+
+ // Help icon for Tagged edits
+ this.helpIcon = new OO.ui.ButtonWidget( {
+ icon: 'helpNotice',
+ framed: false,
+ title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
+ classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
+ href: mw.util.getUrl( 'Special:Tags' ),
+ target: '_blank'
+ } );
+ this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );
+
+ // Highlight button
+ this.highlightButton = new OO.ui.ToggleButtonWidget( {
+ icon: 'highlight',
+ label: mw.message( 'rcfilters-highlightbutton-title' ).text(),
+ classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
+ } );
+
+ // Invert namespaces button
+ this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
+ icon: '',
+ classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
+ } );
+ this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
+
+ // Events
+ this.backButton.connect( this, { click: 'onBackButtonClick' } );
+ this.highlightButton
+ .connect( this, { click: 'onHighlightButtonClick' } );
+ this.invertNamespacesButton
+ .connect( this, { click: 'onInvertNamespacesButtonClick' } );
+ this.model.connect( this, {
+ highlightChange: 'onModelHighlightChange',
+ searchChange: 'onModelSearchChange',
+ initialize: 'onModelInitialize'
+ } );
+ this.view = this.model.getCurrentView();
+
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
+ .append( this.backButton.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
+ .append( this.$label, this.helpIcon.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
+ .append( this.invertNamespacesButton.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
+ .append( this.highlightButton.$element )
+ )
+ )
+ );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( FilterMenuHeaderWidget, OO.ui.Widget );
+ OO.mixinClass( FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );
+
+ /* Methods */
+
+ /**
+ * Respond to model initialization event
+ *
+ * Note: need to wait for initialization before getting the invertModel
+ * and registering its update event. Creating all the models before the UI
+ * would help with that.
+ */
+ FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
+ this.invertModel = this.model.getInvertModel();
+ this.updateInvertButton();
+ this.invertModel.connect( this, { update: 'updateInvertButton' } );
+ };
+
+ /**
+ * Respond to model update event
+ */
+ FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
+ var currentView = this.model.getCurrentView();
+
+ if ( this.view !== currentView ) {
+ this.setLabel( this.model.getViewTitle( currentView ) );
+
+ this.invertNamespacesButton.toggle( currentView === 'namespaces' );
+ this.backButton.toggle( currentView !== 'default' );
+ this.helpIcon.toggle( currentView === 'tags' );
+ this.view = currentView;
+ }
+ };
+
+ /**
+ * Respond to model highlight change event
+ *
+ * @param {boolean} highlightEnabled Highlight is enabled
+ */
+ FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
+ this.highlightButton.setActive( highlightEnabled );
+ };
+
+ /**
+ * Update the state of the invert button
+ */
+ FilterMenuHeaderWidget.prototype.updateInvertButton = function () {
+ this.invertNamespacesButton.setActive( this.invertModel.isSelected() );
+ this.invertNamespacesButton.setLabel(
+ this.invertModel.isSelected() ?
+ mw.msg( 'rcfilters-exclude-button-on' ) :
+ mw.msg( 'rcfilters-exclude-button-off' )
+ );
+ };
+
+ FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
+ this.controller.switchView( 'default' );
+ };
+
+ /**
+ * Respond to highlight button click
+ */
+ FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
+ this.controller.toggleHighlight();
+ };
+
+ /**
+ * Respond to highlight button click
+ */
+ FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
+ this.controller.toggleInvertedNamespaces();
+ };
+
+ module.exports = FilterMenuHeaderWidget;
+}() );
--- /dev/null
+( function () {
+ var ItemMenuOptionWidget = require( './ItemMenuOptionWidget.js' ),
+ FilterMenuOptionWidget;
+
+ /**
+ * A widget representing a single toggle filter
+ *
+ * @class mw.rcfilters.ui.FilterMenuOptionWidget
+ * @extends mw.rcfilters.ui.ItemMenuOptionWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.FilterItem} invertModel
+ * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
+ * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
+ * @param {Object} config Configuration object
+ */
+ FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
+ controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
+ ) {
+ config = config || {};
+
+ this.controller = controller;
+ this.invertModel = invertModel;
+ this.model = itemModel;
+
+ // Parent
+ FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
+
+ // Event
+ this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
+ };
+
+ /* Initialization */
+ OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget );
+
+ /* Static properties */
+
+ // We do our own scrolling to top
+ FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+
+ /* Methods */
+
+ /**
+ * @inheritdoc
+ */
+ FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+ // Parent
+ FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
+
+ this.setCurrentMuteState();
+ };
+
+ /**
+ * Respond to item group model update event
+ */
+ FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
+ this.setCurrentMuteState();
+ };
+
+ /**
+ * Set the current muted view of the widget based on its state
+ */
+ FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
+ if (
+ this.model.getGroupModel().getView() === 'namespaces' &&
+ this.invertModel.isSelected()
+ ) {
+ // This is an inverted behavior than the other rules, specifically
+ // for inverted namespaces
+ this.setFlags( {
+ muted: this.model.isSelected()
+ } );
+ } else {
+ this.setFlags( {
+ muted: (
+ this.model.isConflicted() ||
+ (
+ // Item is also muted when any of the items in its group is active
+ this.model.getGroupModel().isActive() &&
+ // But it isn't selected
+ !this.model.isSelected() &&
+ // And also not included
+ !this.model.isIncluded()
+ )
+ )
+ } );
+ }
+ };
+
+ module.exports = FilterMenuOptionWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * A widget representing a menu section for filter groups
+ *
+ * @class mw.rcfilters.ui.FilterMenuSectionOptionWidget
+ * @extends OO.ui.MenuSectionOptionWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] Overlay
+ */
+ var FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
+ var whatsThisMessages,
+ $header = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
+ $popupContent = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );
+
+ config = config || {};
+
+ this.controller = controller;
+ this.model = model;
+ this.$overlay = config.$overlay || this.$element;
+
+ // Parent
+ FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
+ label: this.model.getTitle(),
+ $label: $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
+ }, config ) );
+
+ $header.append( this.$label );
+
+ if ( this.model.hasWhatsThis() ) {
+ whatsThisMessages = this.model.getWhatsThis();
+
+ // Create popup
+ if ( whatsThisMessages.header ) {
+ $popupContent.append(
+ ( new OO.ui.LabelWidget( {
+ label: mw.msg( whatsThisMessages.header ),
+ classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
+ } ) ).$element
+ );
+ }
+ if ( whatsThisMessages.body ) {
+ $popupContent.append(
+ ( new OO.ui.LabelWidget( {
+ label: mw.msg( whatsThisMessages.body ),
+ classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
+ } ) ).$element
+ );
+ }
+ if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
+ $popupContent.append(
+ ( new OO.ui.ButtonWidget( {
+ framed: false,
+ flags: [ 'progressive' ],
+ href: whatsThisMessages.url,
+ label: mw.msg( whatsThisMessages.linkText ),
+ classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
+ } ) ).$element
+ );
+ }
+
+ // Add button
+ this.whatsThisButton = new OO.ui.PopupButtonWidget( {
+ framed: false,
+ label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
+ $overlay: this.$overlay,
+ classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
+ flags: [ 'progressive' ],
+ popup: {
+ padded: false,
+ align: 'center',
+ position: 'above',
+ $content: $popupContent,
+ classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
+ }
+ } );
+
+ $header
+ .append( this.whatsThisButton.$element );
+ }
+
+ // Events
+ this.model.connect( this, { update: 'updateUiBasedOnState' } );
+
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
+ .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
+ .append( $header );
+ this.updateUiBasedOnState();
+ };
+
+ /* Initialize */
+
+ OO.inheritClass( FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );
+
+ /* Methods */
+
+ /**
+ * Respond to model update event
+ */
+ FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
+ this.$element.toggleClass(
+ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
+ this.model.isActive()
+ );
+ this.toggle( this.model.isVisible() );
+ };
+
+ /**
+ * Get the group name
+ *
+ * @return {string} Group name
+ */
+ FilterMenuSectionOptionWidget.prototype.getName = function () {
+ return this.model.getName();
+ };
+
+ module.exports = FilterMenuSectionOptionWidget;
+
+}() );
--- /dev/null
+( function () {
+ var TagItemWidget = require( './TagItemWidget.js' ),
+ FilterTagItemWidget;
+
+ /**
+ * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
+ *
+ * @class mw.rcfilters.ui.FilterTagItemWidget
+ * @extends mw.rcfilters.ui.TagItemWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.FilterItem} invertModel
+ * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
+ * @param {Object} config Configuration object
+ */
+ FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
+ controller, filtersViewModel, invertModel, itemModel, config
+ ) {
+ config = config || {};
+
+ FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( FilterTagItemWidget, TagItemWidget );
+
+ /* Methods */
+
+ /**
+ * @inheritdoc
+ */
+ FilterTagItemWidget.prototype.setCurrentMuteState = function () {
+ this.setFlags( {
+ muted: (
+ !this.itemModel.isSelected() ||
+ this.itemModel.isIncluded() ||
+ this.itemModel.isFullyCovered()
+ ),
+ invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
+ } );
+ };
+
+ module.exports = FilterTagItemWidget;
+}() );
--- /dev/null
+( function () {
+ var ViewSwitchWidget = require( './ViewSwitchWidget.js' ),
+ SaveFiltersPopupButtonWidget = require( './SaveFiltersPopupButtonWidget.js' ),
+ MenuSelectWidget = require( './MenuSelectWidget.js' ),
+ FilterTagItemWidget = require( './FilterTagItemWidget.js' ),
+ FilterTagMultiselectWidget;
+
+ /**
+ * List displaying all filter groups
+ *
+ * @class mw.rcfilters.ui.FilterTagMultiselectWidget
+ * @extends OO.ui.MenuTagMultiselectWidget
+ * @mixins OO.ui.mixin.PendingElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+ * system. If not given, falls back to this widget's $element
+ * @cfg {boolean} [collapsed] Filter area is collapsed
+ */
+ FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
+ var rcFiltersRow,
+ title = new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-activefilters' ),
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
+ } ),
+ $contentWrapper = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
+
+ config = config || {};
+
+ this.controller = controller;
+ this.model = model;
+ this.queriesModel = savedQueriesModel;
+ this.$overlay = config.$overlay || this.$element;
+ this.$wrapper = config.$wrapper || this.$element;
+ this.matchingQuery = null;
+ this.currentView = this.model.getCurrentView();
+ this.collapsed = false;
+
+ // Parent
+ FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
+ label: mw.msg( 'rcfilters-filterlist-title' ),
+ placeholder: mw.msg( 'rcfilters-empty-filter' ),
+ inputPosition: 'outline',
+ allowArbitrary: false,
+ allowDisplayInvalidTags: false,
+ allowReordering: false,
+ $overlay: this.$overlay,
+ menu: {
+ // Our filtering is done through the model
+ filterFromInput: false,
+ hideWhenOutOfView: false,
+ hideOnChoose: false,
+ width: 650,
+ footers: [
+ {
+ name: 'viewSelect',
+ sticky: false,
+ // View select menu, appears on default view only
+ $element: $( '<div>' )
+ .append( new ViewSwitchWidget( this.controller, this.model ).$element ),
+ views: [ 'default' ]
+ },
+ {
+ name: 'feedback',
+ // Feedback footer, appears on all views
+ $element: $( '<div>' )
+ .append(
+ new OO.ui.ButtonWidget( {
+ framed: false,
+ icon: 'feedback',
+ flags: [ 'progressive' ],
+ label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
+ href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
+ } ).$element
+ )
+ }
+ ]
+ },
+ input: {
+ icon: 'menu',
+ placeholder: mw.msg( 'rcfilters-search-placeholder' )
+ }
+ }, config ) );
+
+ this.savedQueryTitle = new OO.ui.LabelWidget( {
+ label: '',
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
+ } );
+
+ this.resetButton = new OO.ui.ButtonWidget( {
+ framed: false,
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
+ } );
+
+ this.hideShowButton = new OO.ui.ButtonWidget( {
+ framed: false,
+ flags: [ 'progressive' ],
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
+ } );
+ this.toggleCollapsed( !!config.collapsed );
+
+ if ( !mw.user.isAnon() ) {
+ this.saveQueryButton = new SaveFiltersPopupButtonWidget(
+ this.controller,
+ this.queriesModel,
+ {
+ $overlay: this.$overlay
+ }
+ );
+
+ this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
+ e.stopPropagation();
+ } );
+
+ this.saveQueryButton.connect( this, {
+ click: 'onSaveQueryButtonClick',
+ saveCurrent: 'setSavedQueryVisibility'
+ } );
+ this.queriesModel.connect( this, {
+ itemUpdate: 'onSavedQueriesItemUpdate',
+ initialize: 'onSavedQueriesInitialize',
+ default: 'reevaluateResetRestoreState'
+ } );
+ }
+
+ this.emptyFilterMessage = new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-empty-filter' ),
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
+ } );
+ this.$content.append( this.emptyFilterMessage.$element );
+
+ // Events
+ this.resetButton.connect( this, { click: 'onResetButtonClick' } );
+ this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
+ // Stop propagation for mousedown, so that the widget doesn't
+ // trigger the focus on the input and scrolls up when we click the reset button
+ this.resetButton.$element.on( 'mousedown', function ( e ) {
+ e.stopPropagation();
+ } );
+ this.hideShowButton.$element.on( 'mousedown', function ( e ) {
+ e.stopPropagation();
+ } );
+ this.model.connect( this, {
+ initialize: 'onModelInitialize',
+ update: 'onModelUpdate',
+ searchChange: 'onModelSearchChange',
+ itemUpdate: 'onModelItemUpdate',
+ highlightChange: 'onModelHighlightChange'
+ } );
+ this.input.connect( this, { change: 'onInputChange' } );
+
+ // The filter list and button should appear side by side regardless of how
+ // wide the button is; the button also changes its width depending
+ // on language and its state, so the safest way to present both side
+ // by side is with a table layout
+ rcFiltersRow = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ this.$content
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
+ );
+
+ if ( !mw.user.isAnon() ) {
+ rcFiltersRow.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
+ .append( this.saveQueryButton.$element )
+ );
+ }
+
+ // Add a selector at the right of the input
+ this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
+ items: [
+ new OO.ui.ButtonOptionWidget( {
+ framed: false,
+ data: 'namespaces',
+ icon: 'article',
+ label: mw.msg( 'namespaces' ),
+ title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
+ } ),
+ new OO.ui.ButtonOptionWidget( {
+ framed: false,
+ data: 'tags',
+ icon: 'tag',
+ label: mw.msg( 'tags-title' ),
+ title: mw.msg( 'rcfilters-view-tags-tooltip' )
+ } )
+ ]
+ } );
+
+ // Rearrange the UI so the select widget is at the right of the input
+ this.$element.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
+ .append( this.input.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
+ .append( this.viewsSelectWidget.$element )
+ )
+ )
+ );
+
+ // Event
+ this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
+
+ rcFiltersRow.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
+ .append( this.resetButton.$element )
+ );
+
+ // Build the content
+ $contentWrapper.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
+ .append( title.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
+ .append( this.savedQueryTitle.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
+ .append(
+ this.hideShowButton.$element
+ )
+ ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
+ .append( rcFiltersRow )
+ );
+
+ // Initialize
+ this.$handle.append( $contentWrapper );
+ this.emptyFilterMessage.toggle( this.isEmpty() );
+ this.savedQueryTitle.toggle( false );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
+
+ this.reevaluateResetRestoreState();
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
+
+ /* Methods */
+
+ /**
+ * Override parent method to avoid unnecessary resize events.
+ */
+ FilterTagMultiselectWidget.prototype.updateIfHeightChanged = function () { };
+
+ /**
+ * Respond to view select widget choose event
+ *
+ * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
+ */
+ FilterTagMultiselectWidget.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget ) {
+ this.controller.switchView( buttonOptionWidget.getData() );
+ this.viewsSelectWidget.selectItem( null );
+ this.focus();
+ };
+
+ /**
+ * Respond to model search change event
+ *
+ * @param {string} value Search value
+ */
+ FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
+ this.input.setValue( value );
+ };
+
+ /**
+ * Respond to input change event
+ *
+ * @param {string} value Value of the input
+ */
+ FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
+ this.controller.setSearch( value );
+ };
+
+ /**
+ * Respond to query button click
+ */
+ FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
+ this.getMenu().toggle( false );
+ };
+
+ /**
+ * Respond to save query model initialization
+ */
+ FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
+ this.setSavedQueryVisibility();
+ };
+
+ /**
+ * Respond to save query item change. Mainly this is done to update the label in case
+ * a query item has been edited
+ *
+ * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
+ */
+ FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
+ if ( this.matchingQuery === item ) {
+ // This means we just edited the item that is currently matched
+ this.savedQueryTitle.setLabel( item.getLabel() );
+ }
+ };
+
+ /**
+ * Respond to menu toggle
+ *
+ * @param {boolean} isVisible Menu is visible
+ */
+ FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
+ // Parent
+ FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
+
+ if ( isVisible ) {
+ this.focus();
+
+ mw.hook( 'RcFilters.popup.open' ).fire();
+
+ if ( !this.getMenu().findSelectedItem() ) {
+ // If there are no selected items, scroll menu to top
+ // This has to be in a setTimeout so the menu has time
+ // to be positioned and fixed
+ setTimeout(
+ function () {
+ this.getMenu().scrollToTop();
+ }.bind( this )
+ );
+ }
+ } else {
+ // Clear selection
+ this.selectTag( null );
+
+ // Clear the search
+ this.controller.setSearch( '' );
+
+ // Log filter grouping
+ this.controller.trackFilterGroupings( 'filtermenu' );
+
+ this.blur();
+ }
+
+ this.input.setIcon( isVisible ? 'search' : 'menu' );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ FilterTagMultiselectWidget.prototype.onInputFocus = function () {
+ // Parent
+ FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
+
+ // Only scroll to top of the viewport if:
+ // - The widget is more than 20px from the top
+ // - The widget is not above the top of the viewport (do not scroll downwards)
+ // (This isn't represented because >20 is, anyways and always, bigger than 0)
+ this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ FilterTagMultiselectWidget.prototype.doInputEscape = function () {
+ // Parent
+ FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
+
+ // Blur the input
+ this.input.$input.trigger( 'blur' );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
+ if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
+ this.menu.toggle();
+
+ return false;
+ }
+ };
+
+ /**
+ * @inheritdoc
+ */
+ FilterTagMultiselectWidget.prototype.onChangeTags = function () {
+ // If initialized, call parent method.
+ if ( this.controller.isInitialized() ) {
+ FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
+ }
+
+ this.emptyFilterMessage.toggle( this.isEmpty() );
+ };
+
+ /**
+ * Respond to model initialize event
+ */
+ FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
+ this.setSavedQueryVisibility();
+ };
+
+ /**
+ * Respond to model update event
+ */
+ FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
+ this.updateElementsForView();
+ };
+
+ /**
+ * Update the elements in the widget to the current view
+ */
+ FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
+ var view = this.model.getCurrentView(),
+ inputValue = this.input.getValue().trim(),
+ inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
+
+ if ( inputView !== 'default' ) {
+ // We have a prefix already, remove it
+ inputValue = inputValue.substr( 1 );
+ }
+
+ if ( inputView !== view ) {
+ // Add the correct prefix
+ inputValue = this.model.getViewTrigger( view ) + inputValue;
+ }
+
+ // Update input
+ this.input.setValue( inputValue );
+
+ if ( this.currentView !== view ) {
+ this.scrollToTop( this.$element );
+ this.currentView = view;
+ }
+ };
+
+ /**
+ * Set the visibility of the saved query button
+ */
+ FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
+ if ( mw.user.isAnon() ) {
+ return;
+ }
+
+ this.matchingQuery = this.controller.findQueryMatchingCurrentState();
+
+ this.savedQueryTitle.setLabel(
+ this.matchingQuery ? this.matchingQuery.getLabel() : ''
+ );
+ this.savedQueryTitle.toggle( !!this.matchingQuery );
+ this.saveQueryButton.setDisabled( !!this.matchingQuery );
+ this.saveQueryButton.setTitle( !this.matchingQuery ?
+ mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
+ mw.msg( 'rcfilters-savedqueries-already-saved' ) );
+
+ if ( this.matchingQuery ) {
+ this.emphasize();
+ }
+ };
+
+ /**
+ * Respond to model itemUpdate event
+ * fixme: when a new state is applied to the model this function is called 60+ times in a row
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item Filter item model
+ */
+ FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
+ if ( !item.getGroupModel().isHidden() ) {
+ if (
+ item.isSelected() ||
+ (
+ this.model.isHighlightEnabled() &&
+ item.getHighlightColor()
+ )
+ ) {
+ this.addTag( item.getName(), item.getLabel() );
+ } else {
+ // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
+ if ( this.findItemFromData( item.getName() ) !== null ) {
+ this.removeTagByData( item.getName() );
+ }
+ }
+ }
+
+ this.setSavedQueryVisibility();
+
+ // Re-evaluate reset state
+ this.reevaluateResetRestoreState();
+ };
+
+ /**
+ * @inheritdoc
+ */
+ FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
+ return (
+ this.model.getItemByName( data ) &&
+ !this.isDuplicateData( data )
+ );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
+ this.controller.toggleFilterSelect( item.model.getName() );
+
+ // Select the tag if it exists, or reset selection otherwise
+ this.selectTag( this.findItemFromData( item.model.getName() ) );
+
+ this.focus();
+ };
+
+ /**
+ * Respond to highlightChange event
+ *
+ * @param {boolean} isHighlightEnabled Highlight is enabled
+ */
+ FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
+ var highlightedItems = this.model.getHighlightedItems();
+
+ if ( isHighlightEnabled ) {
+ // Add capsule widgets
+ highlightedItems.forEach( function ( filterItem ) {
+ this.addTag( filterItem.getName(), filterItem.getLabel() );
+ }.bind( this ) );
+ } else {
+ // Remove capsule widgets if they're not selected
+ highlightedItems.forEach( function ( filterItem ) {
+ if ( !filterItem.isSelected() ) {
+ // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
+ if ( this.findItemFromData( filterItem.getName() ) !== null ) {
+ this.removeTagByData( filterItem.getName() );
+ }
+ }
+ }.bind( this ) );
+ }
+
+ this.setSavedQueryVisibility();
+ };
+
+ /**
+ * @inheritdoc
+ */
+ FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
+ var menuOption = this.menu.getItemFromModel( tagItem.getModel() );
+
+ this.menu.setUserSelecting( true );
+ // Parent method
+ FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
+
+ // Switch view
+ this.controller.resetSearchForView( tagItem.getView() );
+
+ this.selectTag( tagItem );
+ this.scrollToTop( menuOption.$element );
+
+ this.menu.setUserSelecting( false );
+ };
+
+ /**
+ * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
+ * If no items are given, reset selection from all.
+ *
+ * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
+ * omit to deselect all
+ */
+ FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
+ var i, len, selected;
+
+ for ( i = 0, len = this.items.length; i < len; i++ ) {
+ selected = this.items[ i ] === item;
+ if ( this.items[ i ].isSelected() !== selected ) {
+ this.items[ i ].toggleSelected( selected );
+ }
+ }
+ };
+ /**
+ * @inheritdoc
+ */
+ FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
+ // Parent method
+ FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
+
+ this.controller.clearFilter( tagItem.getName() );
+
+ tagItem.destroy();
+ };
+
+ /**
+ * Respond to click event on the reset button
+ */
+ FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
+ if ( this.model.areVisibleFiltersEmpty() ) {
+ // Reset to default filters
+ this.controller.resetToDefaults();
+ } else {
+ // Reset to have no filters
+ this.controller.emptyFilters();
+ }
+ };
+
+ /**
+ * Respond to hide/show button click
+ */
+ FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
+ this.toggleCollapsed();
+ };
+
+ /**
+ * Toggle the collapsed state of the filters widget
+ *
+ * @param {boolean} isCollapsed Widget is collapsed
+ */
+ FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
+ isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;
+
+ this.collapsed = isCollapsed;
+
+ if ( isCollapsed ) {
+ // If we are collapsing, close the menu, in case it was open
+ // We should make sure the menu closes before the rest of the elements
+ // are hidden, otherwise there is an unknown error in jQuery as ooui
+ // sets and unsets properties on the input (which is hidden at that point)
+ this.menu.toggle( false );
+ }
+ this.input.setDisabled( isCollapsed );
+ this.hideShowButton.setLabel( mw.msg(
+ isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
+ ) );
+ this.hideShowButton.setTitle( mw.msg(
+ isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
+ ) );
+
+ // Toggle the wrapper class, so we have min height values correctly throughout
+ this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );
+
+ // Save the state
+ this.controller.updateCollapsedState( isCollapsed );
+ };
+
+ /**
+ * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
+ */
+ FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
+ var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
+ currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
+ hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
+
+ this.resetButton.setIcon(
+ currFiltersAreEmpty ? 'history' : 'trash'
+ );
+
+ this.resetButton.setLabel(
+ currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
+ );
+ this.resetButton.setTitle(
+ currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
+ );
+
+ this.resetButton.toggle( !hideResetButton );
+ this.emptyFilterMessage.toggle( currFiltersAreEmpty );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
+ return new MenuSelectWidget(
+ this.controller,
+ this.model,
+ menuConfig
+ );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
+ var filterItem = this.model.getItemByName( data );
+
+ if ( filterItem ) {
+ return new FilterTagItemWidget(
+ this.controller,
+ this.model,
+ this.model.getInvertModel(),
+ filterItem,
+ {
+ $overlay: this.$overlay
+ }
+ );
+ }
+ };
+
+ FilterTagMultiselectWidget.prototype.emphasize = function () {
+ if (
+ !this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
+ ) {
+ this.$handle
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+
+ setTimeout( function () {
+ this.$handle
+ .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
+
+ setTimeout( function () {
+ this.$handle
+ .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+ }.bind( this ), 1000 );
+ }.bind( this ), 500 );
+
+ }
+ };
+ /**
+ * Scroll the element to top within its container
+ *
+ * @private
+ * @param {jQuery} $element Element to position
+ * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
+ * much space (in pixels) above the widget.
+ * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
+ * @param {number} [threshold.min] Minimum distance above the element
+ * @param {number} [threshold.max] Minimum distance below the element
+ */
+ FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
+ var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
+ pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
+ containerScrollTop = $( container ).scrollTop(),
+ effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
+ newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );
+
+ // Scroll to item
+ if (
+ threshold === undefined ||
+ (
+ (
+ threshold.min === undefined ||
+ newScrollTop - containerScrollTop >= threshold.min
+ ) &&
+ (
+ threshold.max === undefined ||
+ newScrollTop - containerScrollTop <= threshold.max
+ )
+ )
+ ) {
+ // eslint-disable-next-line jquery/no-animate
+ $( container ).animate( {
+ scrollTop: newScrollTop
+ } );
+ }
+ };
+
+ module.exports = FilterTagMultiselectWidget;
+}() );
--- /dev/null
+( function () {
+ var FilterTagMultiselectWidget = require( './FilterTagMultiselectWidget.js' ),
+ LiveUpdateButtonWidget = require( './LiveUpdateButtonWidget.js' ),
+ ChangesLimitAndDateButtonWidget = require( './ChangesLimitAndDateButtonWidget.js' ),
+ FilterWrapperWidget;
+
+ /**
+ * List displaying all filter groups
+ *
+ * @class mw.rcfilters.ui.FilterWrapperWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.PendingElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {Object} [config] Configuration object
+ * @cfg {Object} [filters] A definition of the filter groups in this list
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+ * system. If not given, falls back to this widget's $element
+ * @cfg {boolean} [collapsed] Filter area is collapsed
+ */
+ FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
+ controller, model, savedQueriesModel, changesListModel, config
+ ) {
+ var $bottom;
+ config = config || {};
+
+ // Parent
+ FilterWrapperWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.PendingElement.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+ this.queriesModel = savedQueriesModel;
+ this.changesListModel = changesListModel;
+ this.$overlay = config.$overlay || this.$element;
+ this.$wrapper = config.$wrapper || this.$element;
+
+ this.filterTagWidget = new FilterTagMultiselectWidget(
+ this.controller,
+ this.model,
+ this.queriesModel,
+ {
+ $overlay: this.$overlay,
+ collapsed: config.collapsed,
+ $wrapper: this.$wrapper
+ }
+ );
+
+ this.liveUpdateButton = new LiveUpdateButtonWidget(
+ this.controller,
+ this.changesListModel
+ );
+
+ this.numChangesAndDateWidget = new ChangesLimitAndDateButtonWidget(
+ this.controller,
+ this.model,
+ {
+ $overlay: this.$overlay
+ }
+ );
+
+ this.showNewChangesLink = new OO.ui.ButtonWidget( {
+ icon: 'reload',
+ framed: false,
+ label: mw.msg( 'rcfilters-show-new-changes' ),
+ flags: [ 'progressive' ],
+ classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
+ } );
+
+ // Events
+ this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
+ this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
+ this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
+ this.showNewChangesLink.toggle( false );
+
+ // Initialize
+ this.$top = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
+
+ $bottom = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
+ .append(
+ this.showNewChangesLink.$element,
+ this.numChangesAndDateWidget.$element
+ );
+
+ if ( mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' ) ) {
+ $bottom.prepend( this.liveUpdateButton.$element );
+ }
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
+ .append(
+ this.$top,
+ this.filterTagWidget.$element,
+ $bottom
+ );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( FilterWrapperWidget, OO.ui.Widget );
+ OO.mixinClass( FilterWrapperWidget, OO.ui.mixin.PendingElement );
+
+ /* Methods */
+
+ /**
+ * Set the content of the top section
+ *
+ * @param {jQuery} $topSectionElement
+ */
+ FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
+ this.$top.append( $topSectionElement );
+ };
+
+ /**
+ * Respond to the user clicking the 'show new changes' button
+ */
+ FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
+ this.controller.showNewChanges();
+ };
+
+ /**
+ * Respond to changes list model newChangesExist
+ *
+ * @param {boolean} newChangesExist Whether new changes exist
+ */
+ FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
+ this.showNewChangesLink.toggle( newChangesExist );
+ };
+
+ module.exports = FilterWrapperWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Wrapper for the RC form with hide/show links
+ * Must be constructed after the model is initialized.
+ *
+ * @class mw.rcfilters.ui.FormWrapperWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
+ * @param {mw.rcfilters.Controller} controller RCfilters controller
+ * @param {jQuery} $formRoot Root element of the form to attach to
+ * @param {Object} config Configuration object
+ */
+ var FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
+ config = config || {};
+
+ // Parent
+ FormWrapperWidget.parent.call( this, $.extend( {}, config, {
+ $element: $formRoot
+ } ) );
+
+ this.changeListModel = changeListModel;
+ this.filtersModel = filtersModel;
+ this.controller = controller;
+ this.$submitButton = this.$element.find( 'form input[type=submit]' );
+
+ this.$element
+ .on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );
+
+ this.$element
+ .on( 'submit', 'form', this.onFormSubmit.bind( this ) );
+
+ // Events
+ this.changeListModel.connect( this, {
+ invalidate: 'onChangesModelInvalidate',
+ update: 'onChangesModelUpdate'
+ } );
+
+ // Initialize
+ this.cleanUpFieldset();
+ this.$element
+ .addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( FormWrapperWidget, OO.ui.Widget );
+
+ /**
+ * Respond to link click
+ *
+ * @param {jQuery.Event} e Event
+ * @return {boolean} false
+ */
+ FormWrapperWidget.prototype.onLinkClick = function ( e ) {
+ this.controller.updateChangesList( $( e.target ).data( 'params' ) );
+ return false;
+ };
+
+ /**
+ * Respond to form submit event
+ *
+ * @param {jQuery.Event} e Event
+ * @return {boolean} false
+ */
+ FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
+ var data = {};
+
+ // Collect all data from form
+ $( e.target ).find( 'input:not([type="hidden"],[type="submit"]), select' ).each( function () {
+ var value = '';
+
+ if ( !$( this ).is( ':checkbox' ) || $( this ).is( ':checked' ) ) {
+ value = $( this ).val();
+ }
+
+ data[ $( this ).prop( 'name' ) ] = value;
+ } );
+
+ this.controller.updateChangesList( data );
+ return false;
+ };
+
+ /**
+ * Respond to model invalidate
+ */
+ FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
+ this.$submitButton.prop( 'disabled', true );
+ };
+
+ /**
+ * Respond to model update, replace the show/hide links with the ones from the
+ * server so they feature the correct state.
+ *
+ * @param {jQuery|string} $changesList Updated changes list
+ * @param {jQuery} $fieldset Updated fieldset
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
+ */
+ FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
+ this.$submitButton.prop( 'disabled', false );
+
+ // Replace the entire fieldset
+ this.$element.empty().append( $fieldset.contents() );
+
+ if ( !isInitialDOM ) {
+ // Make sure enhanced RC re-initializes correctly
+ mw.hook( 'wikipage.content' ).fire( this.$element );
+ }
+
+ this.cleanUpFieldset();
+ };
+
+ /**
+ * Clean up the old-style show/hide that we have implemented in the filter list
+ */
+ FormWrapperWidget.prototype.cleanUpFieldset = function () {
+ this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
+ // HACK: Remove the text node after the span.
+ // If there isn't one, we're at the end, so remove the text node before the span.
+ // This would be unnecessary if we added separators with CSS.
+ if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
+ this.parentNode.removeChild( this.nextSibling );
+ } else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
+ this.parentNode.removeChild( this.previousSibling );
+ }
+ // Remove the span itself
+ this.parentNode.removeChild( this );
+ } );
+
+ // Hide namespaces and tags
+ this.$element.find( '.namespaceForm' ).detach();
+ this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
+
+ // Hide Related Changes page name form
+ this.$element.find( '.targetForm' ).detach();
+
+ // misc: limit, days, watchlist info msg
+ this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();
+
+ if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
+ this.$element.find( '.mw-recentchanges-table' ).detach();
+ this.$element.find( 'hr' ).detach();
+ }
+
+ // Get rid of all <br>s, which are inside rcshowhide
+ // If we still have content in rcshowhide, the <br>s are
+ // gone. Instead, the CSS now has a rule to mark all <span>s
+ // inside .rcshowhide with display:block; to simulate newlines
+ // where they're actually needed.
+ this.$element.find( 'br' ).detach();
+ if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
+ this.$element.find( '.rcshowhide' ).detach();
+ }
+
+ if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
+ this.$element.find( '.cloption-submit' ).detach();
+ }
+
+ this.$element.find(
+ '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
+ ).detach();
+
+ // Get rid of the legend
+ this.$element.find( 'legend' ).detach();
+
+ // Check if the element is essentially empty, and detach it if it is
+ if ( !this.$element.text().trim().length ) {
+ this.$element.detach();
+ }
+ };
+
+ module.exports = FormWrapperWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * A group widget to allow for aggregation of events
+ *
+ * @class mw.rcfilters.ui.GroupWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration object
+ * @param {Object} [events] Events to aggregate. The object represent the
+ * event name to aggregate and the event value to emit on aggregate for items.
+ */
+ var GroupWidget = function MwRcfiltersUiViewSwitchWidget( config ) {
+ var aggregate = {};
+
+ config = config || {};
+
+ // Parent constructor
+ GroupWidget.parent.call( this, config );
+
+ // Mixin constructors
+ OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+ if ( config.events ) {
+ // Aggregate events
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( config.events, function ( eventName, eventEmit ) {
+ aggregate[ eventName ] = eventEmit;
+ } );
+
+ this.aggregate( aggregate );
+ }
+
+ if ( Array.isArray( config.items ) ) {
+ this.addItems( config.items );
+ }
+ };
+
+ /* Initialize */
+
+ OO.inheritClass( GroupWidget, OO.ui.Widget );
+ OO.mixinClass( GroupWidget, OO.ui.mixin.GroupWidget );
+
+ module.exports = GroupWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * A widget representing a filter item highlight color picker
+ *
+ * @class mw.rcfilters.ui.HighlightColorPickerWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {Object} [config] Configuration object
+ */
+ var HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
+ var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
+ config = config || {};
+
+ // Parent
+ HighlightColorPickerWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
+ label: mw.message( 'rcfilters-highlightmenu-title' ).text()
+ } ) );
+
+ this.controller = controller;
+
+ this.currentSelection = 'none';
+ this.buttonSelect = new OO.ui.ButtonSelectWidget( {
+ items: colors.map( function ( color ) {
+ return new OO.ui.ButtonOptionWidget( {
+ icon: color === 'none' ? 'check' : null,
+ data: color,
+ classes: [
+ 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
+ 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
+ ],
+ framed: false
+ } );
+ } ),
+ classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
+ } );
+
+ // Event
+ this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
+ .append(
+ this.$label
+ .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
+ this.buttonSelect.$element
+ );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( HighlightColorPickerWidget, OO.ui.Widget );
+ OO.mixinClass( HighlightColorPickerWidget, OO.ui.mixin.LabelElement );
+
+ /* Events */
+
+ /**
+ * @event chooseColor
+ * @param {string} The chosen color
+ *
+ * A color has been chosen
+ */
+
+ /* Methods */
+
+ /**
+ * Bind the color picker to an item
+ * @param {mw.rcfilters.dm.FilterItem} filterItem
+ */
+ HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
+ if ( this.filterItem ) {
+ this.filterItem.disconnect( this );
+ }
+
+ this.filterItem = filterItem;
+ this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
+ this.updateUiBasedOnModel();
+ };
+
+ /**
+ * Respond to item model update event
+ */
+ HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
+ this.selectColor( this.filterItem.getHighlightColor() || 'none' );
+ };
+
+ /**
+ * Select the color for this widget
+ *
+ * @param {string} color Selected color
+ */
+ HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
+ var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
+ selectedItem = this.buttonSelect.findItemFromData( color );
+
+ if ( this.currentSelection !== color ) {
+ this.currentSelection = color;
+
+ this.buttonSelect.selectItem( selectedItem );
+ if ( previousItem ) {
+ previousItem.setIcon( null );
+ }
+
+ if ( selectedItem ) {
+ selectedItem.setIcon( 'check' );
+ }
+ }
+ };
+
+ HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
+ var color = button.data;
+ if ( color === 'none' ) {
+ this.controller.clearHighlightColor( this.filterItem.getName() );
+ } else {
+ this.controller.setHighlightColor( this.filterItem.getName(), color );
+ }
+ this.emit( 'chooseColor', color );
+ };
+
+ module.exports = HighlightColorPickerWidget;
+}() );
--- /dev/null
+( function () {
+ var HighlightColorPickerWidget = require( './HighlightColorPickerWidget.js' ),
+ HighlightPopupWidget;
+ /**
+ * A popup containing a color picker, for setting highlight colors.
+ *
+ * @class mw.rcfilters.ui.HighlightPopupWidget
+ * @extends OO.ui.PopupWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {Object} [config] Configuration object
+ */
+ HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
+ config = config || {};
+
+ // Parent
+ HighlightPopupWidget.parent.call( this, $.extend( {
+ autoClose: true,
+ anchor: false,
+ padded: true,
+ align: 'backwards',
+ horizontalPosition: 'end',
+ width: 290
+ }, config ) );
+
+ this.colorPicker = new HighlightColorPickerWidget( controller );
+
+ this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
+
+ this.$body.append( this.colorPicker.$element );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget );
+
+ /* Methods */
+
+ /**
+ * Set the button (or other widget) that this popup should hang off.
+ *
+ * @param {OO.ui.Widget} widget Widget the popup should orient itself to
+ */
+ HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
+ this.setFloatableContainer( widget.$element );
+ this.$autoCloseIgnore = widget.$element;
+ };
+
+ /**
+ * Set the filter item that this popup should control the highlight color for.
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item
+ */
+ HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
+ this.colorPicker.setFilterItem( item );
+ };
+
+ /**
+ * When the user chooses a color in the color picker, close the popup.
+ */
+ HighlightPopupWidget.prototype.onChooseColor = function () {
+ this.toggle( false );
+ };
+
+ module.exports = HighlightPopupWidget;
+
+}() );
--- /dev/null
+( function () {
+ var FilterItemHighlightButton = require( './FilterItemHighlightButton.js' ),
+ CheckboxInputWidget = require( './CheckboxInputWidget.js' ),
+ ItemMenuOptionWidget;
+
+ /**
+ * A widget representing a base toggle item
+ *
+ * @class mw.rcfilters.ui.ItemMenuOptionWidget
+ * @extends OO.ui.MenuOptionWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.ItemModel} invertModel
+ * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
+ * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
+ * @param {Object} config Configuration object
+ */
+ ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
+ controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
+ ) {
+ var layout,
+ classes = [],
+ $label = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
+
+ config = config || {};
+
+ this.controller = controller;
+ this.filtersViewModel = filtersViewModel;
+ this.invertModel = invertModel;
+ this.itemModel = itemModel;
+
+ // Parent
+ ItemMenuOptionWidget.parent.call( this, $.extend( {
+ // Override the 'check' icon that OOUI defines
+ icon: '',
+ data: this.itemModel.getName(),
+ label: this.itemModel.getLabel()
+ }, config ) );
+
+ this.checkboxWidget = new CheckboxInputWidget( {
+ value: this.itemModel.getName(),
+ selected: this.itemModel.isSelected()
+ } );
+
+ $label.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
+ .append( $( '<bdi>' ).append( this.$label ) )
+ );
+ if ( this.itemModel.getDescription() ) {
+ $label.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
+ .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
+ );
+ }
+
+ this.highlightButton = new FilterItemHighlightButton(
+ this.controller,
+ this.itemModel,
+ highlightPopup,
+ {
+ $overlay: config.$overlay || this.$element,
+ title: mw.msg( 'rcfilters-highlightmenu-help' )
+ }
+ );
+ this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
+
+ this.excludeLabel = new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-filter-excluded' )
+ } );
+ this.excludeLabel.toggle(
+ this.itemModel.getGroupModel().getView() === 'namespaces' &&
+ this.itemModel.isSelected() &&
+ this.invertModel.isSelected()
+ );
+
+ layout = new OO.ui.FieldLayout( this.checkboxWidget, {
+ label: $label,
+ align: 'inline'
+ } );
+
+ // Events
+ this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
+ this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
+ this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
+ // HACK: Prevent defaults on 'click' for the label so it
+ // doesn't steal the focus away from the input. This means
+ // we can continue arrow-movement after we click the label
+ // and is consistent with the checkbox *itself* also preventing
+ // defaults on 'click' as well.
+ layout.$label.on( 'click', false );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
+ .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView() )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
+ .append( layout.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
+ .append( this.excludeLabel.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
+ .append( this.highlightButton.$element )
+ )
+ )
+ );
+
+ if ( this.itemModel.getIdentifiers() ) {
+ this.itemModel.getIdentifiers().forEach( function ( ident ) {
+ classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
+ } );
+
+ this.$element.addClass( classes );
+ }
+
+ this.updateUiBasedOnState();
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
+
+ /* Static properties */
+
+ // We do our own scrolling to top
+ ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+
+ /* Methods */
+
+ /**
+ * Respond to item model update event
+ */
+ ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+ this.checkboxWidget.setSelected( this.itemModel.isSelected() );
+
+ this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
+ this.excludeLabel.toggle(
+ this.itemModel.getGroupModel().getView() === 'namespaces' &&
+ this.itemModel.isSelected() &&
+ this.invertModel.isSelected()
+ );
+ this.toggle( this.itemModel.isVisible() );
+ };
+
+ /**
+ * Get the name of this filter
+ *
+ * @return {string} Filter name
+ */
+ ItemMenuOptionWidget.prototype.getName = function () {
+ return this.itemModel.getName();
+ };
+
+ ItemMenuOptionWidget.prototype.getModel = function () {
+ return this.itemModel;
+ };
+
+ module.exports = ItemMenuOptionWidget;
+
+}() );
--- /dev/null
+( function () {
+ /**
+ * Widget for toggling live updates
+ *
+ * @class mw.rcfilters.ui.LiveUpdateButtonWidget
+ * @extends OO.ui.ToggleButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {Object} [config] Configuration object
+ */
+ var LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
+ config = config || {};
+
+ // Parent
+ LiveUpdateButtonWidget.parent.call( this, $.extend( {
+ label: mw.message( 'rcfilters-liveupdates-button' ).text()
+ }, config ) );
+
+ this.controller = controller;
+ this.model = changesListModel;
+
+ // Events
+ this.connect( this, { click: 'onClick' } );
+ this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
+
+ this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
+
+ this.setState( false );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
+
+ /* Methods */
+
+ /**
+ * Respond to the button being clicked
+ */
+ LiveUpdateButtonWidget.prototype.onClick = function () {
+ this.controller.toggleLiveUpdate();
+ };
+
+ /**
+ * Set the button's state and change its appearance
+ *
+ * @param {boolean} enable Whether the 'live update' feature is now on/off
+ */
+ LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
+ this.setValue( enable );
+ this.setIcon( enable ? 'stop' : 'play' );
+ this.setTitle( mw.message(
+ enable ?
+ 'rcfilters-liveupdates-button-title-on' :
+ 'rcfilters-liveupdates-button-title-off'
+ ).text() );
+ };
+
+ /**
+ * Respond to the 'live update' feature being turned on/off
+ *
+ * @param {boolean} enable Whether the 'live update' feature is now on/off
+ */
+ LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
+ this.setState( enable );
+ };
+
+ module.exports = LiveUpdateButtonWidget;
+
+}() );
--- /dev/null
+( function () {
+ var SavedLinksListWidget = require( './SavedLinksListWidget.js' ),
+ FilterWrapperWidget = require( './FilterWrapperWidget.js' ),
+ ChangesListWrapperWidget = require( './ChangesListWrapperWidget.js' ),
+ RcTopSectionWidget = require( './RcTopSectionWidget.js' ),
+ RclTopSectionWidget = require( './RclTopSectionWidget.js' ),
+ WatchlistTopSectionWidget = require( './WatchlistTopSectionWidget.js' ),
+ FormWrapperWidget = require( './FormWrapperWidget.js' ),
+ MainWrapperWidget;
+
+ /**
+ * Wrapper for changes list content
+ *
+ * @class mw.rcfilters.ui.MainWrapperWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} $topSection Top section container
+ * @cfg {jQuery} $filtersContainer
+ * @cfg {jQuery} $changesListContainer
+ * @cfg {jQuery} $formContainer
+ * @cfg {boolean} [collapsed] Filter area is collapsed
+ * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+ * system. If not given, falls back to this widget's $element
+ */
+ MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
+ controller, model, savedQueriesModel, changesListModel, config
+ ) {
+ config = $.extend( {}, config );
+
+ // Parent
+ MainWrapperWidget.parent.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+ this.changesListModel = changesListModel;
+ this.$topSection = config.$topSection;
+ this.$filtersContainer = config.$filtersContainer;
+ this.$changesListContainer = config.$changesListContainer;
+ this.$formContainer = config.$formContainer;
+ this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay' );
+ this.$wrapper = config.$wrapper || this.$element;
+
+ this.savedLinksListWidget = new SavedLinksListWidget(
+ controller, savedQueriesModel, { $overlay: this.$overlay }
+ );
+
+ this.filtersWidget = new FilterWrapperWidget(
+ controller,
+ model,
+ savedQueriesModel,
+ changesListModel,
+ {
+ $overlay: this.$overlay,
+ $wrapper: this.$wrapper,
+ collapsed: config.collapsed
+ }
+ );
+
+ this.changesListWidget = new ChangesListWrapperWidget(
+ model, changesListModel, controller, this.$changesListContainer );
+
+ /* Events */
+
+ // Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
+ // to prevent users from accidentally clicking on links in results, while menu is opened.
+ // Overlay on changes list is not the same as this.$overlay
+ this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );
+
+ // Initialize
+ this.$filtersContainer.append( this.filtersWidget.$element );
+ $( 'body' )
+ .append( this.$overlay )
+ .addClass( 'mw-rcfilters-ui-initialized' );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( MainWrapperWidget, OO.ui.Widget );
+
+ /* Methods */
+
+ /**
+ * Set the content of the top section, depending on the type of special page.
+ *
+ * @param {string} specialPage
+ */
+ MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
+ var topSection;
+
+ if ( specialPage === 'Recentchanges' ) {
+ topSection = new RcTopSectionWidget(
+ this.savedLinksListWidget, this.$topSection
+ );
+ this.filtersWidget.setTopSection( topSection.$element );
+ }
+
+ if ( specialPage === 'Recentchangeslinked' ) {
+ topSection = new RclTopSectionWidget(
+ this.savedLinksListWidget, this.controller,
+ this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
+ this.model.getGroup( 'page' ).getItemByParamName( 'target' )
+ );
+
+ this.filtersWidget.setTopSection( topSection.$element );
+ }
+
+ if ( specialPage === 'Watchlist' ) {
+ topSection = new WatchlistTopSectionWidget(
+ this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
+ );
+
+ this.filtersWidget.setTopSection( topSection.$element );
+ }
+ };
+
+ /**
+ * Filter menu toggle event listener
+ *
+ * @param {boolean} isVisible
+ */
+ MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
+ this.changesListWidget.toggleOverlay( isVisible );
+ };
+
+ /**
+ * Initialize FormWrapperWidget
+ *
+ * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
+ */
+ MainWrapperWidget.prototype.initFormWidget = function () {
+ return new FormWrapperWidget(
+ this.model, this.changesListModel, this.controller, this.$formContainer );
+ };
+
+ module.exports = MainWrapperWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Button for marking all changes as seen on the Watchlist
+ *
+ * @class mw.rcfilters.ui.MarkSeenButtonWidget
+ * @extends OO.ui.ButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
+ * @param {Object} [config] Configuration object
+ */
+ var MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
+ config = config || {};
+
+ // Parent
+ MarkSeenButtonWidget.parent.call( this, $.extend( {
+ label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
+ icon: 'checkAll'
+ }, config ) );
+
+ this.controller = controller;
+ this.model = model;
+
+ // Events
+ this.connect( this, { click: 'onClick' } );
+ this.model.connect( this, { update: 'onModelUpdate' } );
+
+ this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );
+
+ this.onModelUpdate();
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( MarkSeenButtonWidget, OO.ui.ButtonWidget );
+
+ /* Methods */
+
+ /**
+ * Respond to the button being clicked
+ */
+ MarkSeenButtonWidget.prototype.onClick = function () {
+ this.controller.markAllChangesAsSeen();
+ // assume there's no more unseen changes until the next model update
+ this.setDisabled( true );
+ };
+
+ /**
+ * Respond to the model being updated with new changes
+ */
+ MarkSeenButtonWidget.prototype.onModelUpdate = function () {
+ this.setDisabled( !this.model.hasUnseenWatchedChanges() );
+ };
+
+ module.exports = MarkSeenButtonWidget;
+
+}() );
--- /dev/null
+( function () {
+ var FilterMenuHeaderWidget = require( './FilterMenuHeaderWidget.js' ),
+ HighlightPopupWidget = require( './HighlightPopupWidget.js' ),
+ FilterMenuSectionOptionWidget = require( './FilterMenuSectionOptionWidget.js' ),
+ FilterMenuOptionWidget = require( './FilterMenuOptionWidget.js' ),
+ MenuSelectWidget;
+
+ /**
+ * A floating menu widget for the filter list
+ *
+ * @class mw.rcfilters.ui.MenuSelectWidget
+ * @extends OO.ui.MenuSelectWidget
+ *
+ * @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
+ * @cfg {Object[]} [footers] An array of objects defining the footers for
+ * this menu, with a definition whether they appear per specific views.
+ * The expected structure is:
+ * [
+ * {
+ * name: {string} A unique name for the footer object
+ * $element: {jQuery} A jQuery object for the content of the footer
+ * views: {string[]} Optional. An array stating which views this footer is
+ * active on. Use null or omit to display this on all views.
+ * }
+ * ]
+ */
+ MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
+ var header;
+
+ config = config || {};
+
+ this.controller = controller;
+ this.model = model;
+ this.currentView = '';
+ this.views = {};
+ this.userSelecting = false;
+
+ this.menuInitialized = false;
+ this.$overlay = config.$overlay || this.$element;
+ this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
+ this.footers = [];
+
+ // Parent
+ MenuSelectWidget.parent.call( this, $.extend( config, {
+ $autoCloseIgnore: this.$overlay,
+ width: 650,
+ // Our filtering is done through the model
+ filterFromInput: false
+ } ) );
+ this.setGroupElement(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
+ );
+ this.setClippableElement( this.$body );
+ this.setClippableContainer( this.$element );
+
+ header = new FilterMenuHeaderWidget(
+ this.controller,
+ this.model,
+ {
+ $overlay: this.$overlay
+ }
+ );
+
+ this.noResults = new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-filterlist-noresults' ),
+ classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
+ } );
+
+ // Events
+ this.model.connect( this, {
+ initialize: 'onModelInitialize',
+ searchChange: 'onModelSearchChange'
+ } );
+
+ // Initialization
+ this.$element
+ .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
+ .append( header.$element )
+ .append(
+ this.$body
+ .append( this.$group, this.noResults.$element )
+ );
+
+ // Append all footers; we will control their visibility
+ // based on view
+ config.footers = config.footers || [];
+ config.footers.forEach( function ( footerData ) {
+ var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
+ adjustedData = {
+ // Wrap the element with our own footer wrapper
+ $element: $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
+ .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
+ .append( footerData.$element ),
+ views: footerData.views
+ };
+
+ if ( !footerData.disabled ) {
+ this.footers.push( adjustedData );
+
+ if ( isSticky ) {
+ this.$element.append( adjustedData.$element );
+ } else {
+ this.$body.append( adjustedData.$element );
+ }
+ }
+ }.bind( this ) );
+
+ // Switch to the correct view
+ this.updateView();
+ };
+
+ /* Initialize */
+
+ OO.inheritClass( MenuSelectWidget, OO.ui.MenuSelectWidget );
+
+ /* Events */
+
+ /* Methods */
+ MenuSelectWidget.prototype.onModelSearchChange = function () {
+ this.updateView();
+ };
+
+ /**
+ * @inheritdoc
+ */
+ MenuSelectWidget.prototype.toggle = function ( show ) {
+ this.lazyMenuCreation();
+ MenuSelectWidget.parent.prototype.toggle.call( this, show );
+ // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
+ this.setVerticalPosition( 'below' );
+ };
+
+ /**
+ * lazy creation of the menu
+ */
+ MenuSelectWidget.prototype.lazyMenuCreation = function () {
+ var widget = this,
+ items = [],
+ viewGroupCount = {},
+ groups = this.model.getFilterGroups();
+
+ if ( this.menuInitialized ) {
+ return;
+ }
+
+ this.menuInitialized = true;
+
+ // Create shared popup for highlight buttons
+ this.highlightPopup = new HighlightPopupWidget( this.controller );
+ this.$overlay.append( this.highlightPopup.$element );
+
+ // Count groups per view
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( groups, function ( groupName, groupModel ) {
+ if ( !groupModel.isHidden() ) {
+ viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
+ viewGroupCount[ groupModel.getView() ]++;
+ }
+ } );
+
+ // eslint-disable-next-line jquery/no-each-util
+ $.each( groups, function ( groupName, groupModel ) {
+ var currentItems = [],
+ view = groupModel.getView();
+
+ if ( !groupModel.isHidden() ) {
+ if ( viewGroupCount[ view ] > 1 ) {
+ // Only add a section header if there is more than
+ // one group
+ currentItems.push(
+ // Group section
+ new FilterMenuSectionOptionWidget(
+ widget.controller,
+ groupModel,
+ {
+ $overlay: widget.$overlay
+ }
+ )
+ );
+ }
+
+ // Add items
+ widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
+ currentItems.push(
+ new FilterMenuOptionWidget(
+ widget.controller,
+ widget.model,
+ widget.model.getInvertModel(),
+ filterItem,
+ widget.highlightPopup,
+ {
+ $overlay: widget.$overlay
+ }
+ )
+ );
+ } );
+
+ // Cache the items per view, so we can switch between them
+ // without rebuilding the widgets each time
+ widget.views[ view ] = widget.views[ view ] || [];
+ widget.views[ view ] = widget.views[ view ].concat( currentItems );
+ items = items.concat( currentItems );
+ }
+ } );
+
+ this.addItems( items );
+ this.updateView();
+ };
+
+ /**
+ * Respond to model initialize event. Populate the menu from the model
+ */
+ MenuSelectWidget.prototype.onModelInitialize = function () {
+ this.menuInitialized = false;
+ // Set timeout for the menu to lazy build.
+ setTimeout( this.lazyMenuCreation.bind( this ) );
+ };
+
+ /**
+ * Update view
+ */
+ MenuSelectWidget.prototype.updateView = function () {
+ var viewName = this.model.getCurrentView();
+
+ if ( this.views[ viewName ] && this.currentView !== viewName ) {
+ this.updateFooterVisibility( viewName );
+
+ this.$element
+ .data( 'view', viewName )
+ .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
+ .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
+
+ this.currentView = viewName;
+ this.scrollToTop();
+ }
+
+ this.postProcessItems();
+ this.clip();
+ };
+
+ /**
+ * Go over the available footers and decide which should be visible
+ * for this view
+ *
+ * @param {string} [currentView] Current view
+ */
+ MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
+ currentView = currentView || this.model.getCurrentView();
+
+ this.footers.forEach( function ( data ) {
+ data.$element.toggle(
+ // This footer should only be shown if it is configured
+ // for all views or for this specific view
+ !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
+ );
+ } );
+ };
+
+ /**
+ * Post-process items after the visibility changed. Make sure
+ * that we always have an item selected, and that the no-results
+ * widget appears if the menu is empty.
+ */
+ MenuSelectWidget.prototype.postProcessItems = function () {
+ var i,
+ itemWasSelected = false,
+ items = this.getItems();
+
+ // If we are not already selecting an item, always make sure
+ // that the top item is selected
+ if ( !this.userSelecting ) {
+ // Select the first item in the list
+ for ( i = 0; i < items.length; i++ ) {
+ if (
+ !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
+ items[ i ].isVisible()
+ ) {
+ itemWasSelected = true;
+ this.selectItem( items[ i ] );
+ break;
+ }
+ }
+
+ if ( !itemWasSelected ) {
+ this.selectItem( null );
+ }
+ }
+
+ this.noResults.toggle( !this.getItems().some( function ( item ) {
+ return item.isVisible();
+ } ) );
+ };
+
+ /**
+ * Get the option widget that matches the model given
+ *
+ * @param {mw.rcfilters.dm.ItemModel} model Item model
+ * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
+ */
+ MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
+ this.lazyMenuCreation();
+ return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
+ return item.getName() === model.getName();
+ } )[ 0 ];
+ };
+
+ /**
+ * @inheritdoc
+ */
+ MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
+ var nextItem,
+ currentItem = this.findHighlightedItem() || this.findSelectedItem();
+
+ // Call parent
+ MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
+
+ // We want to select the item on arrow movement
+ // rather than just highlight it, like the menu
+ // does by default
+ if ( !this.isDisabled() && this.isVisible() ) {
+ switch ( e.keyCode ) {
+ case OO.ui.Keys.UP:
+ case OO.ui.Keys.LEFT:
+ // Get the next item
+ nextItem = this.findRelativeSelectableItem( currentItem, -1 );
+ break;
+ case OO.ui.Keys.DOWN:
+ case OO.ui.Keys.RIGHT:
+ // Get the next item
+ nextItem = this.findRelativeSelectableItem( currentItem, 1 );
+ break;
+ }
+
+ nextItem = nextItem && nextItem.constructor.static.selectable ?
+ nextItem : null;
+
+ // Select the next item
+ this.selectItem( nextItem );
+ }
+ };
+
+ /**
+ * Scroll to the top of the menu
+ */
+ MenuSelectWidget.prototype.scrollToTop = function () {
+ this.$body.scrollTop( 0 );
+ };
+
+ /**
+ * Set whether the user is currently selecting an item.
+ * This is important when the user selects an item that is in between
+ * different views, and makes sure we do not re-select a different
+ * item (like the item on top) when this is happening.
+ *
+ * @param {boolean} isSelecting User is selecting
+ */
+ MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
+ this.userSelecting = !!isSelecting;
+ };
+
+ module.exports = MenuSelectWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Top section (between page title and filters) on Special:Recentchanges
+ *
+ * @class mw.rcfilters.ui.RcTopSectionWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+ * @param {jQuery} $topLinks Content of the community-defined links
+ * @param {Object} [config] Configuration object
+ */
+ var RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
+ savedLinksListWidget, $topLinks, config
+ ) {
+ var toplinksTitle,
+ topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
+ topLinksCookie = mw.cookie.get( topLinksCookieName ),
+ topLinksCookieValue = topLinksCookie || 'collapsed',
+ widget = this;
+
+ config = config || {};
+
+ // Parent
+ RcTopSectionWidget.parent.call( this, config );
+
+ this.$topLinks = $topLinks;
+
+ toplinksTitle = new OO.ui.ButtonWidget( {
+ framed: false,
+ indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
+ flags: [ 'progressive' ],
+ label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
+ } );
+
+ this.$topLinks
+ .makeCollapsible( {
+ collapsed: topLinksCookieValue === 'collapsed',
+ $customTogglers: toplinksTitle.$element
+ } )
+ .on( 'beforeExpand.mw-collapsible', function () {
+ mw.cookie.set( topLinksCookieName, 'expanded' );
+ toplinksTitle.setIndicator( 'up' );
+ widget.switchTopLinks( 'expanded' );
+ } )
+ .on( 'beforeCollapse.mw-collapsible', function () {
+ mw.cookie.set( topLinksCookieName, 'collapsed' );
+ toplinksTitle.setIndicator( 'down' );
+ widget.switchTopLinks( 'collapsed' );
+ } );
+
+ this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
+ .replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );
+
+ // Create two positions for the toplinks to toggle between
+ // in the table (first cell) or up above it
+ this.$top = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
+ this.$tableTopLinks = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );
+
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ this.$tableTopLinks,
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table-placeholder' )
+ .addClass( 'mw-rcfilters-ui-cell' ),
+ !mw.user.isAnon() ?
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
+ .append( savedLinksListWidget.$element ) :
+ null
+ )
+ )
+ );
+
+ // Hack: For jumpiness reasons, this should be a sibling of -head
+ $( '.rcfilters-head' ).before( this.$top );
+
+ // Initialize top links position
+ widget.switchTopLinks( topLinksCookieValue );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( RcTopSectionWidget, OO.ui.Widget );
+
+ /**
+ * Switch the top links widget from inside the table (when collapsed)
+ * to the 'top' (when open)
+ *
+ * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
+ */
+ RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
+ state = state || 'expanded';
+
+ if ( state === 'expanded' ) {
+ this.$top.append( this.$topLinks );
+ } else {
+ this.$tableTopLinks.append( this.$topLinks );
+ }
+ this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
+ };
+
+ module.exports = RcTopSectionWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
+ *
+ * @class mw.rcfilters.ui.RclTargetPageWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FilterItem} targetPageModel
+ * @param {Object} [config] Configuration object
+ */
+ var RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
+ controller, targetPageModel, config
+ ) {
+ config = config || {};
+
+ // Parent
+ RclTargetPageWidget.parent.call( this, config );
+
+ this.controller = controller;
+ this.model = targetPageModel;
+
+ this.titleSearch = new mw.widgets.TitleInputWidget( {
+ validate: false,
+ placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
+ showImages: true,
+ showDescriptions: true,
+ addQueryInput: false
+ } );
+
+ // Events
+ this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+
+ this.titleSearch.$input.on( {
+ blur: this.onLookupInputBlur.bind( this )
+ } );
+
+ this.titleSearch.lookupMenu.connect( this, {
+ choose: 'onLookupMenuItemChoose'
+ } );
+
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
+ .append( this.titleSearch.$element );
+
+ this.updateUiBasedOnModel();
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( RclTargetPageWidget, OO.ui.Widget );
+
+ /* Methods */
+
+ /**
+ * Respond to the user choosing a title
+ */
+ RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
+ this.titleSearch.$input.trigger( 'blur' );
+ };
+
+ /**
+ * Respond to titleSearch $input blur
+ */
+ RclTargetPageWidget.prototype.onLookupInputBlur = function () {
+ this.controller.setTargetPage( this.titleSearch.getQueryValue() );
+ };
+
+ /**
+ * Respond to the model being updated
+ */
+ RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
+ var title = mw.Title.newFromText( this.model.getValue() ),
+ text = title ? title.toText() : this.model.getValue();
+ this.titleSearch.setValue( text );
+ this.titleSearch.setTitle( text );
+ };
+
+ module.exports = RclTargetPageWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Widget to select to view changes that link TO or FROM the target page
+ * on Special:RecentChangesLinked (AKA Related Changes)
+ *
+ * @class mw.rcfilters.ui.RclToOrFromWidget
+ * @extends OO.ui.DropdownWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
+ * @param {Object} [config] Configuration object
+ */
+ var RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
+ controller, showLinkedToModel, config
+ ) {
+ config = config || {};
+
+ this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
+ data: 'from', // default (showlinkedto=0)
+ label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
+ } );
+ this.showLinkedTo = new OO.ui.MenuOptionWidget( {
+ data: 'to', // showlinkedto=1
+ label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
+ } );
+
+ // Parent
+ RclToOrFromWidget.parent.call( this, $.extend( {
+ classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
+ menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
+ }, config ) );
+
+ this.controller = controller;
+ this.model = showLinkedToModel;
+
+ this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
+ this.model.connect( this, { update: 'onModelUpdate' } );
+
+ // force an initial update of the component based on the state
+ this.onModelUpdate();
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget );
+
+ /* Methods */
+
+ /**
+ * Respond to the user choosing an item in the menu
+ *
+ * @param {OO.ui.MenuOptionWidget} chosenItem
+ */
+ RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
+ this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
+ };
+
+ /**
+ * Respond to model update
+ */
+ RclToOrFromWidget.prototype.onModelUpdate = function () {
+ this.getMenu().selectItem(
+ this.model.isSelected() ?
+ this.showLinkedTo :
+ this.showLinkedFrom
+ );
+ this.setLabel( mw.msg(
+ this.model.isSelected() ?
+ 'rcfilters-filter-showlinkedto-label' :
+ 'rcfilters-filter-showlinkedfrom-label'
+ ) );
+ };
+
+ module.exports = RclToOrFromWidget;
+}() );
--- /dev/null
+( function () {
+ var RclToOrFromWidget = require( './RclToOrFromWidget.js' ),
+ RclTargetPageWidget = require( './RclTargetPageWidget.js' ),
+ RclTopSectionWidget;
+
+ /**
+ * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
+ *
+ * @class mw.rcfilters.ui.RclTopSectionWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
+ * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
+ * @param {Object} [config] Configuration object
+ */
+ RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
+ savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
+ ) {
+ var toOrFromWidget,
+ targetPage;
+ config = config || {};
+
+ // Parent
+ RclTopSectionWidget.parent.call( this, config );
+
+ this.controller = controller;
+
+ toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel );
+ targetPage = new RclTargetPageWidget( controller, targetPageModel );
+
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .append( toOrFromWidget.$element )
+ ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .append( targetPage.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table-placeholder' )
+ .addClass( 'mw-rcfilters-ui-cell' ),
+ !mw.user.isAnon() ?
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
+ .append( savedLinksListWidget.$element ) :
+ null
+ )
+ )
+ );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( RclTopSectionWidget, OO.ui.Widget );
+
+ module.exports = RclTopSectionWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Save filters widget. This widget is displayed in the tag area
+ * and allows the user to save the current state of the system
+ * as a new saved filter query they can later load or set as
+ * default.
+ *
+ * @class mw.rcfilters.ui.SaveFiltersPopupButtonWidget
+ * @extends OO.ui.PopupButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
+ * @param {Object} [config] Configuration object
+ */
+ var SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
+ var layout,
+ checkBoxLayout,
+ $popupContent = $( '<div>' );
+
+ config = config || {};
+
+ this.controller = controller;
+ this.model = model;
+
+ // Parent
+ SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
+ framed: false,
+ icon: 'bookmark',
+ title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
+ popup: {
+ classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
+ padded: true,
+ head: true,
+ label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
+ $content: $popupContent
+ }
+ }, config ) );
+ // // HACK: Add an icon to the popup head label
+ this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
+
+ this.input = new OO.ui.TextInputWidget( {
+ placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
+ } );
+ layout = new OO.ui.FieldLayout( this.input, {
+ label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
+ align: 'top'
+ } );
+
+ this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
+ checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
+ label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
+ align: 'inline'
+ } );
+
+ this.applyButton = new OO.ui.ButtonWidget( {
+ label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
+ classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
+ flags: [ 'primary', 'progressive' ]
+ } );
+ this.cancelButton = new OO.ui.ButtonWidget( {
+ label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
+ classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
+ } );
+
+ $popupContent
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
+ .append( layout.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
+ .append( checkBoxLayout.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
+ .append(
+ this.cancelButton.$element,
+ this.applyButton.$element
+ )
+ );
+
+ // Events
+ this.popup.connect( this, {
+ ready: 'onPopupReady'
+ } );
+ this.input.connect( this, {
+ change: 'onInputChange',
+ enter: 'onInputEnter'
+ } );
+ this.input.$input.on( {
+ keyup: this.onInputKeyup.bind( this )
+ } );
+ this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
+ this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
+ this.applyButton.connect( this, { click: 'onApplyButtonClick' } );
+
+ // Initialize
+ this.applyButton.setDisabled( !this.input.getValue() );
+ this.$element
+ .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
+ };
+
+ /* Initialization */
+ OO.inheritClass( SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );
+
+ /**
+ * Respond to input enter event
+ */
+ SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
+ this.apply();
+ };
+
+ /**
+ * Respond to input change event
+ *
+ * @param {string} value Input value
+ */
+ SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
+ value = value.trim();
+
+ this.applyButton.setDisabled( !value );
+ };
+
+ /**
+ * Respond to input keyup event, this is the way to intercept 'escape' key
+ *
+ * @param {jQuery.Event} e Event data
+ * @return {boolean} false
+ */
+ SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
+ if ( e.which === OO.ui.Keys.ESCAPE ) {
+ this.popup.toggle( false );
+ return false;
+ }
+ };
+
+ /**
+ * Respond to popup ready event
+ */
+ SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
+ this.input.focus();
+ };
+
+ /**
+ * Respond to "set as default" checkbox change
+ * @param {boolean} checked State of the checkbox
+ */
+ SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
+ var messageKey = checked ?
+ 'rcfilters-savedqueries-apply-and-setdefault-label' :
+ 'rcfilters-savedqueries-apply-label';
+
+ this.applyButton
+ .setIcon( checked ? 'pushPin' : null )
+ .setLabel( mw.msg( messageKey ) );
+ };
+
+ /**
+ * Respond to cancel button click event
+ */
+ SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
+ this.popup.toggle( false );
+ };
+
+ /**
+ * Respond to apply button click event
+ */
+ SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
+ this.apply();
+ };
+
+ /**
+ * Apply and add the new quick link
+ */
+ SaveFiltersPopupButtonWidget.prototype.apply = function () {
+ var label = this.input.getValue().trim();
+
+ // This condition is more for sanity-check, since the
+ // apply button should be disabled if the label is empty
+ if ( label ) {
+ this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
+ this.input.setValue( '' );
+ this.setAsDefaultCheckbox.setSelected( false );
+ this.popup.toggle( false );
+
+ this.emit( 'saveCurrent' );
+ }
+ };
+
+ module.exports = SaveFiltersPopupButtonWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Quick links menu option widget
+ *
+ * @class mw.rcfilters.ui.SavedLinksListItemWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.TitledElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
+ * @param {Object} [config] Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+ var SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
+ config = config || {};
+
+ this.model = model;
+
+ // Parent
+ SavedLinksListItemWidget.parent.call( this, $.extend( {
+ data: this.model.getID()
+ }, config ) );
+
+ // Mixin constructors
+ OO.ui.mixin.LabelElement.call( this, $.extend( {
+ label: this.model.getLabel()
+ }, config ) );
+ OO.ui.mixin.IconElement.call( this, $.extend( {
+ icon: ''
+ }, config ) );
+ OO.ui.mixin.TitledElement.call( this, $.extend( {
+ title: this.model.getLabel()
+ }, config ) );
+
+ this.edit = false;
+ this.$overlay = config.$overlay || this.$element;
+
+ this.popupButton = new OO.ui.ButtonWidget( {
+ classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
+ icon: 'ellipsis',
+ framed: false
+ } );
+ this.menu = new OO.ui.MenuSelectWidget( {
+ classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
+ widget: this.popupButton,
+ width: 200,
+ horizontalPosition: 'end',
+ $floatableContainer: this.popupButton.$element,
+ items: [
+ new OO.ui.MenuOptionWidget( {
+ data: 'edit',
+ icon: 'edit',
+ label: mw.msg( 'rcfilters-savedqueries-rename' )
+ } ),
+ new OO.ui.MenuOptionWidget( {
+ data: 'delete',
+ icon: 'trash',
+ label: mw.msg( 'rcfilters-savedqueries-remove' )
+ } ),
+ new OO.ui.MenuOptionWidget( {
+ data: 'default',
+ icon: 'pushPin',
+ label: mw.msg( 'rcfilters-savedqueries-setdefault' )
+ } )
+ ]
+ } );
+
+ this.editInput = new OO.ui.TextInputWidget( {
+ classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
+ } );
+ this.saveButton = new OO.ui.ButtonWidget( {
+ icon: 'check',
+ flags: [ 'primary', 'progressive' ]
+ } );
+ this.toggleEdit( false );
+
+ // Events
+ this.model.connect( this, { update: 'onModelUpdate' } );
+ this.popupButton.connect( this, { click: 'onPopupButtonClick' } );
+ this.menu.connect( this, {
+ choose: 'onMenuChoose'
+ } );
+ this.saveButton.connect( this, { click: 'save' } );
+ this.editInput.connect( this, {
+ change: 'onInputChange',
+ enter: 'save'
+ } );
+ this.editInput.$input.on( {
+ blur: this.onInputBlur.bind( this ),
+ keyup: this.onInputKeyup.bind( this )
+ } );
+ this.$element.on( { click: this.onClick.bind( this ) } );
+ this.$label.on( { click: this.onClick.bind( this ) } );
+ this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );
+ // Prevent propagation on mousedown for the save button
+ // so the menu doesn't close
+ this.saveButton.$element.on( { mousedown: function () {
+ return false;
+ } } );
+
+ // Initialize
+ this.toggleDefault( !!this.model.isDefault() );
+ this.$overlay.append( this.menu.$element );
+ this.$element
+ .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
+ .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
+ .append(
+ this.$label
+ .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
+ this.editInput.$element,
+ this.saveButton.$element
+ ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
+ .append( this.$icon ),
+ this.popupButton.$element
+ .addClass( 'mw-rcfilters-ui-cell' )
+ )
+ )
+ );
+ };
+
+ /* Initialization */
+ OO.inheritClass( SavedLinksListItemWidget, OO.ui.Widget );
+ OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.LabelElement );
+ OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.IconElement );
+ OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.TitledElement );
+
+ /* Events */
+
+ /**
+ * @event delete
+ *
+ * The delete option was selected for this item
+ */
+
+ /**
+ * @event default
+ * @param {boolean} default Item is default
+ *
+ * The 'make default' option was selected for this item
+ */
+
+ /**
+ * @event edit
+ * @param {string} newLabel New label for the query
+ *
+ * The label has been edited
+ */
+
+ /* Methods */
+
+ /**
+ * Respond to model update event
+ */
+ SavedLinksListItemWidget.prototype.onModelUpdate = function () {
+ this.setLabel( this.model.getLabel() );
+ this.toggleDefault( this.model.isDefault() );
+ };
+
+ /**
+ * Respond to click on the element or label
+ *
+ * @fires click
+ */
+ SavedLinksListItemWidget.prototype.onClick = function () {
+ if ( !this.editing ) {
+ this.emit( 'click' );
+ }
+ };
+
+ /**
+ * Respond to click on the 'default' icon. Open the submenu where the
+ * default state can be changed.
+ *
+ * @return {boolean} false
+ */
+ SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
+ this.menu.toggle();
+ return false;
+ };
+
+ /**
+ * Respond to popup button click event
+ */
+ SavedLinksListItemWidget.prototype.onPopupButtonClick = function () {
+ this.menu.toggle();
+ };
+
+ /**
+ * Respond to menu choose event
+ *
+ * @param {OO.ui.MenuOptionWidget} item Chosen item
+ * @fires delete
+ * @fires default
+ */
+ SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
+ var action = item.getData();
+
+ if ( action === 'edit' ) {
+ this.toggleEdit( true );
+ } else if ( action === 'delete' ) {
+ this.emit( 'delete' );
+ } else if ( action === 'default' ) {
+ this.emit( 'default', !this.default );
+ }
+ // Reset selected
+ this.menu.selectItem( null );
+ // Close the menu
+ this.menu.toggle( false );
+ };
+
+ /**
+ * Respond to input keyup event, this is the way to intercept 'escape' key
+ *
+ * @param {jQuery.Event} e Event data
+ * @return {boolean} false
+ */
+ SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
+ if ( e.which === OO.ui.Keys.ESCAPE ) {
+ // Return the input to the original label
+ this.editInput.setValue( this.getLabel() );
+ this.toggleEdit( false );
+ return false;
+ }
+ };
+
+ /**
+ * Respond to blur event on the input
+ */
+ SavedLinksListItemWidget.prototype.onInputBlur = function () {
+ this.save();
+
+ // Whether the save succeeded or not, the input-blur event
+ // means we need to cancel editing mode
+ this.toggleEdit( false );
+ };
+
+ /**
+ * Respond to input change event
+ *
+ * @param {string} value Input value
+ */
+ SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
+ value = value.trim();
+
+ this.saveButton.setDisabled( !value );
+ };
+
+ /**
+ * Save the name of the query
+ *
+ * @param {string} [value] The value to save
+ * @fires edit
+ */
+ SavedLinksListItemWidget.prototype.save = function () {
+ var value = this.editInput.getValue().trim();
+
+ if ( value ) {
+ this.emit( 'edit', value );
+ this.toggleEdit( false );
+ }
+ };
+
+ /**
+ * Toggle edit mode on this widget
+ *
+ * @param {boolean} isEdit Widget is in edit mode
+ */
+ SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
+ isEdit = isEdit === undefined ? !this.editing : isEdit;
+
+ if ( this.editing !== isEdit ) {
+ this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
+ this.editInput.setValue( this.getLabel() );
+
+ this.editInput.toggle( isEdit );
+ this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
+ this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
+ this.popupButton.toggle( !isEdit );
+ this.saveButton.toggle( isEdit );
+
+ if ( isEdit ) {
+ this.editInput.$input.trigger( 'focus' );
+ }
+ this.editing = isEdit;
+ }
+ };
+
+ /**
+ * Toggle default this widget
+ *
+ * @param {boolean} isDefault This item is default
+ */
+ SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
+ isDefault = isDefault === undefined ? !this.default : isDefault;
+
+ if ( this.default !== isDefault ) {
+ this.default = isDefault;
+ this.setIcon( this.default ? 'pushPin' : '' );
+ this.menu.findItemFromData( 'default' ).setLabel(
+ this.default ?
+ mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
+ mw.msg( 'rcfilters-savedqueries-setdefault' )
+ );
+ }
+ };
+
+ /**
+ * Get item ID
+ *
+ * @return {string} Query identifier
+ */
+ SavedLinksListItemWidget.prototype.getID = function () {
+ return this.model.getID();
+ };
+
+ module.exports = SavedLinksListItemWidget;
+
+}() );
--- /dev/null
+( function () {
+ var GroupWidget = require( './GroupWidget.js' ),
+ SavedLinksListItemWidget = require( './SavedLinksListItemWidget.js' ),
+ SavedLinksListWidget;
+
+ /**
+ * Quick links widget
+ *
+ * @class mw.rcfilters.ui.SavedLinksListWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
+ * @param {Object} [config] Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+ SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
+ var $labelNoEntries = $( '<div>' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
+ .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
+ .text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
+ );
+
+ config = config || {};
+
+ // Parent
+ SavedLinksListWidget.parent.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+ this.$overlay = config.$overlay || this.$element;
+
+ this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
+ classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
+ label: $labelNoEntries,
+ icon: 'bookmark'
+ } );
+
+ this.menu = new GroupWidget( {
+ events: {
+ click: 'menuItemClick',
+ delete: 'menuItemDelete',
+ default: 'menuItemDefault',
+ edit: 'menuItemEdit'
+ },
+ classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
+ items: [ this.placeholderItem ]
+ } );
+ this.button = new OO.ui.PopupButtonWidget( {
+ classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
+ label: mw.msg( 'rcfilters-quickfilters' ),
+ icon: 'bookmark',
+ indicator: 'down',
+ $overlay: this.$overlay,
+ popup: {
+ width: 300,
+ anchor: false,
+ align: 'backwards',
+ $autoCloseIgnore: this.$overlay,
+ $content: this.menu.$element
+ }
+ } );
+
+ // Events
+ this.model.connect( this, {
+ add: 'onModelAddItem',
+ remove: 'onModelRemoveItem'
+ } );
+ this.menu.connect( this, {
+ menuItemClick: 'onMenuItemClick',
+ menuItemDelete: 'onMenuItemRemove',
+ menuItemDefault: 'onMenuItemDefault',
+ menuItemEdit: 'onMenuItemEdit'
+ } );
+
+ this.placeholderItem.toggle( this.model.isEmpty() );
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-savedLinksListWidget' )
+ .append( this.button.$element );
+ };
+
+ /* Initialization */
+ OO.inheritClass( SavedLinksListWidget, OO.ui.Widget );
+
+ /* Methods */
+
+ /**
+ * Respond to menu item click event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+ SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) {
+ this.controller.applySavedQuery( item.getID() );
+ this.button.popup.toggle( false );
+ };
+
+ /**
+ * Respond to menu item remove event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+ SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
+ this.controller.removeSavedQuery( item.getID() );
+ };
+
+ /**
+ * Respond to menu item default event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ * @param {boolean} isDefault Item is default
+ */
+ SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
+ this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
+ };
+
+ /**
+ * Respond to menu item edit event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ * @param {string} newLabel New label
+ */
+ SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
+ this.controller.renameSavedQuery( item.getID(), newLabel );
+ };
+
+ /**
+ * Respond to menu add item event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+ SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
+ if ( this.menu.findItemFromData( item.getID() ) ) {
+ return;
+ }
+
+ this.menu.addItems( [
+ new SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
+ ] );
+ this.placeholderItem.toggle( this.model.isEmpty() );
+ };
+
+ /**
+ * Respond to menu remove item event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+ SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
+ this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
+ this.placeholderItem.toggle( this.model.isEmpty() );
+ };
+
+ module.exports = SavedLinksListWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Extend OOUI's TagItemWidget to also display a popup on hover.
+ *
+ * @class mw.rcfilters.ui.TagItemWidget
+ * @extends OO.ui.TagItemWidget
+ * @mixins OO.ui.mixin.PopupElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.FilterItem} invertModel
+ * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+ var TagItemWidget = function MwRcfiltersUiTagItemWidget(
+ controller, filtersViewModel, invertModel, itemModel, config
+ ) {
+ // Configuration initialization
+ config = config || {};
+
+ this.controller = controller;
+ this.invertModel = invertModel;
+ this.filtersViewModel = filtersViewModel;
+ this.itemModel = itemModel;
+ this.selected = false;
+
+ TagItemWidget.parent.call( this, $.extend( {
+ data: this.itemModel.getName()
+ }, config ) );
+
+ this.$overlay = config.$overlay || this.$element;
+ this.popupLabel = new OO.ui.LabelWidget();
+
+ // Mixin constructors
+ OO.ui.mixin.PopupElement.call( this, $.extend( {
+ popup: {
+ padded: false,
+ align: 'center',
+ position: 'above',
+ $content: $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
+ .append( this.popupLabel.$element ),
+ $floatableContainer: this.$element,
+ classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
+ }
+ }, config ) );
+
+ this.popupTimeoutShow = null;
+ this.popupTimeoutHide = null;
+
+ this.$highlight = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );
+
+ // Add title attribute with the item label to 'x' button
+ this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );
+
+ // Events
+ this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
+ this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
+ this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
+
+ // Initialization
+ this.$overlay.append( this.popup.$element );
+ this.$element
+ .addClass( 'mw-rcfilters-ui-tagItemWidget' )
+ .prepend( this.$highlight )
+ .attr( 'aria-haspopup', 'true' )
+ .on( 'mouseenter', this.onMouseEnter.bind( this ) )
+ .on( 'mouseleave', this.onMouseLeave.bind( this ) );
+
+ this.updateUiBasedOnState();
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( TagItemWidget, OO.ui.TagItemWidget );
+ OO.mixinClass( TagItemWidget, OO.ui.mixin.PopupElement );
+
+ /* Methods */
+
+ /**
+ * Respond to model update event
+ */
+ TagItemWidget.prototype.updateUiBasedOnState = function () {
+ // Update label if needed
+ var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel.isSelected() );
+ if ( labelMsg ) {
+ this.setLabel( $( '<div>' ).append(
+ $( '<bdi>' ).html(
+ mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
+ )
+ ).contents() );
+ } else {
+ this.setLabel(
+ $( '<bdi>' ).append(
+ this.itemModel.getLabel()
+ )
+ );
+ }
+
+ this.setCurrentMuteState();
+ this.setHighlightColor();
+ };
+
+ /**
+ * Set the current highlight color for this item
+ */
+ TagItemWidget.prototype.setHighlightColor = function () {
+ var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
+ this.itemModel.getHighlightColor() :
+ null;
+
+ this.$highlight
+ .attr( 'data-color', selectedColor )
+ .toggleClass(
+ 'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
+ !!selectedColor
+ );
+ };
+
+ /**
+ * Set the current mute state for this item
+ */
+ TagItemWidget.prototype.setCurrentMuteState = function () {};
+
+ /**
+ * Respond to mouse enter event
+ */
+ TagItemWidget.prototype.onMouseEnter = function () {
+ var labelText = this.itemModel.getStateMessage();
+
+ if ( labelText ) {
+ this.popupLabel.setLabel( labelText );
+
+ // Set timeout for the popup to show
+ this.popupTimeoutShow = setTimeout( function () {
+ this.popup.toggle( true );
+ }.bind( this ), 500 );
+
+ // Cancel the hide timeout
+ clearTimeout( this.popupTimeoutHide );
+ this.popupTimeoutHide = null;
+ }
+ };
+
+ /**
+ * Respond to mouse leave event
+ */
+ TagItemWidget.prototype.onMouseLeave = function () {
+ this.popupTimeoutHide = setTimeout( function () {
+ this.popup.toggle( false );
+ }.bind( this ), 250 );
+
+ // Clear the show timeout
+ clearTimeout( this.popupTimeoutShow );
+ this.popupTimeoutShow = null;
+ };
+
+ /**
+ * Set selected state on this widget
+ *
+ * @param {boolean} [isSelected] Widget is selected
+ */
+ TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
+ isSelected = isSelected !== undefined ? isSelected : !this.selected;
+
+ if ( this.selected !== isSelected ) {
+ this.selected = isSelected;
+
+ this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
+ }
+ };
+
+ /**
+ * Get the selected state of this widget
+ *
+ * @return {boolean} Tag is selected
+ */
+ TagItemWidget.prototype.isSelected = function () {
+ return this.selected;
+ };
+
+ /**
+ * Get item name
+ *
+ * @return {string} Filter name
+ */
+ TagItemWidget.prototype.getName = function () {
+ return this.itemModel.getName();
+ };
+
+ /**
+ * Get item model
+ *
+ * @return {string} Filter model
+ */
+ TagItemWidget.prototype.getModel = function () {
+ return this.itemModel;
+ };
+
+ /**
+ * Get item view
+ *
+ * @return {string} Filter view
+ */
+ TagItemWidget.prototype.getView = function () {
+ return this.itemModel.getGroupModel().getView();
+ };
+
+ /**
+ * Remove and destroy external elements of this widget
+ */
+ TagItemWidget.prototype.destroy = function () {
+ // Destroy the popup
+ this.popup.$element.detach();
+
+ // Disconnect events
+ this.itemModel.disconnect( this );
+ this.closeButton.disconnect( this );
+ };
+
+ module.exports = TagItemWidget;
+}() );
--- /dev/null
+( function () {
+ /**
+ * Widget defining the behavior used to choose from a set of values
+ * in a single_value group
+ *
+ * @class mw.rcfilters.ui.ValuePickerWidget
+ * @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(); }
+ */
+ var ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) {
+ config = config || {};
+
+ // Parent
+ 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( ValuePickerWidget, OO.ui.Widget );
+ OO.mixinClass( ValuePickerWidget, OO.ui.mixin.LabelElement );
+
+ /* Events */
+
+ /**
+ * @event choose
+ * @param {string} name Item name
+ *
+ * An item has been chosen
+ */
+
+ /* Methods */
+
+ /**
+ * Respond to model update event
+ */
+ ValuePickerWidget.prototype.onModelUpdate = function () {
+ this.selectCurrentModelItem();
+ };
+
+ /**
+ * Respond to select widget choose event
+ *
+ * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item
+ * @fires choose
+ */
+ ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) {
+ this.emit( 'choose', chosenItem.getData() );
+ };
+
+ /**
+ * Initialize the select widget
+ */
+ 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
+ */
+ ValuePickerWidget.prototype.selectCurrentModelItem = function () {
+ var selectedItem = this.model.findSelectedItems()[ 0 ];
+
+ if ( selectedItem ) {
+ this.selectWidget.selectItemByData( selectedItem.getName() );
+ }
+ };
+
+ module.exports = ValuePickerWidget;
+}() );
--- /dev/null
+( function () {
+ var GroupWidget = require( './GroupWidget.js' ),
+ ViewSwitchWidget;
+
+ /**
+ * A widget for the footer for the default view, allowing to switch views
+ *
+ * @class mw.rcfilters.ui.ViewSwitchWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} [config] Configuration object
+ */
+ ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
+ config = config || {};
+
+ // Parent
+ ViewSwitchWidget.parent.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+
+ this.buttons = new GroupWidget( {
+ events: {
+ click: 'buttonClick'
+ },
+ items: [
+ new OO.ui.ButtonWidget( {
+ data: 'namespaces',
+ icon: 'article',
+ label: mw.msg( 'namespaces' )
+ } ),
+ new OO.ui.ButtonWidget( {
+ data: 'tags',
+ icon: 'tag',
+ label: mw.msg( 'rcfilters-view-tags' )
+ } )
+ ]
+ } );
+
+ // Events
+ this.model.connect( this, { update: 'onModelUpdate' } );
+ this.buttons.connect( this, { buttonClick: 'onButtonClick' } );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
+ .append(
+ new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-advancedfilters' )
+ } ).$element,
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
+ .append( this.buttons.$element )
+ );
+ };
+
+ /* Initialize */
+
+ OO.inheritClass( ViewSwitchWidget, OO.ui.Widget );
+
+ /**
+ * Respond to model update event
+ */
+ ViewSwitchWidget.prototype.onModelUpdate = function () {
+ var currentView = this.model.getCurrentView();
+
+ this.buttons.getItems().forEach( function ( buttonWidget ) {
+ buttonWidget.setActive( buttonWidget.getData() === currentView );
+ } );
+ };
+
+ /**
+ * Respond to button switch click
+ *
+ * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
+ */
+ ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
+ this.controller.switchView( buttonWidget.getData() );
+ };
+
+ module.exports = ViewSwitchWidget;
+}() );
--- /dev/null
+( function () {
+ var MarkSeenButtonWidget = require( './MarkSeenButtonWidget.js' ),
+ WatchlistTopSectionWidget;
+ /**
+ * Top section (between page title and filters) on Special:Watchlist
+ *
+ * @class mw.rcfilters.ui.WatchlistTopSectionWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+ * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
+ * @param {Object} [config] Configuration object
+ */
+ WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
+ controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
+ ) {
+ var editWatchlistButton,
+ markSeenButton,
+ $topTable,
+ $bottomTable,
+ $separator;
+ config = config || {};
+
+ // Parent
+ WatchlistTopSectionWidget.parent.call( this, config );
+
+ editWatchlistButton = new OO.ui.ButtonWidget( {
+ label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
+ icon: 'edit',
+ href: mw.config.get( 'wgStructuredChangeFiltersEditWatchlistUrl' )
+ } );
+ markSeenButton = new MarkSeenButtonWidget( controller, changesListModel );
+
+ $topTable = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
+ .append( $watchlistDetails )
+ )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
+ .append( editWatchlistButton.$element )
+ )
+ );
+
+ $bottomTable = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .append( markSeenButton.$element )
+ )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
+ .append( savedLinksListWidget.$element )
+ )
+ );
+
+ $separator = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
+ .append( $topTable, $separator, $bottomTable );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget );
+
+ module.exports = WatchlistTopSectionWidget;
+}() );
+++ /dev/null
-( function () {
- /**
- * Widget defining the button controlling the popup for the number of results
- *
- * @class
- * @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.ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.parent.call( this, config );
-
- this.controller = controller;
- this.model = model;
-
- this.$overlay = config.$overlay || this.$element;
-
- this.button = null;
- this.limitGroupModel = null;
- this.groupByPageItemModel = null;
- this.daysGroupModel = null;
-
- this.model.connect( this, {
- initialize: 'onModelInitialize'
- } );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.ChangesLimitAndDateButtonWidget, OO.ui.Widget );
-
- /**
- * Respond to model initialize event
- */
- mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
- var changesLimitPopupWidget, selectedItem, currentValue, datePopupWidget,
- displayGroupModel = this.model.getGroup( 'display' );
-
- this.limitGroupModel = this.model.getGroup( 'limit' );
- this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
- 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.limitGroupModel && this.daysGroupModel ) {
- changesLimitPopupWidget = new mw.rcfilters.ui.ChangesLimitPopupWidget(
- this.limitGroupModel,
- this.groupByPageItemModel
- );
-
- datePopupWidget = new mw.rcfilters.ui.DatePopupWidget(
- this.daysGroupModel,
- {
- label: mw.msg( 'rcfilters-date-popup-title' )
- }
- );
-
- selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
- currentValue = ( selectedItem && selectedItem.getLabel() ) ||
- mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );
-
- this.button = new OO.ui.PopupButtonWidget( {
- icon: 'settings',
- indicator: 'down',
- label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
- $overlay: this.$overlay,
- popup: {
- width: 300,
- padded: false,
- anchor: false,
- align: 'backwards',
- $autoCloseIgnore: this.$overlay,
- $content: $( '<div>' ).append(
- // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
- changesLimitPopupWidget.$element,
- datePopupWidget.$element
- )
- }
- } );
- this.updateButtonLabel();
-
- // Events
- this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
- this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
- changesLimitPopupWidget.connect( this, {
- limit: 'onPopupLimit',
- groupByPage: 'onPopupGroupByPage'
- } );
- 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.ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
- var item = this.limitGroupModel.getItemByName( filterName );
-
- this.controller.toggleFilterSelect( filterName, true );
- this.controller.updateLimitDefault( item.getParamName() );
- this.button.popup.toggle( false );
- };
-
- /**
- * Respond to popup limit change event
- *
- * @param {boolean} isGrouped The result set is grouped by page
- */
- mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
- this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
- this.controller.updateGroupByPageDefault( isGrouped );
- this.button.popup.toggle( false );
- };
-
- /**
- * Respond to popup limit change event
- *
- * @param {string} filterName Chosen filter name
- */
- mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
- var item = this.daysGroupModel.getItemByName( filterName );
-
- this.controller.toggleFilterSelect( filterName, true );
- this.controller.updateDaysDefault( item.getParamName() );
- this.button.popup.toggle( false );
- };
-
- /**
- * Respond to limit choose event
- *
- * @param {string} filterName Filter name
- */
- mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
- var message,
- limit = this.limitGroupModel.findSelectedItems()[ 0 ],
- label = limit && limit.getLabel(),
- days = this.daysGroupModel.findSelectedItems()[ 0 ],
- daysParamName = Number( days.getParamName() ) < 1 ?
- 'rcfilters-days-show-hours' :
- 'rcfilters-days-show-days';
-
- // Update the label
- if ( label && days ) {
- message = mw.msg( 'rcfilters-limit-and-date-label', label,
- mw.msg( daysParamName, days.getLabel() )
- );
- this.button.setLabel( message );
- }
- };
-
-}() );
+++ /dev/null
-( function () {
- /**
- * Widget defining the popup to choose number of results
- *
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
- * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.ChangesLimitPopupWidget.parent.call( this, config );
-
- this.limitModel = limitModel;
- this.groupByPageItemModel = groupByPageItemModel;
-
- this.valuePicker = new mw.rcfilters.ui.ValuePickerWidget(
- this.limitModel,
- {
- label: mw.msg( 'rcfilters-limit-title' )
- }
- );
-
- this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
- selected: this.groupByPageItemModel.isSelected()
- } );
-
- // Events
- this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
- this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
- this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
-
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
- .append(
- this.valuePicker.$element,
- new OO.ui.FieldLayout(
- this.groupByPageCheckbox,
- {
- align: 'inline',
- label: mw.msg( 'rcfilters-group-results-by-page' )
- }
- ).$element
- );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.ChangesLimitPopupWidget, OO.ui.Widget );
-
- /* Events */
-
- /**
- * @event limit
- * @param {string} name Item name
- *
- * A limit item was chosen
- */
-
- /**
- * @event groupByPage
- * @param {boolean} isGrouped The results are grouped by page
- *
- * Results are grouped by page
- */
-
- /**
- * Respond to group by page model update
- */
- mw.rcfilters.ui.ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
- this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * List of changes
- *
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
- * @param {mw.rcfilters.Controller} controller
- * @param {jQuery} $changesListRoot Root element of the changes list to attach to
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
- filtersViewModel,
- changesListViewModel,
- controller,
- $changesListRoot,
- config
- ) {
- config = $.extend( {}, config, {
- $element: $changesListRoot
- } );
-
- // Parent
- mw.rcfilters.ui.ChangesListWrapperWidget.parent.call( this, config );
-
- this.filtersViewModel = filtersViewModel;
- this.changesListViewModel = changesListViewModel;
- this.controller = controller;
- this.highlightClasses = null;
-
- // Events
- this.filtersViewModel.connect( this, {
- itemUpdate: 'onItemUpdate',
- highlightChange: 'onHighlightChange'
- } );
- this.changesListViewModel.connect( this, {
- invalidate: 'onModelInvalidate',
- update: 'onModelUpdate'
- } );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
- // We handle our own display/hide of the empty results message
- // We keep the timeout class here and remove it later, since at this
- // stage it is still needed to identify that the timeout occurred.
- .removeClass( 'mw-changeslist-empty' );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.ChangesListWrapperWidget, OO.ui.Widget );
-
- /**
- * Get all available highlight classes
- *
- * @return {string[]} An array of available highlight class names
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.getHighlightClasses = function () {
- if ( !this.highlightClasses || !this.highlightClasses.length ) {
- this.highlightClasses = this.filtersViewModel.getItemsSupportingHighlights()
- .map( function ( filterItem ) {
- return filterItem.getCssClass();
- } );
- }
-
- return this.highlightClasses;
- };
-
- /**
- * Respond to the highlight feature being toggled on and off
- *
- * @param {boolean} highlightEnabled
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
- if ( highlightEnabled ) {
- this.applyHighlight();
- } else {
- this.clearHighlight();
- }
- };
-
- /**
- * Respond to a filter item model update
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onItemUpdate = function () {
- if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
- // this.controller.isInitialized() is still false during page load,
- // we don't want to clear/apply highlights at this stage.
- this.clearHighlight();
- this.applyHighlight();
- }
- };
-
- /**
- * Respond to changes list model invalidate
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
- $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
- };
-
- /**
- * Respond to changes list model update
- *
- * @param {jQuery|string} $changesListContent The content of the updated changes list
- * @param {jQuery} $fieldset The content of the updated fieldset
- * @param {string} noResultsDetails Type of no result error
- * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
- * @param {boolean} from Timestamp of the new changes
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelUpdate = function (
- $changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
- ) {
- var conflictItem,
- $message = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
- isEmpty = $changesListContent === 'NO_RESULTS',
- // For enhanced mode, we have to load these modules, which are
- // not loaded for the 'regular' mode in the backend
- loaderPromise = mw.user.options.get( 'usenewrc' ) ?
- mw.loader.using( [ 'mediawiki.special.changeslist.enhanced', 'mediawiki.icon' ] ) :
- $.Deferred().resolve(),
- widget = this;
-
- this.$element.toggleClass( 'mw-changeslist', !isEmpty );
- if ( isEmpty ) {
- this.$element.empty();
-
- if ( this.filtersViewModel.hasConflict() ) {
- conflictItem = this.filtersViewModel.getFirstConflictedItem();
-
- $message
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
- .text( mw.message( 'rcfilters-noresults-conflict' ).text() ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
- .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() )
- );
- } else {
- $message
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
- .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
- );
-
- // remove all classes matching mw-changeslist-*
- this.$element.removeClass( function ( elementIndex, allClasses ) {
- return allClasses
- .split( ' ' )
- .filter( function ( className ) {
- return className.indexOf( 'mw-changeslist-' ) === 0;
- } )
- .join( ' ' );
- } );
- }
-
- this.$element.append( $message );
- } else {
- if ( !isInitialDOM ) {
- this.$element.empty().append( $changesListContent );
-
- if ( from ) {
- this.emphasizeNewChanges( from );
- }
- }
-
- // Apply highlight
- this.applyHighlight();
-
- }
-
- this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
-
- loaderPromise.done( function () {
- if ( !isInitialDOM && !isEmpty ) {
- // Make sure enhanced RC re-initializes correctly
- mw.hook( 'wikipage.content' ).fire( widget.$element );
- }
-
- $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
- } );
- };
-
- /** Toggles overlay class on changes list
- *
- * @param {boolean} isVisible True if overlay should be visible
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
- this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
- };
-
- /**
- * Map a reason for having no results to its message key
- *
- * @param {string} reason One of the NO_RESULTS_* "constant" that represent
- * a reason for having no results
- * @return {string} Key for the message that explains why there is no results in this case
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
- var reasonMsgKeyMap = {
- NO_RESULTS_NORMAL: 'recentchanges-noresult',
- NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
- NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
- NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
- NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
- };
- return reasonMsgKeyMap[ reason ];
- };
-
- /**
- * Emphasize the elements (or groups) newer than the 'from' parameter
- * @param {string} from Anything newer than this is considered 'new'
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
- var $firstNew,
- $indicator,
- $newChanges = $( [] ),
- selector = this.inEnhancedMode() ?
- 'table.mw-enhanced-rc[data-mw-ts]' :
- 'li[data-mw-ts]',
- set = this.$element.find( selector ),
- length = set.length;
-
- set.each( function ( index ) {
- var $this = $( this ),
- ts = $this.data( 'mw-ts' );
-
- if ( ts >= from ) {
- $newChanges = $newChanges.add( $this );
- $firstNew = $this;
-
- // guards against putting the marker after the last element
- if ( index === ( length - 1 ) ) {
- $firstNew = null;
- }
- }
- } );
-
- if ( $firstNew ) {
- $indicator = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
-
- $firstNew.after( $indicator );
- }
-
- // FIXME: Use CSS transition
- // eslint-disable-next-line jquery/no-fade
- $newChanges
- .hide()
- .fadeIn( 1000 );
- };
-
- /**
- * In enhanced mode, we need to check whether the grouped results all have the
- * same active highlights in order to see whether the "parent" of the group should
- * be grey or highlighted normally.
- *
- * This is called every time highlights are applied.
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
- var activeHighlightClasses,
- $enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc.mw-collapsible' );
-
- activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
- return 'mw-rcfilters-highlight-color-' + color;
- } );
-
- // Go over top pages and their children, and figure out if all sub-pages have the
- // same highlights between themselves. If they do, the parent should be highlighted
- // with all colors. If classes are different, the parent should receive a grey
- // background
- $enhancedTopPageCell.each( function () {
- var firstChildClasses, $rowsWithDifferentHighlights,
- $table = $( this );
-
- // Collect the relevant classes from the first nested child
- firstChildClasses = activeHighlightClasses.filter( function ( className ) {
- return $table.find( 'tr:nth-child(2)' ).hasClass( className );
- } );
- // Filter the non-head rows and see if they all have the same classes
- // to the first row
- $rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
- var classesInThisRow,
- $this = $( this );
-
- classesInThisRow = activeHighlightClasses.filter( function ( className ) {
- return $this.hasClass( className );
- } );
-
- return !OO.compare( firstChildClasses, classesInThisRow );
- } );
-
- // If classes are different, tag the row for using grey color
- $table.find( 'tr:first-child' )
- .toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
- } );
- };
-
- /**
- * @return {boolean} Whether the changes are grouped by page
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
- var uri = new mw.Uri();
- return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
- ( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
- };
-
- /**
- * Apply color classes based on filters highlight configuration
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.applyHighlight = function () {
- if ( !this.filtersViewModel.isHighlightEnabled() ) {
- return;
- }
-
- this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
- var $elements = this.$element.find( '.' + filterItem.getCssClass() );
-
- // Add highlight class to all highlighted list items
- $elements
- .addClass(
- 'mw-rcfilters-highlighted ' +
- 'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
- );
-
- // Track the filters for each item in .data( 'highlightedFilters' )
- $elements.each( function () {
- var filters = $( this ).data( 'highlightedFilters' );
- if ( !filters ) {
- filters = [];
- $( this ).data( 'highlightedFilters', filters );
- }
- if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
- filters.push( filterItem.getLabel() );
- }
- } );
- }.bind( this ) );
- // Apply a title to each highlighted item, with a list of filters
- this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
- var filters = $( this ).data( 'highlightedFilters' );
-
- if ( filters && filters.length ) {
- $( this ).attr( 'title', mw.msg(
- 'rcfilters-highlighted-filters-list',
- filters.join( mw.msg( 'comma-separator' ) )
- ) );
- }
-
- } );
- if ( this.inEnhancedMode() ) {
- this.updateEnhancedParentHighlight();
- }
-
- // Turn on highlights
- this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
- };
-
- /**
- * Remove all color classes
- */
- mw.rcfilters.ui.ChangesListWrapperWidget.prototype.clearHighlight = function () {
- // Remove highlight classes
- mw.rcfilters.HighlightColors.forEach( function ( color ) {
- this.$element
- .find( '.mw-rcfilters-highlight-color-' + color )
- .removeClass( 'mw-rcfilters-highlight-color-' + color );
- }.bind( this ) );
-
- this.$element.find( '.mw-rcfilters-highlighted' )
- .removeAttr( 'title' )
- .removeData( 'highlightedFilters' )
- .removeClass( 'mw-rcfilters-highlighted' );
-
- // Remove grey from enhanced rows
- this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
- .removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );
-
- // Turn off highlights
- this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * A widget representing a single toggle filter
- *
- * @extends OO.ui.CheckboxInputWidget
- *
- * @constructor
- * @param {Object} config Configuration object
- */
- mw.rcfilters.ui.CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.CheckboxInputWidget.parent.call( this, config );
-
- // Event
- this.$input
- // HACK: This widget just pretends to be a checkbox for visual purposes.
- // In reality, all actions - setting to true or false, etc - are
- // decided by the model, and executed by the controller. This means
- // that we want to let the controller and model make the decision
- // of whether to check/uncheck this checkboxInputWidget, and for that,
- // we have to bypass the browser action that checks/unchecks it during
- // click.
- .on( 'click', false )
- .on( 'change', this.onUserChange.bind( this ) );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.CheckboxInputWidget, OO.ui.CheckboxInputWidget );
-
- /* Events */
-
- /**
- * @event userChange
- * @param {boolean} Current state of the checkbox
- *
- * The user has checked or unchecked this checkbox
- */
-
- /* Methods */
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.CheckboxInputWidget.prototype.onEdit = function () {
- // Similarly to preventing defaults in 'click' event, we want
- // to prevent this widget from deciding anything about its own
- // state; it emits a change event and the model and controller
- // make a decision about what its select state is.
- // onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
- // so we really want to prevent that from messing with what
- // the model decides the state of the widget is.
- };
-
- /**
- * Respond to checkbox change by a user and emit 'userChange'.
- */
- mw.rcfilters.ui.CheckboxInputWidget.prototype.onUserChange = function () {
- this.emit( 'userChange', this.$input.prop( 'checked' ) );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * 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 );
- // Mixin constructors
- OO.ui.mixin.LabelElement.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.$label
- .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
- this.hoursValuePicker.$element,
- this.daysValuePicker.$element
- );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.DatePopupWidget, OO.ui.Widget );
- OO.mixinClass( mw.rcfilters.ui.DatePopupWidget, OO.ui.mixin.LabelElement );
-
- /* Events */
-
- /**
- * @event days
- * @param {string} name Item name
- *
- * A days item was chosen
- */
-}() );
+++ /dev/null
-( function () {
- /**
- * A button to configure highlight for a filter item
- *
- * @extends OO.ui.PopupButtonWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {mw.rcfilters.dm.FilterItem} model Filter item model
- * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
- icon: 'highlight',
- indicator: 'down'
- } ) );
-
- this.controller = controller;
- this.model = model;
- this.popup = highlightPopup;
-
- // Event
- this.model.connect( this, { update: 'updateUiBasedOnModel' } );
- // This lives inside a MenuOptionWidget, which intercepts mousedown
- // to select the item. We want to prevent that when we click the highlight
- // button
- this.$element.on( 'mousedown', function ( e ) {
- e.stopPropagation();
- } );
-
- this.updateUiBasedOnModel();
-
- this.$element
- .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.FilterItemHighlightButton, OO.ui.PopupButtonWidget );
-
- /* Static Properties */
-
- /**
- * @static
- */
- mw.rcfilters.ui.FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;
-
- /* Methods */
-
- mw.rcfilters.ui.FilterItemHighlightButton.prototype.onAction = function () {
- this.popup.setAssociatedButton( this );
- this.popup.setFilterItem( this.model );
-
- // Parent method
- mw.rcfilters.ui.FilterItemHighlightButton.parent.prototype.onAction.call( this );
- };
-
- /**
- * Respond to item model update event
- */
- mw.rcfilters.ui.FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
- var currentColor = this.model.getHighlightColor(),
- widget = this;
-
- this.$icon.toggleClass(
- 'mw-rcfilters-ui-filterItemHighlightButton-circle',
- currentColor !== null
- );
-
- mw.rcfilters.HighlightColors.forEach( function ( c ) {
- widget.$icon
- .toggleClass(
- 'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
- c === currentColor
- );
- } );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Menu header for the RCFilters filters menu
- *
- * @class
- * @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.FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
- config = config || {};
-
- this.controller = controller;
- this.model = model;
- this.$overlay = config.$overlay || this.$element;
-
- // Parent
- mw.rcfilters.ui.FilterMenuHeaderWidget.parent.call( this, config );
- OO.ui.mixin.LabelElement.call( this, $.extend( {
- label: mw.msg( 'rcfilters-filterlist-title' ),
- $label: $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
- }, config ) );
-
- // "Back" to default view button
- this.backButton = new OO.ui.ButtonWidget( {
- icon: 'previous',
- framed: false,
- title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
- classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
- } );
- this.backButton.toggle( this.model.getCurrentView() !== 'default' );
-
- // Help icon for Tagged edits
- this.helpIcon = new OO.ui.ButtonWidget( {
- icon: 'helpNotice',
- framed: false,
- title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
- classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
- href: mw.util.getUrl( 'Special:Tags' ),
- target: '_blank'
- } );
- this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );
-
- // Highlight button
- this.highlightButton = new OO.ui.ToggleButtonWidget( {
- icon: 'highlight',
- label: mw.message( 'rcfilters-highlightbutton-title' ).text(),
- classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
- } );
-
- // Invert namespaces button
- this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
- icon: '',
- classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
- } );
- this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
-
- // Events
- this.backButton.connect( this, { click: 'onBackButtonClick' } );
- this.highlightButton
- .connect( this, { click: 'onHighlightButtonClick' } );
- this.invertNamespacesButton
- .connect( this, { click: 'onInvertNamespacesButtonClick' } );
- this.model.connect( this, {
- highlightChange: 'onModelHighlightChange',
- searchChange: 'onModelSearchChange',
- initialize: 'onModelInitialize'
- } );
- this.view = this.model.getCurrentView();
-
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
- .append( this.backButton.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
- .append( this.$label, this.helpIcon.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
- .append( this.invertNamespacesButton.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
- .append( this.highlightButton.$element )
- )
- )
- );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.FilterMenuHeaderWidget, OO.ui.Widget );
- OO.mixinClass( mw.rcfilters.ui.FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );
-
- /* Methods */
-
- /**
- * Respond to model initialization event
- *
- * Note: need to wait for initialization before getting the invertModel
- * and registering its update event. Creating all the models before the UI
- * would help with that.
- */
- mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
- this.invertModel = this.model.getInvertModel();
- this.updateInvertButton();
- this.invertModel.connect( this, { update: 'updateInvertButton' } );
- };
-
- /**
- * Respond to model update event
- */
- mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
- var currentView = this.model.getCurrentView();
-
- if ( this.view !== currentView ) {
- this.setLabel( this.model.getViewTitle( currentView ) );
-
- this.invertNamespacesButton.toggle( currentView === 'namespaces' );
- this.backButton.toggle( currentView !== 'default' );
- this.helpIcon.toggle( currentView === 'tags' );
- this.view = currentView;
- }
- };
-
- /**
- * Respond to model highlight change event
- *
- * @param {boolean} highlightEnabled Highlight is enabled
- */
- mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
- this.highlightButton.setActive( highlightEnabled );
- };
-
- /**
- * Update the state of the invert button
- */
- mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.updateInvertButton = function () {
- this.invertNamespacesButton.setActive( this.invertModel.isSelected() );
- this.invertNamespacesButton.setLabel(
- this.invertModel.isSelected() ?
- mw.msg( 'rcfilters-exclude-button-on' ) :
- mw.msg( 'rcfilters-exclude-button-off' )
- );
- };
-
- mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
- this.controller.switchView( 'default' );
- };
-
- /**
- * Respond to highlight button click
- */
- mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
- this.controller.toggleHighlight();
- };
-
- /**
- * Respond to highlight button click
- */
- mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
- this.controller.toggleInvertedNamespaces();
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * A widget representing a single toggle filter
- *
- * @extends mw.rcfilters.ui.ItemMenuOptionWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
- * @param {mw.rcfilters.dm.FilterItem} invertModel
- * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
- * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
- * @param {Object} config Configuration object
- */
- mw.rcfilters.ui.FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
- controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
- ) {
- config = config || {};
-
- this.controller = controller;
- this.invertModel = invertModel;
- this.model = itemModel;
-
- // Parent
- mw.rcfilters.ui.FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
-
- // Event
- this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
- };
-
- /* Initialization */
- OO.inheritClass( mw.rcfilters.ui.FilterMenuOptionWidget, mw.rcfilters.ui.ItemMenuOptionWidget );
-
- /* Static properties */
-
- // We do our own scrolling to top
- mw.rcfilters.ui.FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
-
- /* Methods */
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
- // Parent
- mw.rcfilters.ui.FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
-
- this.setCurrentMuteState();
- };
-
- /**
- * Respond to item group model update event
- */
- mw.rcfilters.ui.FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
- this.setCurrentMuteState();
- };
-
- /**
- * Set the current muted view of the widget based on its state
- */
- mw.rcfilters.ui.FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
- if (
- this.model.getGroupModel().getView() === 'namespaces' &&
- this.invertModel.isSelected()
- ) {
- // This is an inverted behavior than the other rules, specifically
- // for inverted namespaces
- this.setFlags( {
- muted: this.model.isSelected()
- } );
- } else {
- this.setFlags( {
- muted: (
- this.model.isConflicted() ||
- (
- // Item is also muted when any of the items in its group is active
- this.model.getGroupModel().isActive() &&
- // But it isn't selected
- !this.model.isSelected() &&
- // And also not included
- !this.model.isIncluded()
- )
- )
- } );
- }
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * A widget representing a menu section for filter groups
- *
- * @class
- * @extends OO.ui.MenuSectionOptionWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
- * @param {Object} config Configuration object
- * @cfg {jQuery} [$overlay] Overlay
- */
- mw.rcfilters.ui.FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
- var whatsThisMessages,
- $header = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
- $popupContent = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );
-
- config = config || {};
-
- this.controller = controller;
- this.model = model;
- this.$overlay = config.$overlay || this.$element;
-
- // Parent
- mw.rcfilters.ui.FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
- label: this.model.getTitle(),
- $label: $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
- }, config ) );
-
- $header.append( this.$label );
-
- if ( this.model.hasWhatsThis() ) {
- whatsThisMessages = this.model.getWhatsThis();
-
- // Create popup
- if ( whatsThisMessages.header ) {
- $popupContent.append(
- ( new OO.ui.LabelWidget( {
- label: mw.msg( whatsThisMessages.header ),
- classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
- } ) ).$element
- );
- }
- if ( whatsThisMessages.body ) {
- $popupContent.append(
- ( new OO.ui.LabelWidget( {
- label: mw.msg( whatsThisMessages.body ),
- classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
- } ) ).$element
- );
- }
- if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
- $popupContent.append(
- ( new OO.ui.ButtonWidget( {
- framed: false,
- flags: [ 'progressive' ],
- href: whatsThisMessages.url,
- label: mw.msg( whatsThisMessages.linkText ),
- classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
- } ) ).$element
- );
- }
-
- // Add button
- this.whatsThisButton = new OO.ui.PopupButtonWidget( {
- framed: false,
- label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
- $overlay: this.$overlay,
- classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
- flags: [ 'progressive' ],
- popup: {
- padded: false,
- align: 'center',
- position: 'above',
- $content: $popupContent,
- classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
- }
- } );
-
- $header
- .append( this.whatsThisButton.$element );
- }
-
- // Events
- this.model.connect( this, { update: 'updateUiBasedOnState' } );
-
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
- .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
- .append( $header );
- this.updateUiBasedOnState();
- };
-
- /* Initialize */
-
- OO.inheritClass( mw.rcfilters.ui.FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );
-
- /* Methods */
-
- /**
- * Respond to model update event
- */
- mw.rcfilters.ui.FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
- this.$element.toggleClass(
- 'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
- this.model.isActive()
- );
- this.toggle( this.model.isVisible() );
- };
-
- /**
- * Get the group name
- *
- * @return {string} Group name
- */
- mw.rcfilters.ui.FilterMenuSectionOptionWidget.prototype.getName = function () {
- return this.model.getName();
- };
-
-}() );
+++ /dev/null
-( function () {
- /**
- * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
- *
- * @class
- * @extends mw.rcfilters.ui.TagItemWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
- * @param {mw.rcfilters.dm.FilterItem} invertModel
- * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
- * @param {Object} config Configuration object
- */
- mw.rcfilters.ui.FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
- controller, filtersViewModel, invertModel, itemModel, config
- ) {
- config = config || {};
-
- mw.rcfilters.ui.FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.FilterTagItemWidget, mw.rcfilters.ui.TagItemWidget );
-
- /* Methods */
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagItemWidget.prototype.setCurrentMuteState = function () {
- this.setFlags( {
- muted: (
- !this.itemModel.isSelected() ||
- this.itemModel.isIncluded() ||
- this.itemModel.isFullyCovered()
- ),
- invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
- } );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * List displaying all filter groups
- *
- * @class
- * @extends OO.ui.MenuTagMultiselectWidget
- * @mixins OO.ui.mixin.PendingElement
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
- * @param {Object} config Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
- * system. If not given, falls back to this widget's $element
- * @cfg {boolean} [collapsed] Filter area is collapsed
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
- var rcFiltersRow,
- title = new OO.ui.LabelWidget( {
- label: mw.msg( 'rcfilters-activefilters' ),
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
- } ),
- $contentWrapper = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
-
- config = config || {};
-
- this.controller = controller;
- this.model = model;
- this.queriesModel = savedQueriesModel;
- this.$overlay = config.$overlay || this.$element;
- this.$wrapper = config.$wrapper || this.$element;
- this.matchingQuery = null;
- this.currentView = this.model.getCurrentView();
- this.collapsed = false;
-
- // Parent
- mw.rcfilters.ui.FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
- label: mw.msg( 'rcfilters-filterlist-title' ),
- placeholder: mw.msg( 'rcfilters-empty-filter' ),
- inputPosition: 'outline',
- allowArbitrary: false,
- allowDisplayInvalidTags: false,
- allowReordering: false,
- $overlay: this.$overlay,
- menu: {
- // Our filtering is done through the model
- filterFromInput: false,
- hideWhenOutOfView: false,
- hideOnChoose: false,
- width: 650,
- footers: [
- {
- name: 'viewSelect',
- sticky: false,
- // View select menu, appears on default view only
- $element: $( '<div>' )
- .append( new mw.rcfilters.ui.ViewSwitchWidget( this.controller, this.model ).$element ),
- views: [ 'default' ]
- },
- {
- name: 'feedback',
- // Feedback footer, appears on all views
- $element: $( '<div>' )
- .append(
- new OO.ui.ButtonWidget( {
- framed: false,
- icon: 'feedback',
- flags: [ 'progressive' ],
- label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
- href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
- } ).$element
- )
- }
- ]
- },
- input: {
- icon: 'menu',
- placeholder: mw.msg( 'rcfilters-search-placeholder' )
- }
- }, config ) );
-
- this.savedQueryTitle = new OO.ui.LabelWidget( {
- label: '',
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
- } );
-
- this.resetButton = new OO.ui.ButtonWidget( {
- framed: false,
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
- } );
-
- this.hideShowButton = new OO.ui.ButtonWidget( {
- framed: false,
- flags: [ 'progressive' ],
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
- } );
- this.toggleCollapsed( !!config.collapsed );
-
- if ( !mw.user.isAnon() ) {
- this.saveQueryButton = new mw.rcfilters.ui.SaveFiltersPopupButtonWidget(
- this.controller,
- this.queriesModel,
- {
- $overlay: this.$overlay
- }
- );
-
- this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
- e.stopPropagation();
- } );
-
- this.saveQueryButton.connect( this, {
- click: 'onSaveQueryButtonClick',
- saveCurrent: 'setSavedQueryVisibility'
- } );
- this.queriesModel.connect( this, {
- itemUpdate: 'onSavedQueriesItemUpdate',
- initialize: 'onSavedQueriesInitialize',
- default: 'reevaluateResetRestoreState'
- } );
- }
-
- this.emptyFilterMessage = new OO.ui.LabelWidget( {
- label: mw.msg( 'rcfilters-empty-filter' ),
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
- } );
- this.$content.append( this.emptyFilterMessage.$element );
-
- // Events
- this.resetButton.connect( this, { click: 'onResetButtonClick' } );
- this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
- // Stop propagation for mousedown, so that the widget doesn't
- // trigger the focus on the input and scrolls up when we click the reset button
- this.resetButton.$element.on( 'mousedown', function ( e ) {
- e.stopPropagation();
- } );
- this.hideShowButton.$element.on( 'mousedown', function ( e ) {
- e.stopPropagation();
- } );
- this.model.connect( this, {
- initialize: 'onModelInitialize',
- update: 'onModelUpdate',
- searchChange: 'onModelSearchChange',
- itemUpdate: 'onModelItemUpdate',
- highlightChange: 'onModelHighlightChange'
- } );
- this.input.connect( this, { change: 'onInputChange' } );
-
- // The filter list and button should appear side by side regardless of how
- // wide the button is; the button also changes its width depending
- // on language and its state, so the safest way to present both side
- // by side is with a table layout
- rcFiltersRow = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- this.$content
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
- );
-
- if ( !mw.user.isAnon() ) {
- rcFiltersRow.append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
- .append( this.saveQueryButton.$element )
- );
- }
-
- // Add a selector at the right of the input
- this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
- items: [
- new OO.ui.ButtonOptionWidget( {
- framed: false,
- data: 'namespaces',
- icon: 'article',
- label: mw.msg( 'namespaces' ),
- title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
- } ),
- new OO.ui.ButtonOptionWidget( {
- framed: false,
- data: 'tags',
- icon: 'tag',
- label: mw.msg( 'tags-title' ),
- title: mw.msg( 'rcfilters-view-tags-tooltip' )
- } )
- ]
- } );
-
- // Rearrange the UI so the select widget is at the right of the input
- this.$element.append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
- .append( this.input.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
- .append( this.viewsSelectWidget.$element )
- )
- )
- );
-
- // Event
- this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
-
- rcFiltersRow.append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
- .append( this.resetButton.$element )
- );
-
- // Build the content
- $contentWrapper.append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
- .append( title.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
- .append( this.savedQueryTitle.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
- .append(
- this.hideShowButton.$element
- )
- ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
- .append( rcFiltersRow )
- );
-
- // Initialize
- this.$handle.append( $contentWrapper );
- this.emptyFilterMessage.toggle( this.isEmpty() );
- this.savedQueryTitle.toggle( false );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
-
- this.reevaluateResetRestoreState();
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
-
- /* Methods */
-
- /**
- * Override parent method to avoid unnecessary resize events.
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.updateIfHeightChanged = function () { };
-
- /**
- * Respond to view select widget choose event
- *
- * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget ) {
- this.controller.switchView( buttonOptionWidget.getData() );
- this.viewsSelectWidget.selectItem( null );
- this.focus();
- };
-
- /**
- * Respond to model search change event
- *
- * @param {string} value Search value
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
- this.input.setValue( value );
- };
-
- /**
- * Respond to input change event
- *
- * @param {string} value Value of the input
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
- this.controller.setSearch( value );
- };
-
- /**
- * Respond to query button click
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
- this.getMenu().toggle( false );
- };
-
- /**
- * Respond to save query model initialization
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
- this.setSavedQueryVisibility();
- };
-
- /**
- * Respond to save query item change. Mainly this is done to update the label in case
- * a query item has been edited
- *
- * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
- if ( this.matchingQuery === item ) {
- // This means we just edited the item that is currently matched
- this.savedQueryTitle.setLabel( item.getLabel() );
- }
- };
-
- /**
- * Respond to menu toggle
- *
- * @param {boolean} isVisible Menu is visible
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
- // Parent
- mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
-
- if ( isVisible ) {
- this.focus();
-
- mw.hook( 'RcFilters.popup.open' ).fire();
-
- if ( !this.getMenu().findSelectedItem() ) {
- // If there are no selected items, scroll menu to top
- // This has to be in a setTimeout so the menu has time
- // to be positioned and fixed
- setTimeout(
- function () {
- this.getMenu().scrollToTop();
- }.bind( this )
- );
- }
- } else {
- // Clear selection
- this.selectTag( null );
-
- // Clear the search
- this.controller.setSearch( '' );
-
- // Log filter grouping
- this.controller.trackFilterGroupings( 'filtermenu' );
-
- this.blur();
- }
-
- this.input.setIcon( isVisible ? 'search' : 'menu' );
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onInputFocus = function () {
- // Parent
- mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
-
- // Only scroll to top of the viewport if:
- // - The widget is more than 20px from the top
- // - The widget is not above the top of the viewport (do not scroll downwards)
- // (This isn't represented because >20 is, anyways and always, bigger than 0)
- this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.doInputEscape = function () {
- // Parent
- mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
-
- // Blur the input
- this.input.$input.trigger( 'blur' );
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
- if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
- this.menu.toggle();
-
- return false;
- }
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onChangeTags = function () {
- // If initialized, call parent method.
- if ( this.controller.isInitialized() ) {
- mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
- }
-
- this.emptyFilterMessage.toggle( this.isEmpty() );
- };
-
- /**
- * Respond to model initialize event
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
- this.setSavedQueryVisibility();
- };
-
- /**
- * Respond to model update event
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
- this.updateElementsForView();
- };
-
- /**
- * Update the elements in the widget to the current view
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
- var view = this.model.getCurrentView(),
- inputValue = this.input.getValue().trim(),
- inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
-
- if ( inputView !== 'default' ) {
- // We have a prefix already, remove it
- inputValue = inputValue.substr( 1 );
- }
-
- if ( inputView !== view ) {
- // Add the correct prefix
- inputValue = this.model.getViewTrigger( view ) + inputValue;
- }
-
- // Update input
- this.input.setValue( inputValue );
-
- if ( this.currentView !== view ) {
- this.scrollToTop( this.$element );
- this.currentView = view;
- }
- };
-
- /**
- * Set the visibility of the saved query button
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
- if ( mw.user.isAnon() ) {
- return;
- }
-
- this.matchingQuery = this.controller.findQueryMatchingCurrentState();
-
- this.savedQueryTitle.setLabel(
- this.matchingQuery ? this.matchingQuery.getLabel() : ''
- );
- this.savedQueryTitle.toggle( !!this.matchingQuery );
- this.saveQueryButton.setDisabled( !!this.matchingQuery );
- this.saveQueryButton.setTitle( !this.matchingQuery ?
- mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
- mw.msg( 'rcfilters-savedqueries-already-saved' ) );
-
- if ( this.matchingQuery ) {
- this.emphasize();
- }
- };
-
- /**
- * Respond to model itemUpdate event
- * fixme: when a new state is applied to the model this function is called 60+ times in a row
- *
- * @param {mw.rcfilters.dm.FilterItem} item Filter item model
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
- if ( !item.getGroupModel().isHidden() ) {
- if (
- item.isSelected() ||
- (
- this.model.isHighlightEnabled() &&
- item.getHighlightColor()
- )
- ) {
- this.addTag( item.getName(), item.getLabel() );
- } else {
- // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
- if ( this.findItemFromData( item.getName() ) !== null ) {
- this.removeTagByData( item.getName() );
- }
- }
- }
-
- this.setSavedQueryVisibility();
-
- // Re-evaluate reset state
- this.reevaluateResetRestoreState();
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
- return (
- this.model.getItemByName( data ) &&
- !this.isDuplicateData( data )
- );
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
- this.controller.toggleFilterSelect( item.model.getName() );
-
- // Select the tag if it exists, or reset selection otherwise
- this.selectTag( this.findItemFromData( item.model.getName() ) );
-
- this.focus();
- };
-
- /**
- * Respond to highlightChange event
- *
- * @param {boolean} isHighlightEnabled Highlight is enabled
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
- var highlightedItems = this.model.getHighlightedItems();
-
- if ( isHighlightEnabled ) {
- // Add capsule widgets
- highlightedItems.forEach( function ( filterItem ) {
- this.addTag( filterItem.getName(), filterItem.getLabel() );
- }.bind( this ) );
- } else {
- // Remove capsule widgets if they're not selected
- highlightedItems.forEach( function ( filterItem ) {
- if ( !filterItem.isSelected() ) {
- // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
- if ( this.findItemFromData( filterItem.getName() ) !== null ) {
- this.removeTagByData( filterItem.getName() );
- }
- }
- }.bind( this ) );
- }
-
- this.setSavedQueryVisibility();
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
- var menuOption = this.menu.getItemFromModel( tagItem.getModel() );
-
- this.menu.setUserSelecting( true );
- // Parent method
- mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
-
- // Switch view
- this.controller.resetSearchForView( tagItem.getView() );
-
- this.selectTag( tagItem );
- this.scrollToTop( menuOption.$element );
-
- this.menu.setUserSelecting( false );
- };
-
- /**
- * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
- * If no items are given, reset selection from all.
- *
- * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
- * omit to deselect all
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
- var i, len, selected;
-
- for ( i = 0, len = this.items.length; i < len; i++ ) {
- selected = this.items[ i ] === item;
- if ( this.items[ i ].isSelected() !== selected ) {
- this.items[ i ].toggleSelected( selected );
- }
- }
- };
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
- // Parent method
- mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
-
- this.controller.clearFilter( tagItem.getName() );
-
- tagItem.destroy();
- };
-
- /**
- * Respond to click event on the reset button
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
- if ( this.model.areVisibleFiltersEmpty() ) {
- // Reset to default filters
- this.controller.resetToDefaults();
- } else {
- // Reset to have no filters
- this.controller.emptyFilters();
- }
- };
-
- /**
- * Respond to hide/show button click
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
- this.toggleCollapsed();
- };
-
- /**
- * Toggle the collapsed state of the filters widget
- *
- * @param {boolean} isCollapsed Widget is collapsed
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
- isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;
-
- this.collapsed = isCollapsed;
-
- if ( isCollapsed ) {
- // If we are collapsing, close the menu, in case it was open
- // We should make sure the menu closes before the rest of the elements
- // are hidden, otherwise there is an unknown error in jQuery as ooui
- // sets and unsets properties on the input (which is hidden at that point)
- this.menu.toggle( false );
- }
- this.input.setDisabled( isCollapsed );
- this.hideShowButton.setLabel( mw.msg(
- isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
- ) );
- this.hideShowButton.setTitle( mw.msg(
- isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
- ) );
-
- // Toggle the wrapper class, so we have min height values correctly throughout
- this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );
-
- // Save the state
- this.controller.updateCollapsedState( isCollapsed );
- };
-
- /**
- * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
- var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
- currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
- hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
-
- this.resetButton.setIcon(
- currFiltersAreEmpty ? 'history' : 'trash'
- );
-
- this.resetButton.setLabel(
- currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
- );
- this.resetButton.setTitle(
- currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
- );
-
- this.resetButton.toggle( !hideResetButton );
- this.emptyFilterMessage.toggle( currFiltersAreEmpty );
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
- return new mw.rcfilters.ui.MenuSelectWidget(
- this.controller,
- this.model,
- menuConfig
- );
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
- var filterItem = this.model.getItemByName( data );
-
- if ( filterItem ) {
- return new mw.rcfilters.ui.FilterTagItemWidget(
- this.controller,
- this.model,
- this.model.getInvertModel(),
- filterItem,
- {
- $overlay: this.$overlay
- }
- );
- }
- };
-
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.emphasize = function () {
- if (
- !this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
- ) {
- this.$handle
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
-
- setTimeout( function () {
- this.$handle
- .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
-
- setTimeout( function () {
- this.$handle
- .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
- }.bind( this ), 1000 );
- }.bind( this ), 500 );
-
- }
- };
- /**
- * Scroll the element to top within its container
- *
- * @private
- * @param {jQuery} $element Element to position
- * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
- * much space (in pixels) above the widget.
- * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
- * @param {number} [threshold.min] Minimum distance above the element
- * @param {number} [threshold.max] Minimum distance below the element
- */
- mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
- var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
- pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
- containerScrollTop = $( container ).scrollTop(),
- effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
- newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );
-
- // Scroll to item
- if (
- threshold === undefined ||
- (
- (
- threshold.min === undefined ||
- newScrollTop - containerScrollTop >= threshold.min
- ) &&
- (
- threshold.max === undefined ||
- newScrollTop - containerScrollTop <= threshold.max
- )
- )
- ) {
- // eslint-disable-next-line jquery/no-animate
- $( container ).animate( {
- scrollTop: newScrollTop
- } );
- }
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * List displaying all filter groups
- *
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.PendingElement
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
- * @param {Object} [config] Configuration object
- * @cfg {Object} [filters] A definition of the filter groups in this list
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
- * system. If not given, falls back to this widget's $element
- * @cfg {boolean} [collapsed] Filter area is collapsed
- */
- mw.rcfilters.ui.FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
- controller, model, savedQueriesModel, changesListModel, config
- ) {
- var $bottom;
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.FilterWrapperWidget.parent.call( this, config );
- // Mixin constructors
- OO.ui.mixin.PendingElement.call( this, config );
-
- this.controller = controller;
- this.model = model;
- this.queriesModel = savedQueriesModel;
- this.changesListModel = changesListModel;
- this.$overlay = config.$overlay || this.$element;
- this.$wrapper = config.$wrapper || this.$element;
-
- this.filterTagWidget = new mw.rcfilters.ui.FilterTagMultiselectWidget(
- this.controller,
- this.model,
- this.queriesModel,
- {
- $overlay: this.$overlay,
- collapsed: config.collapsed,
- $wrapper: this.$wrapper
- }
- );
-
- this.liveUpdateButton = new mw.rcfilters.ui.LiveUpdateButtonWidget(
- this.controller,
- this.changesListModel
- );
-
- this.numChangesAndDateWidget = new mw.rcfilters.ui.ChangesLimitAndDateButtonWidget(
- this.controller,
- this.model,
- {
- $overlay: this.$overlay
- }
- );
-
- this.showNewChangesLink = new OO.ui.ButtonWidget( {
- icon: 'reload',
- framed: false,
- label: mw.msg( 'rcfilters-show-new-changes' ),
- flags: [ 'progressive' ],
- classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
- } );
-
- // Events
- this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
- this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
- this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
- this.showNewChangesLink.toggle( false );
-
- // Initialize
- this.$top = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
-
- $bottom = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
- .append(
- this.showNewChangesLink.$element,
- this.numChangesAndDateWidget.$element
- );
-
- if ( mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' ) ) {
- $bottom.prepend( this.liveUpdateButton.$element );
- }
-
- this.$element
- .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
- .append(
- this.$top,
- this.filterTagWidget.$element,
- $bottom
- );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.Widget );
- OO.mixinClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.mixin.PendingElement );
-
- /* Methods */
-
- /**
- * Set the content of the top section
- *
- * @param {jQuery} $topSectionElement
- */
- mw.rcfilters.ui.FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
- this.$top.append( $topSectionElement );
- };
-
- /**
- * Respond to the user clicking the 'show new changes' button
- */
- mw.rcfilters.ui.FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
- this.controller.showNewChanges();
- };
-
- /**
- * Respond to changes list model newChangesExist
- *
- * @param {boolean} newChangesExist Whether new changes exist
- */
- mw.rcfilters.ui.FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
- this.showNewChangesLink.toggle( newChangesExist );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Wrapper for the RC form with hide/show links
- * Must be constructed after the model is initialized.
- *
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
- * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
- * @param {mw.rcfilters.Controller} controller RCfilters controller
- * @param {jQuery} $formRoot Root element of the form to attach to
- * @param {Object} config Configuration object
- */
- mw.rcfilters.ui.FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.FormWrapperWidget.parent.call( this, $.extend( {}, config, {
- $element: $formRoot
- } ) );
-
- this.changeListModel = changeListModel;
- this.filtersModel = filtersModel;
- this.controller = controller;
- this.$submitButton = this.$element.find( 'form input[type=submit]' );
-
- this.$element
- .on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );
-
- this.$element
- .on( 'submit', 'form', this.onFormSubmit.bind( this ) );
-
- // Events
- this.changeListModel.connect( this, {
- invalidate: 'onChangesModelInvalidate',
- update: 'onChangesModelUpdate'
- } );
-
- // Initialize
- this.cleanUpFieldset();
- this.$element
- .addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.FormWrapperWidget, OO.ui.Widget );
-
- /**
- * Respond to link click
- *
- * @param {jQuery.Event} e Event
- * @return {boolean} false
- */
- mw.rcfilters.ui.FormWrapperWidget.prototype.onLinkClick = function ( e ) {
- this.controller.updateChangesList( $( e.target ).data( 'params' ) );
- return false;
- };
-
- /**
- * Respond to form submit event
- *
- * @param {jQuery.Event} e Event
- * @return {boolean} false
- */
- mw.rcfilters.ui.FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
- var data = {};
-
- // Collect all data from form
- $( e.target ).find( 'input:not([type="hidden"],[type="submit"]), select' ).each( function () {
- var value = '';
-
- if ( !$( this ).is( ':checkbox' ) || $( this ).is( ':checked' ) ) {
- value = $( this ).val();
- }
-
- data[ $( this ).prop( 'name' ) ] = value;
- } );
-
- this.controller.updateChangesList( data );
- return false;
- };
-
- /**
- * Respond to model invalidate
- */
- mw.rcfilters.ui.FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
- this.$submitButton.prop( 'disabled', true );
- };
-
- /**
- * Respond to model update, replace the show/hide links with the ones from the
- * server so they feature the correct state.
- *
- * @param {jQuery|string} $changesList Updated changes list
- * @param {jQuery} $fieldset Updated fieldset
- * @param {string} noResultsDetails Type of no result error
- * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
- */
- mw.rcfilters.ui.FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
- this.$submitButton.prop( 'disabled', false );
-
- // Replace the entire fieldset
- this.$element.empty().append( $fieldset.contents() );
-
- if ( !isInitialDOM ) {
- // Make sure enhanced RC re-initializes correctly
- mw.hook( 'wikipage.content' ).fire( this.$element );
- }
-
- this.cleanUpFieldset();
- };
-
- /**
- * Clean up the old-style show/hide that we have implemented in the filter list
- */
- mw.rcfilters.ui.FormWrapperWidget.prototype.cleanUpFieldset = function () {
- this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
- // HACK: Remove the text node after the span.
- // If there isn't one, we're at the end, so remove the text node before the span.
- // This would be unnecessary if we added separators with CSS.
- if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
- this.parentNode.removeChild( this.nextSibling );
- } else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
- this.parentNode.removeChild( this.previousSibling );
- }
- // Remove the span itself
- this.parentNode.removeChild( this );
- } );
-
- // Hide namespaces and tags
- this.$element.find( '.namespaceForm' ).detach();
- this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
-
- // Hide Related Changes page name form
- this.$element.find( '.targetForm' ).detach();
-
- // misc: limit, days, watchlist info msg
- this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();
-
- if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
- this.$element.find( '.mw-recentchanges-table' ).detach();
- this.$element.find( 'hr' ).detach();
- }
-
- // Get rid of all <br>s, which are inside rcshowhide
- // If we still have content in rcshowhide, the <br>s are
- // gone. Instead, the CSS now has a rule to mark all <span>s
- // inside .rcshowhide with display:block; to simulate newlines
- // where they're actually needed.
- this.$element.find( 'br' ).detach();
- if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
- this.$element.find( '.rcshowhide' ).detach();
- }
-
- if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
- this.$element.find( '.cloption-submit' ).detach();
- }
-
- this.$element.find(
- '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
- ).detach();
-
- // Get rid of the legend
- this.$element.find( 'legend' ).detach();
-
- // Check if the element is essentially empty, and detach it if it is
- if ( !this.$element.text().trim().length ) {
- this.$element.detach();
- }
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * A group widget to allow for aggregation of events
- *
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {Object} [config] Configuration object
- * @param {Object} [events] Events to aggregate. The object represent the
- * event name to aggregate and the event value to emit on aggregate for items.
- */
- mw.rcfilters.ui.GroupWidget = function MwRcfiltersUiViewSwitchWidget( config ) {
- var aggregate = {};
-
- config = config || {};
-
- // Parent constructor
- mw.rcfilters.ui.GroupWidget.parent.call( this, config );
-
- // Mixin constructors
- OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
-
- if ( config.events ) {
- // Aggregate events
- // eslint-disable-next-line jquery/no-each-util
- $.each( config.events, function ( eventName, eventEmit ) {
- aggregate[ eventName ] = eventEmit;
- } );
-
- this.aggregate( aggregate );
- }
-
- if ( Array.isArray( config.items ) ) {
- this.addItems( config.items );
- }
- };
-
- /* Initialize */
-
- OO.inheritClass( mw.rcfilters.ui.GroupWidget, OO.ui.Widget );
- OO.mixinClass( mw.rcfilters.ui.GroupWidget, OO.ui.mixin.GroupWidget );
-}() );
+++ /dev/null
-( function () {
- /**
- * A widget representing a filter item highlight color picker
- *
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.LabelElement
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
- var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.HighlightColorPickerWidget.parent.call( this, config );
- // Mixin constructors
- OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
- label: mw.message( 'rcfilters-highlightmenu-title' ).text()
- } ) );
-
- this.controller = controller;
-
- this.currentSelection = 'none';
- this.buttonSelect = new OO.ui.ButtonSelectWidget( {
- items: colors.map( function ( color ) {
- return new OO.ui.ButtonOptionWidget( {
- icon: color === 'none' ? 'check' : null,
- data: color,
- classes: [
- 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
- 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
- ],
- framed: false
- } );
- } ),
- classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
- } );
-
- // Event
- this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
- .append(
- this.$label
- .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
- this.buttonSelect.$element
- );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.HighlightColorPickerWidget, OO.ui.Widget );
- OO.mixinClass( mw.rcfilters.ui.HighlightColorPickerWidget, OO.ui.mixin.LabelElement );
-
- /* Events */
-
- /**
- * @event chooseColor
- * @param {string} The chosen color
- *
- * A color has been chosen
- */
-
- /* Methods */
-
- /**
- * Bind the color picker to an item
- * @param {mw.rcfilters.dm.FilterItem} filterItem
- */
- mw.rcfilters.ui.HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
- if ( this.filterItem ) {
- this.filterItem.disconnect( this );
- }
-
- this.filterItem = filterItem;
- this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
- this.updateUiBasedOnModel();
- };
-
- /**
- * Respond to item model update event
- */
- mw.rcfilters.ui.HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
- this.selectColor( this.filterItem.getHighlightColor() || 'none' );
- };
-
- /**
- * Select the color for this widget
- *
- * @param {string} color Selected color
- */
- mw.rcfilters.ui.HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
- var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
- selectedItem = this.buttonSelect.findItemFromData( color );
-
- if ( this.currentSelection !== color ) {
- this.currentSelection = color;
-
- this.buttonSelect.selectItem( selectedItem );
- if ( previousItem ) {
- previousItem.setIcon( null );
- }
-
- if ( selectedItem ) {
- selectedItem.setIcon( 'check' );
- }
- }
- };
-
- mw.rcfilters.ui.HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
- var color = button.data;
- if ( color === 'none' ) {
- this.controller.clearHighlightColor( this.filterItem.getName() );
- } else {
- this.controller.setHighlightColor( this.filterItem.getName(), color );
- }
- this.emit( 'chooseColor', color );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * A popup containing a color picker, for setting highlight colors.
- *
- * @extends OO.ui.PopupWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.HighlightPopupWidget.parent.call( this, $.extend( {
- autoClose: true,
- anchor: false,
- padded: true,
- align: 'backwards',
- horizontalPosition: 'end',
- width: 290
- }, config ) );
-
- this.colorPicker = new mw.rcfilters.ui.HighlightColorPickerWidget( controller );
-
- this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
-
- this.$body.append( this.colorPicker.$element );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.HighlightPopupWidget, OO.ui.PopupWidget );
-
- /* Methods */
-
- /**
- * Set the button (or other widget) that this popup should hang off.
- *
- * @param {OO.ui.Widget} widget Widget the popup should orient itself to
- */
- mw.rcfilters.ui.HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
- this.setFloatableContainer( widget.$element );
- this.$autoCloseIgnore = widget.$element;
- };
-
- /**
- * Set the filter item that this popup should control the highlight color for.
- *
- * @param {mw.rcfilters.dm.FilterItem} item
- */
- mw.rcfilters.ui.HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
- this.colorPicker.setFilterItem( item );
- };
-
- /**
- * When the user chooses a color in the color picker, close the popup.
- */
- mw.rcfilters.ui.HighlightPopupWidget.prototype.onChooseColor = function () {
- this.toggle( false );
- };
-
-}() );
+++ /dev/null
-( function () {
- /**
- * A widget representing a base toggle item
- *
- * @extends OO.ui.MenuOptionWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
- * @param {mw.rcfilters.dm.ItemModel} invertModel
- * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
- * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
- * @param {Object} config Configuration object
- */
- mw.rcfilters.ui.ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
- controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
- ) {
- var layout,
- classes = [],
- $label = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
-
- config = config || {};
-
- this.controller = controller;
- this.filtersViewModel = filtersViewModel;
- this.invertModel = invertModel;
- this.itemModel = itemModel;
-
- // Parent
- mw.rcfilters.ui.ItemMenuOptionWidget.parent.call( this, $.extend( {
- // Override the 'check' icon that OOUI defines
- icon: '',
- data: this.itemModel.getName(),
- label: this.itemModel.getLabel()
- }, config ) );
-
- this.checkboxWidget = new mw.rcfilters.ui.CheckboxInputWidget( {
- value: this.itemModel.getName(),
- selected: this.itemModel.isSelected()
- } );
-
- $label.append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
- .append( $( '<bdi>' ).append( this.$label ) )
- );
- if ( this.itemModel.getDescription() ) {
- $label.append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
- .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
- );
- }
-
- this.highlightButton = new mw.rcfilters.ui.FilterItemHighlightButton(
- this.controller,
- this.itemModel,
- highlightPopup,
- {
- $overlay: config.$overlay || this.$element,
- title: mw.msg( 'rcfilters-highlightmenu-help' )
- }
- );
- this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
-
- this.excludeLabel = new OO.ui.LabelWidget( {
- label: mw.msg( 'rcfilters-filter-excluded' )
- } );
- this.excludeLabel.toggle(
- this.itemModel.getGroupModel().getView() === 'namespaces' &&
- this.itemModel.isSelected() &&
- this.invertModel.isSelected()
- );
-
- layout = new OO.ui.FieldLayout( this.checkboxWidget, {
- label: $label,
- align: 'inline'
- } );
-
- // Events
- this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
- this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
- this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
- // HACK: Prevent defaults on 'click' for the label so it
- // doesn't steal the focus away from the input. This means
- // we can continue arrow-movement after we click the label
- // and is consistent with the checkbox *itself* also preventing
- // defaults on 'click' as well.
- layout.$label.on( 'click', false );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
- .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView() )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
- .append( layout.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
- .append( this.excludeLabel.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
- .append( this.highlightButton.$element )
- )
- )
- );
-
- if ( this.itemModel.getIdentifiers() ) {
- this.itemModel.getIdentifiers().forEach( function ( ident ) {
- classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
- } );
-
- this.$element.addClass( classes );
- }
-
- this.updateUiBasedOnState();
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
-
- /* Static properties */
-
- // We do our own scrolling to top
- mw.rcfilters.ui.ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
-
- /* Methods */
-
- /**
- * Respond to item model update event
- */
- mw.rcfilters.ui.ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
- this.checkboxWidget.setSelected( this.itemModel.isSelected() );
-
- this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
- this.excludeLabel.toggle(
- this.itemModel.getGroupModel().getView() === 'namespaces' &&
- this.itemModel.isSelected() &&
- this.invertModel.isSelected()
- );
- this.toggle( this.itemModel.isVisible() );
- };
-
- /**
- * Get the name of this filter
- *
- * @return {string} Filter name
- */
- mw.rcfilters.ui.ItemMenuOptionWidget.prototype.getName = function () {
- return this.itemModel.getName();
- };
-
- mw.rcfilters.ui.ItemMenuOptionWidget.prototype.getModel = function () {
- return this.itemModel;
- };
-
-}() );
+++ /dev/null
-( function () {
- /**
- * Widget for toggling live updates
- *
- * @extends OO.ui.ToggleButtonWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.LiveUpdateButtonWidget.parent.call( this, $.extend( {
- label: mw.message( 'rcfilters-liveupdates-button' ).text()
- }, config ) );
-
- this.controller = controller;
- this.model = changesListModel;
-
- // Events
- this.connect( this, { click: 'onClick' } );
- this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
-
- this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
-
- this.setState( false );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
-
- /* Methods */
-
- /**
- * Respond to the button being clicked
- */
- mw.rcfilters.ui.LiveUpdateButtonWidget.prototype.onClick = function () {
- this.controller.toggleLiveUpdate();
- };
-
- /**
- * Set the button's state and change its appearance
- *
- * @param {boolean} enable Whether the 'live update' feature is now on/off
- */
- mw.rcfilters.ui.LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
- this.setValue( enable );
- this.setIcon( enable ? 'stop' : 'play' );
- this.setTitle( mw.message(
- enable ?
- 'rcfilters-liveupdates-button-title-on' :
- 'rcfilters-liveupdates-button-title-off'
- ).text() );
- };
-
- /**
- * Respond to the 'live update' feature being turned on/off
- *
- * @param {boolean} enable Whether the 'live update' feature is now on/off
- */
- mw.rcfilters.ui.LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
- this.setState( enable );
- };
-
-}() );
+++ /dev/null
-( function () {
- /**
- * Wrapper for changes list content
- *
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
- * @param {Object} config Configuration object
- * @cfg {jQuery} $topSection Top section container
- * @cfg {jQuery} $filtersContainer
- * @cfg {jQuery} $changesListContainer
- * @cfg {jQuery} $formContainer
- * @cfg {boolean} [collapsed] Filter area is collapsed
- * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
- * system. If not given, falls back to this widget's $element
- */
- mw.rcfilters.ui.MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
- controller, model, savedQueriesModel, changesListModel, config
- ) {
- config = $.extend( {}, config );
-
- // Parent
- mw.rcfilters.ui.MainWrapperWidget.parent.call( this, config );
-
- this.controller = controller;
- this.model = model;
- this.changesListModel = changesListModel;
- this.$topSection = config.$topSection;
- this.$filtersContainer = config.$filtersContainer;
- this.$changesListContainer = config.$changesListContainer;
- this.$formContainer = config.$formContainer;
- this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay' );
- this.$wrapper = config.$wrapper || this.$element;
-
- this.savedLinksListWidget = new mw.rcfilters.ui.SavedLinksListWidget(
- controller, savedQueriesModel, { $overlay: this.$overlay }
- );
-
- this.filtersWidget = new mw.rcfilters.ui.FilterWrapperWidget(
- controller,
- model,
- savedQueriesModel,
- changesListModel,
- {
- $overlay: this.$overlay,
- $wrapper: this.$wrapper,
- collapsed: config.collapsed
- }
- );
-
- this.changesListWidget = new mw.rcfilters.ui.ChangesListWrapperWidget(
- model, changesListModel, controller, this.$changesListContainer );
-
- /* Events */
-
- // Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
- // to prevent users from accidentally clicking on links in results, while menu is opened.
- // Overlay on changes list is not the same as this.$overlay
- this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );
-
- // Initialize
- this.$filtersContainer.append( this.filtersWidget.$element );
- $( 'body' )
- .append( this.$overlay )
- .addClass( 'mw-rcfilters-ui-initialized' );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.MainWrapperWidget, OO.ui.Widget );
-
- /* Methods */
-
- /**
- * Set the content of the top section, depending on the type of special page.
- *
- * @param {string} specialPage
- */
- mw.rcfilters.ui.MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
- var topSection;
-
- if ( specialPage === 'Recentchanges' ) {
- topSection = new mw.rcfilters.ui.RcTopSectionWidget(
- this.savedLinksListWidget, this.$topSection
- );
- this.filtersWidget.setTopSection( topSection.$element );
- }
-
- if ( specialPage === 'Recentchangeslinked' ) {
- topSection = new mw.rcfilters.ui.RclTopSectionWidget(
- this.savedLinksListWidget, this.controller,
- this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
- this.model.getGroup( 'page' ).getItemByParamName( 'target' )
- );
-
- this.filtersWidget.setTopSection( topSection.$element );
- }
-
- if ( specialPage === 'Watchlist' ) {
- topSection = new mw.rcfilters.ui.WatchlistTopSectionWidget(
- this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
- );
-
- this.filtersWidget.setTopSection( topSection.$element );
- }
- };
-
- /**
- * Filter menu toggle event listener
- *
- * @param {boolean} isVisible
- */
- mw.rcfilters.ui.MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
- this.changesListWidget.toggleOverlay( isVisible );
- };
-
- /**
- * Initialize FormWrapperWidget
- *
- * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
- */
- mw.rcfilters.ui.MainWrapperWidget.prototype.initFormWidget = function () {
- return new mw.rcfilters.ui.FormWrapperWidget(
- this.model, this.changesListModel, this.controller, this.$formContainer );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Button for marking all changes as seen on the Watchlist
- *
- * @extends OO.ui.ButtonWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.MarkSeenButtonWidget.parent.call( this, $.extend( {
- label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
- icon: 'checkAll'
- }, config ) );
-
- this.controller = controller;
- this.model = model;
-
- // Events
- this.connect( this, { click: 'onClick' } );
- this.model.connect( this, { update: 'onModelUpdate' } );
-
- this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );
-
- this.onModelUpdate();
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.MarkSeenButtonWidget, OO.ui.ButtonWidget );
-
- /* Methods */
-
- /**
- * Respond to the button being clicked
- */
- mw.rcfilters.ui.MarkSeenButtonWidget.prototype.onClick = function () {
- this.controller.markAllChangesAsSeen();
- // assume there's no more unseen changes until the next model update
- this.setDisabled( true );
- };
-
- /**
- * Respond to the model being updated with new changes
- */
- mw.rcfilters.ui.MarkSeenButtonWidget.prototype.onModelUpdate = function () {
- this.setDisabled( !this.model.hasUnseenWatchedChanges() );
- };
-
-}() );
+++ /dev/null
-( function () {
- /**
- * A floating menu widget for the filter list
- *
- * @extends OO.ui.MenuSelectWidget
- *
- * @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
- * @cfg {Object[]} [footers] An array of objects defining the footers for
- * this menu, with a definition whether they appear per specific views.
- * The expected structure is:
- * [
- * {
- * name: {string} A unique name for the footer object
- * $element: {jQuery} A jQuery object for the content of the footer
- * views: {string[]} Optional. An array stating which views this footer is
- * active on. Use null or omit to display this on all views.
- * }
- * ]
- */
- mw.rcfilters.ui.MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
- var header;
-
- config = config || {};
-
- this.controller = controller;
- this.model = model;
- this.currentView = '';
- this.views = {};
- this.userSelecting = false;
-
- this.menuInitialized = false;
- this.$overlay = config.$overlay || this.$element;
- this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
- this.footers = [];
-
- // Parent
- mw.rcfilters.ui.MenuSelectWidget.parent.call( this, $.extend( config, {
- $autoCloseIgnore: this.$overlay,
- width: 650,
- // Our filtering is done through the model
- filterFromInput: false
- } ) );
- this.setGroupElement(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
- );
- this.setClippableElement( this.$body );
- this.setClippableContainer( this.$element );
-
- header = new mw.rcfilters.ui.FilterMenuHeaderWidget(
- this.controller,
- this.model,
- {
- $overlay: this.$overlay
- }
- );
-
- this.noResults = new OO.ui.LabelWidget( {
- label: mw.msg( 'rcfilters-filterlist-noresults' ),
- classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
- } );
-
- // Events
- this.model.connect( this, {
- initialize: 'onModelInitialize',
- searchChange: 'onModelSearchChange'
- } );
-
- // Initialization
- this.$element
- .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
- .append( header.$element )
- .append(
- this.$body
- .append( this.$group, this.noResults.$element )
- );
-
- // Append all footers; we will control their visibility
- // based on view
- config.footers = config.footers || [];
- config.footers.forEach( function ( footerData ) {
- var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
- adjustedData = {
- // Wrap the element with our own footer wrapper
- $element: $( '<div>' )
- .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
- .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
- .append( footerData.$element ),
- views: footerData.views
- };
-
- if ( !footerData.disabled ) {
- this.footers.push( adjustedData );
-
- if ( isSticky ) {
- this.$element.append( adjustedData.$element );
- } else {
- this.$body.append( adjustedData.$element );
- }
- }
- }.bind( this ) );
-
- // Switch to the correct view
- this.updateView();
- };
-
- /* Initialize */
-
- OO.inheritClass( mw.rcfilters.ui.MenuSelectWidget, OO.ui.MenuSelectWidget );
-
- /* Events */
-
- /* Methods */
- mw.rcfilters.ui.MenuSelectWidget.prototype.onModelSearchChange = function () {
- this.updateView();
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.MenuSelectWidget.prototype.toggle = function ( show ) {
- this.lazyMenuCreation();
- mw.rcfilters.ui.MenuSelectWidget.parent.prototype.toggle.call( this, show );
- // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
- this.setVerticalPosition( 'below' );
- };
-
- /**
- * lazy creation of the menu
- */
- mw.rcfilters.ui.MenuSelectWidget.prototype.lazyMenuCreation = function () {
- var widget = this,
- items = [],
- viewGroupCount = {},
- groups = this.model.getFilterGroups();
-
- if ( this.menuInitialized ) {
- return;
- }
-
- this.menuInitialized = true;
-
- // Create shared popup for highlight buttons
- this.highlightPopup = new mw.rcfilters.ui.HighlightPopupWidget( this.controller );
- this.$overlay.append( this.highlightPopup.$element );
-
- // Count groups per view
- // eslint-disable-next-line jquery/no-each-util
- $.each( groups, function ( groupName, groupModel ) {
- if ( !groupModel.isHidden() ) {
- viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
- viewGroupCount[ groupModel.getView() ]++;
- }
- } );
-
- // eslint-disable-next-line jquery/no-each-util
- $.each( groups, function ( groupName, groupModel ) {
- var currentItems = [],
- view = groupModel.getView();
-
- if ( !groupModel.isHidden() ) {
- if ( viewGroupCount[ view ] > 1 ) {
- // Only add a section header if there is more than
- // one group
- currentItems.push(
- // Group section
- new mw.rcfilters.ui.FilterMenuSectionOptionWidget(
- widget.controller,
- groupModel,
- {
- $overlay: widget.$overlay
- }
- )
- );
- }
-
- // Add items
- widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
- currentItems.push(
- new mw.rcfilters.ui.FilterMenuOptionWidget(
- widget.controller,
- widget.model,
- widget.model.getInvertModel(),
- filterItem,
- widget.highlightPopup,
- {
- $overlay: widget.$overlay
- }
- )
- );
- } );
-
- // Cache the items per view, so we can switch between them
- // without rebuilding the widgets each time
- widget.views[ view ] = widget.views[ view ] || [];
- widget.views[ view ] = widget.views[ view ].concat( currentItems );
- items = items.concat( currentItems );
- }
- } );
-
- this.addItems( items );
- this.updateView();
- };
-
- /**
- * Respond to model initialize event. Populate the menu from the model
- */
- mw.rcfilters.ui.MenuSelectWidget.prototype.onModelInitialize = function () {
- this.menuInitialized = false;
- // Set timeout for the menu to lazy build.
- setTimeout( this.lazyMenuCreation.bind( this ) );
- };
-
- /**
- * Update view
- */
- mw.rcfilters.ui.MenuSelectWidget.prototype.updateView = function () {
- var viewName = this.model.getCurrentView();
-
- if ( this.views[ viewName ] && this.currentView !== viewName ) {
- this.updateFooterVisibility( viewName );
-
- this.$element
- .data( 'view', viewName )
- .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
- .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
-
- this.currentView = viewName;
- this.scrollToTop();
- }
-
- this.postProcessItems();
- this.clip();
- };
-
- /**
- * Go over the available footers and decide which should be visible
- * for this view
- *
- * @param {string} [currentView] Current view
- */
- mw.rcfilters.ui.MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
- currentView = currentView || this.model.getCurrentView();
-
- this.footers.forEach( function ( data ) {
- data.$element.toggle(
- // This footer should only be shown if it is configured
- // for all views or for this specific view
- !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
- );
- } );
- };
-
- /**
- * Post-process items after the visibility changed. Make sure
- * that we always have an item selected, and that the no-results
- * widget appears if the menu is empty.
- */
- mw.rcfilters.ui.MenuSelectWidget.prototype.postProcessItems = function () {
- var i,
- itemWasSelected = false,
- items = this.getItems();
-
- // If we are not already selecting an item, always make sure
- // that the top item is selected
- if ( !this.userSelecting ) {
- // Select the first item in the list
- for ( i = 0; i < items.length; i++ ) {
- if (
- !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
- items[ i ].isVisible()
- ) {
- itemWasSelected = true;
- this.selectItem( items[ i ] );
- break;
- }
- }
-
- if ( !itemWasSelected ) {
- this.selectItem( null );
- }
- }
-
- this.noResults.toggle( !this.getItems().some( function ( item ) {
- return item.isVisible();
- } ) );
- };
-
- /**
- * Get the option widget that matches the model given
- *
- * @param {mw.rcfilters.dm.ItemModel} model Item model
- * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
- */
- mw.rcfilters.ui.MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
- this.lazyMenuCreation();
- return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
- return item.getName() === model.getName();
- } )[ 0 ];
- };
-
- /**
- * @inheritdoc
- */
- mw.rcfilters.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
- var nextItem,
- currentItem = this.findHighlightedItem() || this.findSelectedItem();
-
- // Call parent
- mw.rcfilters.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
-
- // We want to select the item on arrow movement
- // rather than just highlight it, like the menu
- // does by default
- if ( !this.isDisabled() && this.isVisible() ) {
- switch ( e.keyCode ) {
- case OO.ui.Keys.UP:
- case OO.ui.Keys.LEFT:
- // Get the next item
- nextItem = this.findRelativeSelectableItem( currentItem, -1 );
- break;
- case OO.ui.Keys.DOWN:
- case OO.ui.Keys.RIGHT:
- // Get the next item
- nextItem = this.findRelativeSelectableItem( currentItem, 1 );
- break;
- }
-
- nextItem = nextItem && nextItem.constructor.static.selectable ?
- nextItem : null;
-
- // Select the next item
- this.selectItem( nextItem );
- }
- };
-
- /**
- * Scroll to the top of the menu
- */
- mw.rcfilters.ui.MenuSelectWidget.prototype.scrollToTop = function () {
- this.$body.scrollTop( 0 );
- };
-
- /**
- * Set whether the user is currently selecting an item.
- * This is important when the user selects an item that is in between
- * different views, and makes sure we do not re-select a different
- * item (like the item on top) when this is happening.
- *
- * @param {boolean} isSelecting User is selecting
- */
- mw.rcfilters.ui.MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
- this.userSelecting = !!isSelecting;
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Top section (between page title and filters) on Special:Recentchanges
- *
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
- * @param {jQuery} $topLinks Content of the community-defined links
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
- savedLinksListWidget, $topLinks, config
- ) {
- var toplinksTitle,
- topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
- topLinksCookie = mw.cookie.get( topLinksCookieName ),
- topLinksCookieValue = topLinksCookie || 'collapsed',
- widget = this;
-
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.RcTopSectionWidget.parent.call( this, config );
-
- this.$topLinks = $topLinks;
-
- toplinksTitle = new OO.ui.ButtonWidget( {
- framed: false,
- indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
- flags: [ 'progressive' ],
- label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
- } );
-
- this.$topLinks
- .makeCollapsible( {
- collapsed: topLinksCookieValue === 'collapsed',
- $customTogglers: toplinksTitle.$element
- } )
- .on( 'beforeExpand.mw-collapsible', function () {
- mw.cookie.set( topLinksCookieName, 'expanded' );
- toplinksTitle.setIndicator( 'up' );
- widget.switchTopLinks( 'expanded' );
- } )
- .on( 'beforeCollapse.mw-collapsible', function () {
- mw.cookie.set( topLinksCookieName, 'collapsed' );
- toplinksTitle.setIndicator( 'down' );
- widget.switchTopLinks( 'collapsed' );
- } );
-
- this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
- .replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );
-
- // Create two positions for the toplinks to toggle between
- // in the table (first cell) or up above it
- this.$top = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
- this.$tableTopLinks = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );
-
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- this.$tableTopLinks,
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table-placeholder' )
- .addClass( 'mw-rcfilters-ui-cell' ),
- !mw.user.isAnon() ?
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
- .append( savedLinksListWidget.$element ) :
- null
- )
- )
- );
-
- // Hack: For jumpiness reasons, this should be a sibling of -head
- $( '.rcfilters-head' ).before( this.$top );
-
- // Initialize top links position
- widget.switchTopLinks( topLinksCookieValue );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.RcTopSectionWidget, OO.ui.Widget );
-
- /**
- * Switch the top links widget from inside the table (when collapsed)
- * to the 'top' (when open)
- *
- * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
- */
- mw.rcfilters.ui.RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
- state = state || 'expanded';
-
- if ( state === 'expanded' ) {
- this.$top.append( this.$topLinks );
- } else {
- this.$tableTopLinks.append( this.$topLinks );
- }
- this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
- *
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.FilterItem} targetPageModel
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
- controller, targetPageModel, config
- ) {
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.RclTargetPageWidget.parent.call( this, config );
-
- this.controller = controller;
- this.model = targetPageModel;
-
- this.titleSearch = new mw.widgets.TitleInputWidget( {
- validate: false,
- placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
- showImages: true,
- showDescriptions: true,
- addQueryInput: false
- } );
-
- // Events
- this.model.connect( this, { update: 'updateUiBasedOnModel' } );
-
- this.titleSearch.$input.on( {
- blur: this.onLookupInputBlur.bind( this )
- } );
-
- this.titleSearch.lookupMenu.connect( this, {
- choose: 'onLookupMenuItemChoose'
- } );
-
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
- .append( this.titleSearch.$element );
-
- this.updateUiBasedOnModel();
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.RclTargetPageWidget, OO.ui.Widget );
-
- /* Methods */
-
- /**
- * Respond to the user choosing a title
- */
- mw.rcfilters.ui.RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
- this.titleSearch.$input.trigger( 'blur' );
- };
-
- /**
- * Respond to titleSearch $input blur
- */
- mw.rcfilters.ui.RclTargetPageWidget.prototype.onLookupInputBlur = function () {
- this.controller.setTargetPage( this.titleSearch.getQueryValue() );
- };
-
- /**
- * Respond to the model being updated
- */
- mw.rcfilters.ui.RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
- var title = mw.Title.newFromText( this.model.getValue() ),
- text = title ? title.toText() : this.model.getValue();
- this.titleSearch.setValue( text );
- this.titleSearch.setTitle( text );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Widget to select to view changes that link TO or FROM the target page
- * on Special:RecentChangesLinked (AKA Related Changes)
- *
- * @extends OO.ui.DropdownWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
- controller, showLinkedToModel, config
- ) {
- config = config || {};
-
- this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
- data: 'from', // default (showlinkedto=0)
- label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
- } );
- this.showLinkedTo = new OO.ui.MenuOptionWidget( {
- data: 'to', // showlinkedto=1
- label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
- } );
-
- // Parent
- mw.rcfilters.ui.RclToOrFromWidget.parent.call( this, $.extend( {
- classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
- menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
- }, config ) );
-
- this.controller = controller;
- this.model = showLinkedToModel;
-
- this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
- this.model.connect( this, { update: 'onModelUpdate' } );
-
- // force an initial update of the component based on the state
- this.onModelUpdate();
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.RclToOrFromWidget, OO.ui.DropdownWidget );
-
- /* Methods */
-
- /**
- * Respond to the user choosing an item in the menu
- *
- * @param {OO.ui.MenuOptionWidget} chosenItem
- */
- mw.rcfilters.ui.RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
- this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
- };
-
- /**
- * Respond to model update
- */
- mw.rcfilters.ui.RclToOrFromWidget.prototype.onModelUpdate = function () {
- this.getMenu().selectItem(
- this.model.isSelected() ?
- this.showLinkedTo :
- this.showLinkedFrom
- );
- this.setLabel( mw.msg(
- this.model.isSelected() ?
- 'rcfilters-filter-showlinkedto-label' :
- 'rcfilters-filter-showlinkedfrom-label'
- ) );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
- *
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
- * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
- savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
- ) {
- var toOrFromWidget,
- targetPage;
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.RclTopSectionWidget.parent.call( this, config );
-
- this.controller = controller;
-
- toOrFromWidget = new mw.rcfilters.ui.RclToOrFromWidget( controller, showLinkedToModel );
- targetPage = new mw.rcfilters.ui.RclTargetPageWidget( controller, targetPageModel );
-
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .append( toOrFromWidget.$element )
- ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .append( targetPage.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table-placeholder' )
- .addClass( 'mw-rcfilters-ui-cell' ),
- !mw.user.isAnon() ?
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
- .append( savedLinksListWidget.$element ) :
- null
- )
- )
- );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.RclTopSectionWidget, OO.ui.Widget );
-}() );
+++ /dev/null
-( function () {
- /**
- * Save filters widget. This widget is displayed in the tag area
- * and allows the user to save the current state of the system
- * as a new saved filter query they can later load or set as
- * default.
- *
- * @extends OO.ui.PopupButtonWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
- var layout,
- checkBoxLayout,
- $popupContent = $( '<div>' );
-
- config = config || {};
-
- this.controller = controller;
- this.model = model;
-
- // Parent
- mw.rcfilters.ui.SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
- framed: false,
- icon: 'bookmark',
- title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
- popup: {
- classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
- padded: true,
- head: true,
- label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
- $content: $popupContent
- }
- }, config ) );
- // // HACK: Add an icon to the popup head label
- this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
-
- this.input = new OO.ui.TextInputWidget( {
- placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
- } );
- layout = new OO.ui.FieldLayout( this.input, {
- label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
- align: 'top'
- } );
-
- this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
- checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
- label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
- align: 'inline'
- } );
-
- this.applyButton = new OO.ui.ButtonWidget( {
- label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
- classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
- flags: [ 'primary', 'progressive' ]
- } );
- this.cancelButton = new OO.ui.ButtonWidget( {
- label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
- classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
- } );
-
- $popupContent
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
- .append( layout.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
- .append( checkBoxLayout.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
- .append(
- this.cancelButton.$element,
- this.applyButton.$element
- )
- );
-
- // Events
- this.popup.connect( this, {
- ready: 'onPopupReady'
- } );
- this.input.connect( this, {
- change: 'onInputChange',
- enter: 'onInputEnter'
- } );
- this.input.$input.on( {
- keyup: this.onInputKeyup.bind( this )
- } );
- this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
- this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
- this.applyButton.connect( this, { click: 'onApplyButtonClick' } );
-
- // Initialize
- this.applyButton.setDisabled( !this.input.getValue() );
- this.$element
- .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
- };
-
- /* Initialization */
- OO.inheritClass( mw.rcfilters.ui.SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );
-
- /**
- * Respond to input enter event
- */
- mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
- this.apply();
- };
-
- /**
- * Respond to input change event
- *
- * @param {string} value Input value
- */
- mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
- value = value.trim();
-
- this.applyButton.setDisabled( !value );
- };
-
- /**
- * Respond to input keyup event, this is the way to intercept 'escape' key
- *
- * @param {jQuery.Event} e Event data
- * @return {boolean} false
- */
- mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
- if ( e.which === OO.ui.Keys.ESCAPE ) {
- this.popup.toggle( false );
- return false;
- }
- };
-
- /**
- * Respond to popup ready event
- */
- mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
- this.input.focus();
- };
-
- /**
- * Respond to "set as default" checkbox change
- * @param {boolean} checked State of the checkbox
- */
- mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
- var messageKey = checked ?
- 'rcfilters-savedqueries-apply-and-setdefault-label' :
- 'rcfilters-savedqueries-apply-label';
-
- this.applyButton
- .setIcon( checked ? 'pushPin' : null )
- .setLabel( mw.msg( messageKey ) );
- };
-
- /**
- * Respond to cancel button click event
- */
- mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
- this.popup.toggle( false );
- };
-
- /**
- * Respond to apply button click event
- */
- mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
- this.apply();
- };
-
- /**
- * Apply and add the new quick link
- */
- mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.apply = function () {
- var label = this.input.getValue().trim();
-
- // This condition is more for sanity-check, since the
- // apply button should be disabled if the label is empty
- if ( label ) {
- this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
- this.input.setValue( '' );
- this.setAsDefaultCheckbox.setSelected( false );
- this.popup.toggle( false );
-
- this.emit( 'saveCurrent' );
- }
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Quick links menu option widget
- *
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.TitledElement
- *
- * @constructor
- * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
- * @param {Object} [config] Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- */
- mw.rcfilters.ui.SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
- config = config || {};
-
- this.model = model;
-
- // Parent
- mw.rcfilters.ui.SavedLinksListItemWidget.parent.call( this, $.extend( {
- data: this.model.getID()
- }, config ) );
-
- // Mixin constructors
- OO.ui.mixin.LabelElement.call( this, $.extend( {
- label: this.model.getLabel()
- }, config ) );
- OO.ui.mixin.IconElement.call( this, $.extend( {
- icon: ''
- }, config ) );
- OO.ui.mixin.TitledElement.call( this, $.extend( {
- title: this.model.getLabel()
- }, config ) );
-
- this.edit = false;
- this.$overlay = config.$overlay || this.$element;
-
- this.popupButton = new OO.ui.ButtonWidget( {
- classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
- icon: 'ellipsis',
- framed: false
- } );
- this.menu = new OO.ui.MenuSelectWidget( {
- classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
- widget: this.popupButton,
- width: 200,
- horizontalPosition: 'end',
- $floatableContainer: this.popupButton.$element,
- items: [
- new OO.ui.MenuOptionWidget( {
- data: 'edit',
- icon: 'edit',
- label: mw.msg( 'rcfilters-savedqueries-rename' )
- } ),
- new OO.ui.MenuOptionWidget( {
- data: 'delete',
- icon: 'trash',
- label: mw.msg( 'rcfilters-savedqueries-remove' )
- } ),
- new OO.ui.MenuOptionWidget( {
- data: 'default',
- icon: 'pushPin',
- label: mw.msg( 'rcfilters-savedqueries-setdefault' )
- } )
- ]
- } );
-
- this.editInput = new OO.ui.TextInputWidget( {
- classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
- } );
- this.saveButton = new OO.ui.ButtonWidget( {
- icon: 'check',
- flags: [ 'primary', 'progressive' ]
- } );
- this.toggleEdit( false );
-
- // Events
- this.model.connect( this, { update: 'onModelUpdate' } );
- this.popupButton.connect( this, { click: 'onPopupButtonClick' } );
- this.menu.connect( this, {
- choose: 'onMenuChoose'
- } );
- this.saveButton.connect( this, { click: 'save' } );
- this.editInput.connect( this, {
- change: 'onInputChange',
- enter: 'save'
- } );
- this.editInput.$input.on( {
- blur: this.onInputBlur.bind( this ),
- keyup: this.onInputKeyup.bind( this )
- } );
- this.$element.on( { click: this.onClick.bind( this ) } );
- this.$label.on( { click: this.onClick.bind( this ) } );
- this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );
- // Prevent propagation on mousedown for the save button
- // so the menu doesn't close
- this.saveButton.$element.on( { mousedown: function () {
- return false;
- } } );
-
- // Initialize
- this.toggleDefault( !!this.model.isDefault() );
- this.$overlay.append( this.menu.$element );
- this.$element
- .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
- .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
- .append(
- this.$label
- .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
- this.editInput.$element,
- this.saveButton.$element
- ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
- .append( this.$icon ),
- this.popupButton.$element
- .addClass( 'mw-rcfilters-ui-cell' )
- )
- )
- );
- };
-
- /* Initialization */
- OO.inheritClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.Widget );
- OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.LabelElement );
- OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.IconElement );
- OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.TitledElement );
-
- /* Events */
-
- /**
- * @event delete
- *
- * The delete option was selected for this item
- */
-
- /**
- * @event default
- * @param {boolean} default Item is default
- *
- * The 'make default' option was selected for this item
- */
-
- /**
- * @event edit
- * @param {string} newLabel New label for the query
- *
- * The label has been edited
- */
-
- /* Methods */
-
- /**
- * Respond to model update event
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onModelUpdate = function () {
- this.setLabel( this.model.getLabel() );
- this.toggleDefault( this.model.isDefault() );
- };
-
- /**
- * Respond to click on the element or label
- *
- * @fires click
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onClick = function () {
- if ( !this.editing ) {
- this.emit( 'click' );
- }
- };
-
- /**
- * Respond to click on the 'default' icon. Open the submenu where the
- * default state can be changed.
- *
- * @return {boolean} false
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
- this.menu.toggle();
- return false;
- };
-
- /**
- * Respond to popup button click event
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onPopupButtonClick = function () {
- this.menu.toggle();
- };
-
- /**
- * Respond to menu choose event
- *
- * @param {OO.ui.MenuOptionWidget} item Chosen item
- * @fires delete
- * @fires default
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
- var action = item.getData();
-
- if ( action === 'edit' ) {
- this.toggleEdit( true );
- } else if ( action === 'delete' ) {
- this.emit( 'delete' );
- } else if ( action === 'default' ) {
- this.emit( 'default', !this.default );
- }
- // Reset selected
- this.menu.selectItem( null );
- // Close the menu
- this.menu.toggle( false );
- };
-
- /**
- * Respond to input keyup event, this is the way to intercept 'escape' key
- *
- * @param {jQuery.Event} e Event data
- * @return {boolean} false
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
- if ( e.which === OO.ui.Keys.ESCAPE ) {
- // Return the input to the original label
- this.editInput.setValue( this.getLabel() );
- this.toggleEdit( false );
- return false;
- }
- };
-
- /**
- * Respond to blur event on the input
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputBlur = function () {
- this.save();
-
- // Whether the save succeeded or not, the input-blur event
- // means we need to cancel editing mode
- this.toggleEdit( false );
- };
-
- /**
- * Respond to input change event
- *
- * @param {string} value Input value
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
- value = value.trim();
-
- this.saveButton.setDisabled( !value );
- };
-
- /**
- * Save the name of the query
- *
- * @param {string} [value] The value to save
- * @fires edit
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.save = function () {
- var value = this.editInput.getValue().trim();
-
- if ( value ) {
- this.emit( 'edit', value );
- this.toggleEdit( false );
- }
- };
-
- /**
- * Toggle edit mode on this widget
- *
- * @param {boolean} isEdit Widget is in edit mode
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
- isEdit = isEdit === undefined ? !this.editing : isEdit;
-
- if ( this.editing !== isEdit ) {
- this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
- this.editInput.setValue( this.getLabel() );
-
- this.editInput.toggle( isEdit );
- this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
- this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
- this.popupButton.toggle( !isEdit );
- this.saveButton.toggle( isEdit );
-
- if ( isEdit ) {
- this.editInput.$input.trigger( 'focus' );
- }
- this.editing = isEdit;
- }
- };
-
- /**
- * Toggle default this widget
- *
- * @param {boolean} isDefault This item is default
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
- isDefault = isDefault === undefined ? !this.default : isDefault;
-
- if ( this.default !== isDefault ) {
- this.default = isDefault;
- this.setIcon( this.default ? 'pushPin' : '' );
- this.menu.findItemFromData( 'default' ).setLabel(
- this.default ?
- mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
- mw.msg( 'rcfilters-savedqueries-setdefault' )
- );
- }
- };
-
- /**
- * Get item ID
- *
- * @return {string} Query identifier
- */
- mw.rcfilters.ui.SavedLinksListItemWidget.prototype.getID = function () {
- return this.model.getID();
- };
-
-}() );
+++ /dev/null
-( function () {
- /**
- * Quick links widget
- *
- * @class
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
- * @param {Object} [config] Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- */
- mw.rcfilters.ui.SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
- var $labelNoEntries = $( '<div>' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
- .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
- .text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
- );
-
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.SavedLinksListWidget.parent.call( this, config );
-
- this.controller = controller;
- this.model = model;
- this.$overlay = config.$overlay || this.$element;
-
- this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
- classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
- label: $labelNoEntries,
- icon: 'bookmark'
- } );
-
- this.menu = new mw.rcfilters.ui.GroupWidget( {
- events: {
- click: 'menuItemClick',
- delete: 'menuItemDelete',
- default: 'menuItemDefault',
- edit: 'menuItemEdit'
- },
- classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
- items: [ this.placeholderItem ]
- } );
- this.button = new OO.ui.PopupButtonWidget( {
- classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
- label: mw.msg( 'rcfilters-quickfilters' ),
- icon: 'bookmark',
- indicator: 'down',
- $overlay: this.$overlay,
- popup: {
- width: 300,
- anchor: false,
- align: 'backwards',
- $autoCloseIgnore: this.$overlay,
- $content: this.menu.$element
- }
- } );
-
- // Events
- this.model.connect( this, {
- add: 'onModelAddItem',
- remove: 'onModelRemoveItem'
- } );
- this.menu.connect( this, {
- menuItemClick: 'onMenuItemClick',
- menuItemDelete: 'onMenuItemRemove',
- menuItemDefault: 'onMenuItemDefault',
- menuItemEdit: 'onMenuItemEdit'
- } );
-
- this.placeholderItem.toggle( this.model.isEmpty() );
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-savedLinksListWidget' )
- .append( this.button.$element );
- };
-
- /* Initialization */
- OO.inheritClass( mw.rcfilters.ui.SavedLinksListWidget, OO.ui.Widget );
-
- /* Methods */
-
- /**
- * Respond to menu item click event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- */
- mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) {
- this.controller.applySavedQuery( item.getID() );
- this.button.popup.toggle( false );
- };
-
- /**
- * Respond to menu item remove event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- */
- mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
- this.controller.removeSavedQuery( item.getID() );
- };
-
- /**
- * Respond to menu item default event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- * @param {boolean} isDefault Item is default
- */
- mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
- this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
- };
-
- /**
- * Respond to menu item edit event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- * @param {string} newLabel New label
- */
- mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
- this.controller.renameSavedQuery( item.getID(), newLabel );
- };
-
- /**
- * Respond to menu add item event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- */
- mw.rcfilters.ui.SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
- if ( this.menu.findItemFromData( item.getID() ) ) {
- return;
- }
-
- this.menu.addItems( [
- new mw.rcfilters.ui.SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
- ] );
- this.placeholderItem.toggle( this.model.isEmpty() );
- };
-
- /**
- * Respond to menu remove item event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- */
- mw.rcfilters.ui.SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
- this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
- this.placeholderItem.toggle( this.model.isEmpty() );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Extend OOUI's TagItemWidget to also display a popup on hover.
- *
- * @class
- * @extends OO.ui.TagItemWidget
- * @mixins OO.ui.mixin.PopupElement
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
- * @param {mw.rcfilters.dm.FilterItem} invertModel
- * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
- * @param {Object} config Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- */
- mw.rcfilters.ui.TagItemWidget = function MwRcfiltersUiTagItemWidget(
- controller, filtersViewModel, invertModel, itemModel, config
- ) {
- // Configuration initialization
- config = config || {};
-
- this.controller = controller;
- this.invertModel = invertModel;
- this.filtersViewModel = filtersViewModel;
- this.itemModel = itemModel;
- this.selected = false;
-
- mw.rcfilters.ui.TagItemWidget.parent.call( this, $.extend( {
- data: this.itemModel.getName()
- }, config ) );
-
- this.$overlay = config.$overlay || this.$element;
- this.popupLabel = new OO.ui.LabelWidget();
-
- // Mixin constructors
- OO.ui.mixin.PopupElement.call( this, $.extend( {
- popup: {
- padded: false,
- align: 'center',
- position: 'above',
- $content: $( '<div>' )
- .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
- .append( this.popupLabel.$element ),
- $floatableContainer: this.$element,
- classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
- }
- }, config ) );
-
- this.popupTimeoutShow = null;
- this.popupTimeoutHide = null;
-
- this.$highlight = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );
-
- // Add title attribute with the item label to 'x' button
- this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );
-
- // Events
- this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
- this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
- this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
-
- // Initialization
- this.$overlay.append( this.popup.$element );
- this.$element
- .addClass( 'mw-rcfilters-ui-tagItemWidget' )
- .prepend( this.$highlight )
- .attr( 'aria-haspopup', 'true' )
- .on( 'mouseenter', this.onMouseEnter.bind( this ) )
- .on( 'mouseleave', this.onMouseLeave.bind( this ) );
-
- this.updateUiBasedOnState();
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.TagItemWidget, OO.ui.TagItemWidget );
- OO.mixinClass( mw.rcfilters.ui.TagItemWidget, OO.ui.mixin.PopupElement );
-
- /* Methods */
-
- /**
- * Respond to model update event
- */
- mw.rcfilters.ui.TagItemWidget.prototype.updateUiBasedOnState = function () {
- // Update label if needed
- var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel.isSelected() );
- if ( labelMsg ) {
- this.setLabel( $( '<div>' ).append(
- $( '<bdi>' ).html(
- mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
- )
- ).contents() );
- } else {
- this.setLabel(
- $( '<bdi>' ).append(
- this.itemModel.getLabel()
- )
- );
- }
-
- this.setCurrentMuteState();
- this.setHighlightColor();
- };
-
- /**
- * Set the current highlight color for this item
- */
- mw.rcfilters.ui.TagItemWidget.prototype.setHighlightColor = function () {
- var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
- this.itemModel.getHighlightColor() :
- null;
-
- this.$highlight
- .attr( 'data-color', selectedColor )
- .toggleClass(
- 'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
- !!selectedColor
- );
- };
-
- /**
- * Set the current mute state for this item
- */
- mw.rcfilters.ui.TagItemWidget.prototype.setCurrentMuteState = function () {};
-
- /**
- * Respond to mouse enter event
- */
- mw.rcfilters.ui.TagItemWidget.prototype.onMouseEnter = function () {
- var labelText = this.itemModel.getStateMessage();
-
- if ( labelText ) {
- this.popupLabel.setLabel( labelText );
-
- // Set timeout for the popup to show
- this.popupTimeoutShow = setTimeout( function () {
- this.popup.toggle( true );
- }.bind( this ), 500 );
-
- // Cancel the hide timeout
- clearTimeout( this.popupTimeoutHide );
- this.popupTimeoutHide = null;
- }
- };
-
- /**
- * Respond to mouse leave event
- */
- mw.rcfilters.ui.TagItemWidget.prototype.onMouseLeave = function () {
- this.popupTimeoutHide = setTimeout( function () {
- this.popup.toggle( false );
- }.bind( this ), 250 );
-
- // Clear the show timeout
- clearTimeout( this.popupTimeoutShow );
- this.popupTimeoutShow = null;
- };
-
- /**
- * Set selected state on this widget
- *
- * @param {boolean} [isSelected] Widget is selected
- */
- mw.rcfilters.ui.TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
- isSelected = isSelected !== undefined ? isSelected : !this.selected;
-
- if ( this.selected !== isSelected ) {
- this.selected = isSelected;
-
- this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
- }
- };
-
- /**
- * Get the selected state of this widget
- *
- * @return {boolean} Tag is selected
- */
- mw.rcfilters.ui.TagItemWidget.prototype.isSelected = function () {
- return this.selected;
- };
-
- /**
- * Get item name
- *
- * @return {string} Filter name
- */
- mw.rcfilters.ui.TagItemWidget.prototype.getName = function () {
- return this.itemModel.getName();
- };
-
- /**
- * Get item model
- *
- * @return {string} Filter model
- */
- mw.rcfilters.ui.TagItemWidget.prototype.getModel = function () {
- return this.itemModel;
- };
-
- /**
- * Get item view
- *
- * @return {string} Filter view
- */
- mw.rcfilters.ui.TagItemWidget.prototype.getView = function () {
- return this.itemModel.getGroupModel().getView();
- };
-
- /**
- * Remove and destroy external elements of this widget
- */
- mw.rcfilters.ui.TagItemWidget.prototype.destroy = function () {
- // Destroy the popup
- this.popup.$element.detach();
-
- // Disconnect events
- this.itemModel.disconnect( this );
- this.closeButton.disconnect( this );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Widget defining the behavior used to choose from a set of values
- * in a single_value group
- *
- * @class
- * @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.findSelectedItems()[ 0 ];
-
- if ( selectedItem ) {
- this.selectWidget.selectItemByData( selectedItem.getName() );
- }
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * A widget for the footer for the default view, allowing to switch views
- *
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.ViewSwitchWidget.parent.call( this, config );
-
- this.controller = controller;
- this.model = model;
-
- this.buttons = new mw.rcfilters.ui.GroupWidget( {
- events: {
- click: 'buttonClick'
- },
- items: [
- new OO.ui.ButtonWidget( {
- data: 'namespaces',
- icon: 'article',
- label: mw.msg( 'namespaces' )
- } ),
- new OO.ui.ButtonWidget( {
- data: 'tags',
- icon: 'tag',
- label: mw.msg( 'rcfilters-view-tags' )
- } )
- ]
- } );
-
- // Events
- this.model.connect( this, { update: 'onModelUpdate' } );
- this.buttons.connect( this, { buttonClick: 'onButtonClick' } );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
- .append(
- new OO.ui.LabelWidget( {
- label: mw.msg( 'rcfilters-advancedfilters' )
- } ).$element,
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
- .append( this.buttons.$element )
- );
- };
-
- /* Initialize */
-
- OO.inheritClass( mw.rcfilters.ui.ViewSwitchWidget, OO.ui.Widget );
-
- /**
- * Respond to model update event
- */
- mw.rcfilters.ui.ViewSwitchWidget.prototype.onModelUpdate = function () {
- var currentView = this.model.getCurrentView();
-
- this.buttons.getItems().forEach( function ( buttonWidget ) {
- buttonWidget.setActive( buttonWidget.getData() === currentView );
- } );
- };
-
- /**
- * Respond to button switch click
- *
- * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
- */
- mw.rcfilters.ui.ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
- this.controller.switchView( buttonWidget.getData() );
- };
-}() );
+++ /dev/null
-( function () {
- /**
- * Top section (between page title and filters) on Special:Watchlist
- *
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
- * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
- * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
- * @param {Object} [config] Configuration object
- */
- mw.rcfilters.ui.WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
- controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
- ) {
- var editWatchlistButton,
- markSeenButton,
- $topTable,
- $bottomTable,
- $separator;
- config = config || {};
-
- // Parent
- mw.rcfilters.ui.WatchlistTopSectionWidget.parent.call( this, config );
-
- editWatchlistButton = new OO.ui.ButtonWidget( {
- label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
- icon: 'edit',
- href: mw.config.get( 'wgStructuredChangeFiltersEditWatchlistUrl' )
- } );
- markSeenButton = new mw.rcfilters.ui.MarkSeenButtonWidget( controller, changesListModel );
-
- $topTable = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
- .append( $watchlistDetails )
- )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
- .append( editWatchlistButton.$element )
- )
- );
-
- $bottomTable = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .append( markSeenButton.$element )
- )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
- .append( savedLinksListWidget.$element )
- )
- );
-
- $separator = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
- .append( $topTable, $separator, $bottomTable );
- };
-
- /* Initialization */
-
- OO.inheritClass( mw.rcfilters.ui.WatchlistTopSectionWidget, OO.ui.Widget );
-}() );