From a85f40b5bd95e8c896847838fc878d357f998020 Mon Sep 17 00:00:00 2001 From: Fomafix Date: Sun, 7 Apr 2019 10:15:00 +0200 Subject: [PATCH] Remove redundant closure for all packageFiles with own directory The modules loaded with packageFiles, which are always executed in module scope (with a closure), even in debug mode. The behaviour of non-packageFiles debug mode is the only reason files have closures. Bug: T50886 Change-Id: I38b1b426930763e5ddf61fa29235c2df829310c3 --- resources/src/mediawiki.cookie/.eslintrc.json | 5 + resources/src/mediawiki.cookie/index.js | 190 +- .../src/mediawiki.jqueryMsg/.eslintrc.json | 5 + .../mediawiki.jqueryMsg.js | 2546 ++++++++--------- .../src/mediawiki.rcfilters/.eslintrc.json | 5 + .../src/mediawiki.rcfilters/Controller.js | 2319 ++++++++------- .../mediawiki.rcfilters/HighlightColors.js | 20 +- .../src/mediawiki.rcfilters/UriProcessor.js | 574 ++-- .../dm/ChangesListViewModel.js | 336 ++- .../src/mediawiki.rcfilters/dm/FilterGroup.js | 1902 ++++++------ .../src/mediawiki.rcfilters/dm/FilterItem.js | 787 +++-- .../dm/FiltersViewModel.js | 2473 ++++++++-------- .../src/mediawiki.rcfilters/dm/ItemModel.js | 546 ++-- .../dm/SavedQueriesModel.js | 794 +++-- .../dm/SavedQueryItemModel.js | 250 +- .../mediawiki.rcfilters/mw.rcfilters.init.js | 282 +- .../src/mediawiki.rcfilters/mw.rcfilters.js | 106 +- .../ui/ChangesLimitAndDateButtonWidget.js | 343 ++- .../ui/ChangesLimitPopupWidget.js | 138 +- .../ui/ChangesListWrapperWidget.js | 720 +++-- .../ui/CheckboxInputWidget.js | 110 +- .../mediawiki.rcfilters/ui/DatePopupWidget.js | 120 +- .../ui/FilterItemHighlightButton.js | 168 +- .../ui/FilterMenuHeaderWidget.js | 364 ++- .../ui/FilterMenuOptionWidget.js | 160 +- .../ui/FilterMenuSectionOptionWidget.js | 247 +- .../ui/FilterTagItemWidget.js | 98 +- .../ui/FilterTagMultiselectWidget.js | 1486 +++++----- .../ui/FilterWrapperWidget.js | 268 +- .../ui/FormWrapperWidget.js | 344 ++- .../src/mediawiki.rcfilters/ui/GroupWidget.js | 88 +- .../ui/HighlightColorPickerWidget.js | 242 +- .../ui/HighlightPopupWidget.js | 111 +- .../ui/ItemMenuOptionWidget.js | 303 +- .../ui/LiveUpdateButtonWidget.js | 117 +- .../ui/MainWrapperWidget.js | 258 +- .../ui/MarkSeenButtonWidget.js | 113 +- .../ui/MenuSelectWidget.js | 710 +++-- .../ui/RcTopSectionWidget.js | 198 +- .../ui/RclTargetPageWidget.js | 132 +- .../ui/RclToOrFromWidget.js | 128 +- .../ui/RclTopSectionWidget.js | 124 +- .../ui/SaveFiltersPopupButtonWidget.js | 374 ++- .../ui/SavedLinksListItemWidget.js | 655 +++-- .../ui/SavedLinksListWidget.js | 314 +- .../mediawiki.rcfilters/ui/TagItemWidget.js | 446 ++- .../ui/ValuePickerWidget.js | 210 +- .../ui/ViewSwitchWidget.js | 142 +- .../ui/WatchlistTopSectionWidget.js | 156 +- 49 files changed, 11219 insertions(+), 11308 deletions(-) create mode 100644 resources/src/mediawiki.cookie/.eslintrc.json create mode 100644 resources/src/mediawiki.jqueryMsg/.eslintrc.json create mode 100644 resources/src/mediawiki.rcfilters/.eslintrc.json diff --git a/resources/src/mediawiki.cookie/.eslintrc.json b/resources/src/mediawiki.cookie/.eslintrc.json new file mode 100644 index 0000000000..ad8dbb3e85 --- /dev/null +++ b/resources/src/mediawiki.cookie/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "parserOptions": { + "sourceType": "module" + } +} diff --git a/resources/src/mediawiki.cookie/index.js b/resources/src/mediawiki.cookie/index.js index 61379aecdb..b04b57a6b0 100644 --- a/resources/src/mediawiki.cookie/index.js +++ b/resources/src/mediawiki.cookie/index.js @@ -1,115 +1,113 @@ -( function () { - 'use strict'; +'use strict'; - var config = require( './config.json' ), - defaults = { - prefix: config.prefix, - domain: config.domain, - path: config.path, - expires: config.expires, - secure: false - }; +var config = require( './config.json' ), + defaults = { + prefix: config.prefix, + domain: config.domain, + path: config.path, + expires: config.expires, + secure: false + }; + +/** + * Manage cookies in a way that is syntactically and functionally similar + * to the `WebRequest#getCookie` and `WebResponse#setcookie` methods in PHP. + * + * @author Sam Smith + * @author Matthew Flaschen + * + * @class mw.cookie + * @singleton + */ +mw.cookie = { /** - * Manage cookies in a way that is syntactically and functionally similar - * to the `WebRequest#getCookie` and `WebResponse#setcookie` methods in PHP. + * Set or delete a cookie. * - * @author Sam Smith - * @author Matthew Flaschen + * **Note:** If explicitly passing `null` or `undefined` for an options key, + * that will override the default. This is natural in JavaScript, but noted + * here because it is contrary to MediaWiki's `WebResponse#setcookie()` method + * in PHP. * - * @class mw.cookie - * @singleton + * @param {string} key + * @param {string|null} value Value of cookie. If `value` is `null` then this method will + * instead remove a cookie by name of `key`. + * @param {Object|Date|number} [options] Options object, or expiry date + * @param {Date|number|null} [options.expires=wgCookieExpiration] The expiry date of the cookie, + * or lifetime in seconds. If `options.expires` is null or 0, then a session cookie is set. + * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key + * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie + * @param {string} [options.path=wgCookiePath] The path attribute of the cookie + * @param {boolean} [options.secure=false] Whether or not to include the secure attribute. + * (Does **not** use the wgCookieSecure configuration variable) */ - mw.cookie = { + set: function ( key, value, options ) { + var date; - /** - * Set or delete a cookie. - * - * **Note:** If explicitly passing `null` or `undefined` for an options key, - * that will override the default. This is natural in JavaScript, but noted - * here because it is contrary to MediaWiki's `WebResponse#setcookie()` method - * in PHP. - * - * @param {string} key - * @param {string|null} value Value of cookie. If `value` is `null` then this method will - * instead remove a cookie by name of `key`. - * @param {Object|Date|number} [options] Options object, or expiry date - * @param {Date|number|null} [options.expires=wgCookieExpiration] The expiry date of the cookie, - * or lifetime in seconds. If `options.expires` is null or 0, then a session cookie is set. - * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key - * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie - * @param {string} [options.path=wgCookiePath] The path attribute of the cookie - * @param {boolean} [options.secure=false] Whether or not to include the secure attribute. - * (Does **not** use the wgCookieSecure configuration variable) - */ - set: function ( key, value, options ) { - var date; + // The 'options' parameter may be a shortcut for the expiry. + if ( arguments.length > 2 && ( !options || options instanceof Date || typeof options === 'number' ) ) { + options = { expires: options }; + } + // Apply defaults + options = $.extend( {}, defaults, options ); - // The 'options' parameter may be a shortcut for the expiry. - if ( arguments.length > 2 && ( !options || options instanceof Date || typeof options === 'number' ) ) { - options = { expires: options }; - } - // Apply defaults - options = $.extend( {}, defaults, options ); + // Handle prefix + key = options.prefix + key; + // Don't pass invalid option to $.cookie + delete options.prefix; - // Handle prefix - key = options.prefix + key; - // Don't pass invalid option to $.cookie - delete options.prefix; + if ( !options.expires ) { + // Session cookie (null or zero) + // Normalize to absent (undefined) for $.cookie. + delete options.expires; + } else if ( typeof options.expires === 'number' ) { + // Lifetime in seconds + date = new Date(); + date.setTime( Number( date ) + ( options.expires * 1000 ) ); + options.expires = date; + } - if ( !options.expires ) { - // Session cookie (null or zero) - // Normalize to absent (undefined) for $.cookie. - delete options.expires; - } else if ( typeof options.expires === 'number' ) { - // Lifetime in seconds - date = new Date(); - date.setTime( Number( date ) + ( options.expires * 1000 ) ); - options.expires = date; - } + if ( value !== null ) { + value = String( value ); + } - if ( value !== null ) { - value = String( value ); - } + $.cookie( key, value, options ); + }, - $.cookie( key, value, options ); - }, + /** + * Get the value of a cookie. + * + * @param {string} key + * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is + * `undefined` or `null`, then `wgCookiePrefix` is used + * @param {Mixed} [defaultValue=null] + * @return {string|null|Mixed} If the cookie exists, then the value of the + * cookie, otherwise `defaultValue` + */ + get: function ( key, prefix, defaultValue ) { + var result; - /** - * Get the value of a cookie. - * - * @param {string} key - * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is - * `undefined` or `null`, then `wgCookiePrefix` is used - * @param {Mixed} [defaultValue=null] - * @return {string|null|Mixed} If the cookie exists, then the value of the - * cookie, otherwise `defaultValue` - */ - get: function ( key, prefix, defaultValue ) { - var result; + if ( prefix === undefined || prefix === null ) { + prefix = defaults.prefix; + } - if ( prefix === undefined || prefix === null ) { - prefix = defaults.prefix; - } + // Was defaultValue omitted? + if ( arguments.length < 3 ) { + defaultValue = null; + } - // Was defaultValue omitted? - if ( arguments.length < 3 ) { - defaultValue = null; - } + result = $.cookie( prefix + key ); - result = $.cookie( prefix + key ); + return result !== null ? result : defaultValue; + } +}; - return result !== null ? result : defaultValue; +if ( window.QUnit ) { + module.exports = { + setDefaults: function ( value ) { + var prev = defaults; + defaults = value; + return prev; } }; - - if ( window.QUnit ) { - module.exports = { - setDefaults: function ( value ) { - var prev = defaults; - defaults = value; - return prev; - } - }; - } -}() ); +} diff --git a/resources/src/mediawiki.jqueryMsg/.eslintrc.json b/resources/src/mediawiki.jqueryMsg/.eslintrc.json new file mode 100644 index 0000000000..ad8dbb3e85 --- /dev/null +++ b/resources/src/mediawiki.jqueryMsg/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "parserOptions": { + "sourceType": "module" + } +} diff --git a/resources/src/mediawiki.jqueryMsg/mediawiki.jqueryMsg.js b/resources/src/mediawiki.jqueryMsg/mediawiki.jqueryMsg.js index 3b89a744fe..641661290e 100644 --- a/resources/src/mediawiki.jqueryMsg/mediawiki.jqueryMsg.js +++ b/resources/src/mediawiki.jqueryMsg/mediawiki.jqueryMsg.js @@ -5,1421 +5,1419 @@ * @author neilk@wikimedia.org * @author mflaschen@wikimedia.org */ -( function () { - /** - * @class mw.jqueryMsg - * @singleton - */ - var oldParser, - slice = Array.prototype.slice, - parserDefaults = { - // Magic words and their expansions. Server-side data is added to this below. - magic: { - PAGENAME: mw.config.get( 'wgPageName' ), - PAGENAMEE: mw.util.wikiUrlencode( mw.config.get( 'wgPageName' ) ) - }, - // Whitelist for allowed HTML elements in wikitext. - // Self-closing tags are not currently supported. - // Filled in with server-side data below - allowedHtmlElements: [], - // Key tag name, value allowed attributes for that tag. - // See Sanitizer::setupAttributeWhitelist - allowedHtmlCommonAttributes: [ - // HTML - 'id', - 'class', - 'style', - 'lang', - 'dir', - 'title', - - // WAI-ARIA - 'role' - ], - - // Attributes allowed for specific elements. - // Key is element name in lower case - // Value is array of allowed attributes for that element - allowedHtmlAttributesByElement: {}, - messages: mw.messages, - language: mw.language, - - // Same meaning as in mediawiki.js. - // - // Only 'text', 'parse', and 'escaped' are supported, and the - // actual escaping for 'escaped' is done by other code (generally - // through mediawiki.js). - // - // However, note that this default only - // applies to direct calls to jqueryMsg. The default for mediawiki.js itself - // is 'text', including when it uses jqueryMsg. - format: 'parse' - }; - - // Add in server-side data (allowedHtmlElements and magic words) - $.extend( true, parserDefaults, require( './parserDefaults.json' ) ); +/** + * @class mw.jqueryMsg + * @singleton + */ + +var oldParser, + slice = Array.prototype.slice, + parserDefaults = { + // Magic words and their expansions. Server-side data is added to this below. + magic: { + PAGENAME: mw.config.get( 'wgPageName' ), + PAGENAMEE: mw.util.wikiUrlencode( mw.config.get( 'wgPageName' ) ) + }, + // Whitelist for allowed HTML elements in wikitext. + // Self-closing tags are not currently supported. + // Filled in with server-side data below + allowedHtmlElements: [], + // Key tag name, value allowed attributes for that tag. + // See Sanitizer::setupAttributeWhitelist + allowedHtmlCommonAttributes: [ + // HTML + 'id', + 'class', + 'style', + 'lang', + 'dir', + 'title', + + // WAI-ARIA + 'role' + ], + + // Attributes allowed for specific elements. + // Key is element name in lower case + // Value is array of allowed attributes for that element + allowedHtmlAttributesByElement: {}, + messages: mw.messages, + language: mw.language, + + // Same meaning as in mediawiki.js. + // + // Only 'text', 'parse', and 'escaped' are supported, and the + // actual escaping for 'escaped' is done by other code (generally + // through mediawiki.js). + // + // However, note that this default only + // applies to direct calls to jqueryMsg. The default for mediawiki.js itself + // is 'text', including when it uses jqueryMsg. + format: 'parse' + }; - /** - * Wrapper around jQuery append that converts all non-objects to TextNode so append will not - * convert what it detects as an htmlString to an element. - * - * If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to - * new parent. - * - * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is. - * - * @private - * @param {jQuery} $parent Parent node wrapped by jQuery - * @param {Object|string|Array} children What to append, with the same possible types as jQuery - * @return {jQuery} $parent - */ - function appendWithoutParsing( $parent, children ) { - var i, len; +// Add in server-side data (allowedHtmlElements and magic words) +$.extend( true, parserDefaults, require( './parserDefaults.json' ) ); + +/** + * Wrapper around jQuery append that converts all non-objects to TextNode so append will not + * convert what it detects as an htmlString to an element. + * + * If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to + * new parent. + * + * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is. + * + * @private + * @param {jQuery} $parent Parent node wrapped by jQuery + * @param {Object|string|Array} children What to append, with the same possible types as jQuery + * @return {jQuery} $parent + */ +function appendWithoutParsing( $parent, children ) { + var i, len; + + if ( !Array.isArray( children ) ) { + children = [ children ]; + } - if ( !Array.isArray( children ) ) { - children = [ children ]; + for ( i = 0, len = children.length; i < len; i++ ) { + if ( typeof children[ i ] !== 'object' ) { + children[ i ] = document.createTextNode( children[ i ] ); } - - for ( i = 0, len = children.length; i < len; i++ ) { - if ( typeof children[ i ] !== 'object' ) { - children[ i ] = document.createTextNode( children[ i ] ); - } - if ( children[ i ] instanceof $ && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) { - children[ i ] = children[ i ].contents(); - } + if ( children[ i ] instanceof $ && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) { + children[ i ] = children[ i ].contents(); } - - return $parent.append( children ); } - /** - * Decodes the main HTML entities, those encoded by mw.html.escape. - * - * @private - * @param {string} encoded Encoded string - * @return {string} String with those entities decoded - */ - function decodePrimaryHtmlEntities( encoded ) { - return encoded - .replace( /'/g, '\'' ) - .replace( /"/g, '"' ) - .replace( /</g, '<' ) - .replace( />/g, '>' ) - .replace( /&/g, '&' ); + return $parent.append( children ); +} + +/** + * Decodes the main HTML entities, those encoded by mw.html.escape. + * + * @private + * @param {string} encoded Encoded string + * @return {string} String with those entities decoded + */ +function decodePrimaryHtmlEntities( encoded ) { + return encoded + .replace( /'/g, '\'' ) + .replace( /"/g, '"' ) + .replace( /</g, '<' ) + .replace( />/g, '>' ) + .replace( /&/g, '&' ); +} + +/** + * Turn input into a string. + * + * @private + * @param {string|jQuery} input + * @return {string} Textual value of input + */ +function textify( input ) { + if ( input instanceof $ ) { + input = input.text(); } - - /** - * Turn input into a string. - * - * @private - * @param {string|jQuery} input - * @return {string} Textual value of input - */ - function textify( input ) { - if ( input instanceof $ ) { - input = input.text(); + return String( input ); +} + +/** + * Given parser options, return a function that parses a key and replacements, returning jQuery object + * + * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes. + * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into + * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it. + * + * @private + * @param {Object} options Parser options + * @return {Function} + * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements. + * @return {jQuery} return.return + */ +function getFailableParserFn( options ) { + return function ( args ) { + var fallback, + parser = new mw.jqueryMsg.Parser( options ), + key = args[ 0 ], + argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 ); + try { + return parser.parse( key, argsArray ); + } catch ( e ) { + fallback = parser.settings.messages.get( key ); + mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message ); + mw.track( 'mediawiki.jqueryMsg.error', { + messageKey: key, + errorMessage: e.message + } ); + return $( '' ).text( fallback ); } - return String( input ); + }; +} + +mw.jqueryMsg = {}; + +/** + * Initialize parser defaults. + * + * ResourceLoaderJqueryMsgModule calls this to provide default values from + * Sanitizer.php for allowed HTML elements. To override this data for individual + * parsers, pass the relevant options to mw.jqueryMsg.Parser. + * + * @private + * @param {Object} data New data to extend parser defaults with + * @param {boolean} [deep=false] Whether the extend is done recursively (deep) + */ +mw.jqueryMsg.setParserDefaults = function ( data, deep ) { + if ( deep ) { + $.extend( true, parserDefaults, data ); + } else { + $.extend( parserDefaults, data ); } - - /** - * Given parser options, return a function that parses a key and replacements, returning jQuery object - * - * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes. - * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into - * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it. - * - * @private - * @param {Object} options Parser options - * @return {Function} - * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements. - * @return {jQuery} return.return - */ - function getFailableParserFn( options ) { - return function ( args ) { - var fallback, - parser = new mw.jqueryMsg.Parser( options ), - key = args[ 0 ], - argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 ); - try { - return parser.parse( key, argsArray ); - } catch ( e ) { - fallback = parser.settings.messages.get( key ); - mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message ); - mw.track( 'mediawiki.jqueryMsg.error', { - messageKey: key, - errorMessage: e.message - } ); - return $( '' ).text( fallback ); - } - }; +}; + +/** + * Get current parser defaults. + * + * Primarily used for the unit test. Returns a copy. + * + * @private + * @return {Object} + */ +mw.jqueryMsg.getParserDefaults = function () { + return $.extend( {}, parserDefaults ); +}; + +/** + * Returns a function suitable for static use, to construct strings from a message key (and optional replacements). + * + * Example: + * + * var format = mediaWiki.jqueryMsg.getMessageFunction( options ); + * $( '#example' ).text( format( 'hello-user', username ) ); + * + * Tthis returns only strings, so it destroys any bindings. If you want to preserve bindings, use the + * jQuery plugin version instead. This was originally created to ease migration from `window.gM()`, + * from a time when the parser used by `mw.message` was not extendable. + * + * N.B. replacements are variadic arguments or an array in second parameter. In other words: + * somefunction( a, b, c, d ) + * is equivalent to + * somefunction( a, [b, c, d] ) + * + * @param {Object} options parser options + * @return {Function} Function The message formatter + * @return {string} return.key Message key. + * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array). + * @return {string} return.return Rendered HTML. + */ +mw.jqueryMsg.getMessageFunction = function ( options ) { + var failableParserFn, format; + + if ( options && options.format !== undefined ) { + format = options.format; + } else { + format = parserDefaults.format; } - mw.jqueryMsg = {}; - - /** - * Initialize parser defaults. - * - * ResourceLoaderJqueryMsgModule calls this to provide default values from - * Sanitizer.php for allowed HTML elements. To override this data for individual - * parsers, pass the relevant options to mw.jqueryMsg.Parser. - * - * @private - * @param {Object} data New data to extend parser defaults with - * @param {boolean} [deep=false] Whether the extend is done recursively (deep) - */ - mw.jqueryMsg.setParserDefaults = function ( data, deep ) { - if ( deep ) { - $.extend( true, parserDefaults, data ); + return function () { + var failableResult; + if ( !failableParserFn ) { + failableParserFn = getFailableParserFn( options ); + } + failableResult = failableParserFn( arguments ); + if ( format === 'text' || format === 'escaped' ) { + return failableResult.text(); } else { - $.extend( parserDefaults, data ); + return failableResult.html(); } }; - +}; + +/** + * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to + * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links. + * e.g. + * + * $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options ); + * var $userlink = $( '' ).click( function () { alert( "hello!!" ) } ); + * $( 'p#headline' ).msg( 'hello-user', $userlink ); + * + * N.B. replacements are variadic arguments or an array in second parameter. In other words: + * somefunction( a, b, c, d ) + * is equivalent to + * somefunction( a, [b, c, d] ) + * + * We append to 'this', which in a jQuery plugin context will be the selected elements. + * + * @param {Object} options Parser options + * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg + * @return {string} return.key Message key. + * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array). + * @return {jQuery} return.return + */ +mw.jqueryMsg.getPlugin = function ( options ) { + var failableParserFn; + + return function () { + var $target; + if ( !failableParserFn ) { + failableParserFn = getFailableParserFn( options ); + } + $target = this.empty(); + appendWithoutParsing( $target, failableParserFn( arguments ) ); + return $target; + }; +}; + +/** + * The parser itself. + * Describes an object, whose primary duty is to .parse() message keys. + * + * @class + * @private + * @param {Object} options + */ +mw.jqueryMsg.Parser = function ( options ) { + this.settings = $.extend( {}, parserDefaults, options ); + this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' ); + this.astCache = {}; + + this.emitter = new mw.jqueryMsg.HtmlEmitter( this.settings.language, this.settings.magic ); +}; +// Backwards-compatible alias +// @deprecated since 1.31 +mw.jqueryMsg.parser = mw.jqueryMsg.Parser; + +mw.jqueryMsg.Parser.prototype = { /** - * Get current parser defaults. - * - * Primarily used for the unit test. Returns a copy. + * Where the magic happens. + * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery + * If an error is thrown, returns original key, and logs the error * - * @private - * @return {Object} + * @param {string} key Message key. + * @param {Array} replacements Variable replacements for $1, $2... $n + * @return {jQuery} */ - mw.jqueryMsg.getParserDefaults = function () { - return $.extend( {}, parserDefaults ); - }; + parse: function ( key, replacements ) { + var ast = this.getAst( key, replacements ); + return this.emitter.emit( ast, replacements ); + }, /** - * Returns a function suitable for static use, to construct strings from a message key (and optional replacements). - * - * Example: - * - * var format = mediaWiki.jqueryMsg.getMessageFunction( options ); - * $( '#example' ).text( format( 'hello-user', username ) ); - * - * Tthis returns only strings, so it destroys any bindings. If you want to preserve bindings, use the - * jQuery plugin version instead. This was originally created to ease migration from `window.gM()`, - * from a time when the parser used by `mw.message` was not extendable. + * Fetch the message string associated with a key, return parsed structure. Memoized. + * Note that we pass '⧼' + key + '⧽' back for a missing message here. * - * N.B. replacements are variadic arguments or an array in second parameter. In other words: - * somefunction( a, b, c, d ) - * is equivalent to - * somefunction( a, [b, c, d] ) - * - * @param {Object} options parser options - * @return {Function} Function The message formatter - * @return {string} return.key Message key. - * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array). - * @return {string} return.return Rendered HTML. + * @param {string} key + * @param {Array} replacements Variable replacements for $1, $2... $n + * @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing */ - mw.jqueryMsg.getMessageFunction = function ( options ) { - var failableParserFn, format; - - if ( options && options.format !== undefined ) { - format = options.format; - } else { - format = parserDefaults.format; - } + getAst: function ( key, replacements ) { + var wikiText; - return function () { - var failableResult; - if ( !failableParserFn ) { - failableParserFn = getFailableParserFn( options ); - } - failableResult = failableParserFn( arguments ); - if ( format === 'text' || format === 'escaped' ) { - return failableResult.text(); + if ( !Object.prototype.hasOwnProperty.call( this.astCache, key ) ) { + if ( mw.config.get( 'wgUserLanguage' ) === 'qqx' ) { + wikiText = '(' + key + '$*)'; } else { - return failableResult.html(); + wikiText = this.settings.messages.get( key ); + if ( typeof wikiText !== 'string' ) { + wikiText = '⧼' + key + '⧽'; + } } - }; - }; + wikiText = mw.internalDoTransformFormatForQqx( wikiText, replacements ); + this.astCache[ key ] = this.wikiTextToAst( wikiText ); + } + return this.astCache[ key ]; + }, /** - * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to - * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links. - * e.g. - * - * $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options ); - * var $userlink = $( '' ).click( function () { alert( "hello!!" ) } ); - * $( 'p#headline' ).msg( 'hello-user', $userlink ); - * - * N.B. replacements are variadic arguments or an array in second parameter. In other words: - * somefunction( a, b, c, d ) - * is equivalent to - * somefunction( a, [b, c, d] ) + * Parses the input wikiText into an abstract syntax tree, essentially an s-expression. * - * We append to 'this', which in a jQuery plugin context will be the selected elements. - * - * @param {Object} options Parser options - * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg - * @return {string} return.key Message key. - * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array). - * @return {jQuery} return.return - */ - mw.jqueryMsg.getPlugin = function ( options ) { - var failableParserFn; - - return function () { - var $target; - if ( !failableParserFn ) { - failableParserFn = getFailableParserFn( options ); - } - $target = this.empty(); - appendWithoutParsing( $target, failableParserFn( arguments ) ); - return $target; - }; - }; - - /** - * The parser itself. - * Describes an object, whose primary duty is to .parse() message keys. + * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already. + * n.b. We want to move this functionality to the server. Nothing here is required to be on the client. * - * @class - * @private - * @param {Object} options + * @param {string} input Message string wikitext + * @throws Error + * @return {Mixed} abstract syntax tree */ - mw.jqueryMsg.Parser = function ( options ) { - this.settings = $.extend( {}, parserDefaults, options ); - this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' ); - this.astCache = {}; - - this.emitter = new mw.jqueryMsg.HtmlEmitter( this.settings.language, this.settings.magic ); - }; - // Backwards-compatible alias - // @deprecated since 1.31 - mw.jqueryMsg.parser = mw.jqueryMsg.Parser; + wikiTextToAst: function ( input ) { + var pos, + regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets, + doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral, + escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral, + whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue, + htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag, + openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon, + templateContents, openTemplate, closeTemplate, + nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result, + settings = this.settings, + concat = Array.prototype.concat; + + // Indicates current position in input as we parse through it. + // Shared among all parsing functions below. + pos = 0; + + // ========================================================= + // parsing combinators - could be a library on its own + // ========================================================= - mw.jqueryMsg.Parser.prototype = { /** - * Where the magic happens. - * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery - * If an error is thrown, returns original key, and logs the error + * Try parsers until one works, if none work return null * - * @param {string} key Message key. - * @param {Array} replacements Variable replacements for $1, $2... $n - * @return {jQuery} + * @private + * @param {Function[]} ps + * @return {string|null} */ - parse: function ( key, replacements ) { - var ast = this.getAst( key, replacements ); - return this.emitter.emit( ast, replacements ); - }, + function choice( ps ) { + return function () { + var i, result; + for ( i = 0; i < ps.length; i++ ) { + result = ps[ i ](); + if ( result !== null ) { + return result; + } + } + return null; + }; + } /** - * Fetch the message string associated with a key, return parsed structure. Memoized. - * Note that we pass '⧼' + key + '⧽' back for a missing message here. + * Try several ps in a row, all must succeed or return null. + * This is the only eager one. * - * @param {string} key - * @param {Array} replacements Variable replacements for $1, $2... $n - * @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing + * @private + * @param {Function[]} ps + * @return {string|null} */ - getAst: function ( key, replacements ) { - var wikiText; - - if ( !Object.prototype.hasOwnProperty.call( this.astCache, key ) ) { - if ( mw.config.get( 'wgUserLanguage' ) === 'qqx' ) { - wikiText = '(' + key + '$*)'; - } else { - wikiText = this.settings.messages.get( key ); - if ( typeof wikiText !== 'string' ) { - wikiText = '⧼' + key + '⧽'; - } + function sequence( ps ) { + var i, res, + originalPos = pos, + result = []; + for ( i = 0; i < ps.length; i++ ) { + res = ps[ i ](); + if ( res === null ) { + pos = originalPos; + return null; } - wikiText = mw.internalDoTransformFormatForQqx( wikiText, replacements ); - this.astCache[ key ] = this.wikiTextToAst( wikiText ); + result.push( res ); } - return this.astCache[ key ]; - }, + return result; + } /** - * Parses the input wikiText into an abstract syntax tree, essentially an s-expression. - * - * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already. - * n.b. We want to move this functionality to the server. Nothing here is required to be on the client. + * Run the same parser over and over until it fails. + * Must succeed a minimum of n times or return null. * - * @param {string} input Message string wikitext - * @throws Error - * @return {Mixed} abstract syntax tree + * @private + * @param {number} n + * @param {Function} p + * @return {string|null} */ - wikiTextToAst: function ( input ) { - var pos, - regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets, - doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral, - escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral, - whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue, - htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag, - openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon, - templateContents, openTemplate, closeTemplate, - nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result, - settings = this.settings, - concat = Array.prototype.concat; - - // Indicates current position in input as we parse through it. - // Shared among all parsing functions below. - pos = 0; - - // ========================================================= - // parsing combinators - could be a library on its own - // ========================================================= - - /** - * Try parsers until one works, if none work return null - * - * @private - * @param {Function[]} ps - * @return {string|null} - */ - function choice( ps ) { - return function () { - var i, result; - for ( i = 0; i < ps.length; i++ ) { - result = ps[ i ](); - if ( result !== null ) { - return result; - } - } + function nOrMore( n, p ) { + return function () { + var originalPos = pos, + result = [], + parsed = p(); + while ( parsed !== null ) { + result.push( parsed ); + parsed = p(); + } + if ( result.length < n ) { + pos = originalPos; return null; - }; - } - - /** - * Try several ps in a row, all must succeed or return null. - * This is the only eager one. - * - * @private - * @param {Function[]} ps - * @return {string|null} - */ - function sequence( ps ) { - var i, res, - originalPos = pos, - result = []; - for ( i = 0; i < ps.length; i++ ) { - res = ps[ i ](); - if ( res === null ) { - pos = originalPos; - return null; - } - result.push( res ); } return result; - } - - /** - * Run the same parser over and over until it fails. - * Must succeed a minimum of n times or return null. - * - * @private - * @param {number} n - * @param {Function} p - * @return {string|null} - */ - function nOrMore( n, p ) { - return function () { - var originalPos = pos, - result = [], - parsed = p(); - while ( parsed !== null ) { - result.push( parsed ); - parsed = p(); - } - if ( result.length < n ) { - pos = originalPos; - return null; - } - return result; - }; - } + }; + } - /** - * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null. - * - * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore(). - * May be some scoping issue - * - * @private - * @param {Function} p - * @param {Function} fn - * @return {string|null} - */ - function transform( p, fn ) { - return function () { - var result = p(); - return result === null ? null : fn( result ); - }; - } + /** + * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null. + * + * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore(). + * May be some scoping issue + * + * @private + * @param {Function} p + * @param {Function} fn + * @return {string|null} + */ + function transform( p, fn ) { + return function () { + var result = p(); + return result === null ? null : fn( result ); + }; + } - /** - * Just make parsers out of simpler JS builtin types - * - * @private - * @param {string} s - * @return {Function} - * @return {string} return.return - */ - function makeStringParser( s ) { - var len = s.length; - return function () { - var result = null; - if ( input.substr( pos, len ) === s ) { - result = s; - pos += len; - } - return result; - }; - } + /** + * Just make parsers out of simpler JS builtin types + * + * @private + * @param {string} s + * @return {Function} + * @return {string} return.return + */ + function makeStringParser( s ) { + var len = s.length; + return function () { + var result = null; + if ( input.substr( pos, len ) === s ) { + result = s; + pos += len; + } + return result; + }; + } - /** - * Makes a regex parser, given a RegExp object. - * The regex being passed in should start with a ^ to anchor it to the start - * of the string. - * - * @private - * @param {RegExp} regex anchored regex - * @return {Function} function to parse input based on the regex - */ - function makeRegexParser( regex ) { - return function () { - var matches = input.slice( pos ).match( regex ); - if ( matches === null ) { - return null; - } - pos += matches[ 0 ].length; - return matches[ 0 ]; - }; - } + /** + * Makes a regex parser, given a RegExp object. + * The regex being passed in should start with a ^ to anchor it to the start + * of the string. + * + * @private + * @param {RegExp} regex anchored regex + * @return {Function} function to parse input based on the regex + */ + function makeRegexParser( regex ) { + return function () { + var matches = input.slice( pos ).match( regex ); + if ( matches === null ) { + return null; + } + pos += matches[ 0 ].length; + return matches[ 0 ]; + }; + } - // =================================================================== - // General patterns above this line -- wikitext specific parsers below - // =================================================================== - - // Parsing functions follow. All parsing functions work like this: - // They don't accept any arguments. - // Instead, they just operate non destructively on the string 'input' - // As they can consume parts of the string, they advance the shared variable pos, - // and return tokens (or whatever else they want to return). - // some things are defined as closures and other things as ordinary functions - // converting everything to a closure makes it a lot harder to debug... errors pop up - // but some debuggers can't tell you exactly where they come from. Also the mutually - // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF) - // This may be because, to save code, memoization was removed - - /* eslint-disable no-useless-escape */ - regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ ); - regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ ); - regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ ); - regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ ); - /* eslint-enable no-useless-escape */ - - backslash = makeStringParser( '\\' ); - doubleQuote = makeStringParser( '"' ); - singleQuote = makeStringParser( '\'' ); - anyCharacter = makeRegexParser( /^./ ); - - openHtmlStartTag = makeStringParser( '<' ); - optionalForwardSlash = makeRegexParser( /^\/?/ ); - openHtmlEndTag = makeStringParser( '/ ); - - function escapedLiteral() { - var result = sequence( [ - backslash, - anyCharacter - ] ); - return result === null ? null : result[ 1 ]; - } - escapedOrLiteralWithoutSpace = choice( [ - escapedLiteral, - regularLiteralWithoutSpace - ] ); - escapedOrLiteralWithoutBar = choice( [ - escapedLiteral, - regularLiteralWithoutBar - ] ); - escapedOrRegularLiteral = choice( [ - escapedLiteral, - regularLiteral + // =================================================================== + // General patterns above this line -- wikitext specific parsers below + // =================================================================== + + // Parsing functions follow. All parsing functions work like this: + // They don't accept any arguments. + // Instead, they just operate non destructively on the string 'input' + // As they can consume parts of the string, they advance the shared variable pos, + // and return tokens (or whatever else they want to return). + // some things are defined as closures and other things as ordinary functions + // converting everything to a closure makes it a lot harder to debug... errors pop up + // but some debuggers can't tell you exactly where they come from. Also the mutually + // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF) + // This may be because, to save code, memoization was removed + + /* eslint-disable no-useless-escape */ + regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ ); + regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ ); + regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ ); + regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ ); + /* eslint-enable no-useless-escape */ + + backslash = makeStringParser( '\\' ); + doubleQuote = makeStringParser( '"' ); + singleQuote = makeStringParser( '\'' ); + anyCharacter = makeRegexParser( /^./ ); + + openHtmlStartTag = makeStringParser( '<' ); + optionalForwardSlash = makeRegexParser( /^\/?/ ); + openHtmlEndTag = makeStringParser( '/ ); + + function escapedLiteral() { + var result = sequence( [ + backslash, + anyCharacter ] ); - // Used to define "literals" without spaces, in space-delimited situations - function literalWithoutSpace() { - var result = nOrMore( 1, escapedOrLiteralWithoutSpace )(); - return result === null ? null : result.join( '' ); - } - // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default - // it is not a literal in the parameter - function literalWithoutBar() { - var result = nOrMore( 1, escapedOrLiteralWithoutBar )(); - return result === null ? null : result.join( '' ); - } + return result === null ? null : result[ 1 ]; + } + escapedOrLiteralWithoutSpace = choice( [ + escapedLiteral, + regularLiteralWithoutSpace + ] ); + escapedOrLiteralWithoutBar = choice( [ + escapedLiteral, + regularLiteralWithoutBar + ] ); + escapedOrRegularLiteral = choice( [ + escapedLiteral, + regularLiteral + ] ); + // Used to define "literals" without spaces, in space-delimited situations + function literalWithoutSpace() { + var result = nOrMore( 1, escapedOrLiteralWithoutSpace )(); + return result === null ? null : result.join( '' ); + } + // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default + // it is not a literal in the parameter + function literalWithoutBar() { + var result = nOrMore( 1, escapedOrLiteralWithoutBar )(); + return result === null ? null : result.join( '' ); + } - function literal() { - var result = nOrMore( 1, escapedOrRegularLiteral )(); - return result === null ? null : result.join( '' ); - } + function literal() { + var result = nOrMore( 1, escapedOrRegularLiteral )(); + return result === null ? null : result.join( '' ); + } - function curlyBraceTransformExpressionLiteral() { - var result = nOrMore( 1, regularLiteralWithSquareBrackets )(); - return result === null ? null : result.join( '' ); - } + function curlyBraceTransformExpressionLiteral() { + var result = nOrMore( 1, regularLiteralWithSquareBrackets )(); + return result === null ? null : result.join( '' ); + } - asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ ); - htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ ); - htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ ); + asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ ); + htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ ); + htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ ); - whitespace = makeRegexParser( /^\s+/ ); - dollar = makeStringParser( '$' ); - digits = makeRegexParser( /^\d+/ ); + whitespace = makeRegexParser( /^\s+/ ); + dollar = makeStringParser( '$' ); + digits = makeRegexParser( /^\d+/ ); - function replacement() { - var result = sequence( [ - dollar, - digits - ] ); - if ( result === null ) { - return null; - } - return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ]; - } - openExtlink = makeStringParser( '[' ); - closeExtlink = makeStringParser( ']' ); - // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo bar], etc. are allowed - function extlink() { - var result, parsedResult, target; - result = null; - parsedResult = sequence( [ - openExtlink, - nOrMore( 1, nonWhitespaceExpression ), - whitespace, - nOrMore( 1, expression ), - closeExtlink - ] ); - if ( parsedResult !== null ) { - // When the entire link target is a single parameter, we can't use CONCAT, as we allow - // passing fancy parameters (like a whole jQuery object or a function) to use for the - // link. Check only if it's a single match, since we can either do CONCAT or not for - // singles with the same effect. - target = parsedResult[ 1 ].length === 1 ? - parsedResult[ 1 ][ 0 ] : - [ 'CONCAT' ].concat( parsedResult[ 1 ] ); - result = [ - 'EXTLINK', - target, - [ 'CONCAT' ].concat( parsedResult[ 3 ] ) - ]; - } - return result; - } - openWikilink = makeStringParser( '[[' ); - closeWikilink = makeStringParser( ']]' ); - pipe = makeStringParser( '|' ); - - function template() { - var result = sequence( [ - openTemplate, - templateContents, - closeTemplate - ] ); - return result === null ? null : result[ 1 ]; + function replacement() { + var result = sequence( [ + dollar, + digits + ] ); + if ( result === null ) { + return null; } - - function pipedWikilink() { - var result = sequence( [ - nOrMore( 1, paramExpression ), - pipe, - nOrMore( 1, expression ) - ] ); - return result === null ? null : [ - [ 'CONCAT' ].concat( result[ 0 ] ), - [ 'CONCAT' ].concat( result[ 2 ] ) + return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ]; + } + openExtlink = makeStringParser( '[' ); + closeExtlink = makeStringParser( ']' ); + // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo bar], etc. are allowed + function extlink() { + var result, parsedResult, target; + result = null; + parsedResult = sequence( [ + openExtlink, + nOrMore( 1, nonWhitespaceExpression ), + whitespace, + nOrMore( 1, expression ), + closeExtlink + ] ); + if ( parsedResult !== null ) { + // When the entire link target is a single parameter, we can't use CONCAT, as we allow + // passing fancy parameters (like a whole jQuery object or a function) to use for the + // link. Check only if it's a single match, since we can either do CONCAT or not for + // singles with the same effect. + target = parsedResult[ 1 ].length === 1 ? + parsedResult[ 1 ][ 0 ] : + [ 'CONCAT' ].concat( parsedResult[ 1 ] ); + result = [ + 'EXTLINK', + target, + [ 'CONCAT' ].concat( parsedResult[ 3 ] ) ]; } + return result; + } + openWikilink = makeStringParser( '[[' ); + closeWikilink = makeStringParser( ']]' ); + pipe = makeStringParser( '|' ); + + function template() { + var result = sequence( [ + openTemplate, + templateContents, + closeTemplate + ] ); + return result === null ? null : result[ 1 ]; + } - function unpipedWikilink() { - var result = sequence( [ - nOrMore( 1, paramExpression ) - ] ); - return result === null ? null : [ - [ 'CONCAT' ].concat( result[ 0 ] ) - ]; - } + function pipedWikilink() { + var result = sequence( [ + nOrMore( 1, paramExpression ), + pipe, + nOrMore( 1, expression ) + ] ); + return result === null ? null : [ + [ 'CONCAT' ].concat( result[ 0 ] ), + [ 'CONCAT' ].concat( result[ 2 ] ) + ]; + } - wikilinkContents = choice( [ - pipedWikilink, - unpipedWikilink + function unpipedWikilink() { + var result = sequence( [ + nOrMore( 1, paramExpression ) ] ); + return result === null ? null : [ + [ 'CONCAT' ].concat( result[ 0 ] ) + ]; + } - function wikilink() { - var result, parsedResult, parsedLinkContents; - result = null; + wikilinkContents = choice( [ + pipedWikilink, + unpipedWikilink + ] ); - parsedResult = sequence( [ - openWikilink, - wikilinkContents, - closeWikilink - ] ); - if ( parsedResult !== null ) { - parsedLinkContents = parsedResult[ 1 ]; - result = [ 'WIKILINK' ].concat( parsedLinkContents ); - } - return result; - } + function wikilink() { + var result, parsedResult, parsedLinkContents; + result = null; - // TODO: Support data- if appropriate - function doubleQuotedHtmlAttributeValue() { - var parsedResult = sequence( [ - doubleQuote, - htmlDoubleQuoteAttributeValue, - doubleQuote - ] ); - return parsedResult === null ? null : parsedResult[ 1 ]; + parsedResult = sequence( [ + openWikilink, + wikilinkContents, + closeWikilink + ] ); + if ( parsedResult !== null ) { + parsedLinkContents = parsedResult[ 1 ]; + result = [ 'WIKILINK' ].concat( parsedLinkContents ); } + return result; + } - function singleQuotedHtmlAttributeValue() { - var parsedResult = sequence( [ - singleQuote, - htmlSingleQuoteAttributeValue, - singleQuote - ] ); - return parsedResult === null ? null : parsedResult[ 1 ]; - } + // TODO: Support data- if appropriate + function doubleQuotedHtmlAttributeValue() { + var parsedResult = sequence( [ + doubleQuote, + htmlDoubleQuoteAttributeValue, + doubleQuote + ] ); + return parsedResult === null ? null : parsedResult[ 1 ]; + } - function htmlAttribute() { - var parsedResult = sequence( [ - whitespace, - asciiAlphabetLiteral, - htmlAttributeEquals, - choice( [ - doubleQuotedHtmlAttributeValue, - singleQuotedHtmlAttributeValue - ] ) - ] ); - return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ]; - } + function singleQuotedHtmlAttributeValue() { + var parsedResult = sequence( [ + singleQuote, + htmlSingleQuoteAttributeValue, + singleQuote + ] ); + return parsedResult === null ? null : parsedResult[ 1 ]; + } - /** - * Checks if HTML is allowed - * - * @param {string} startTagName HTML start tag name - * @param {string} endTagName HTML start tag name - * @param {Object} attributes array of consecutive key value pairs, - * with index 2 * n being a name and 2 * n + 1 the associated value - * @return {boolean} true if this is HTML is allowed, false otherwise - */ - function isAllowedHtml( startTagName, endTagName, attributes ) { - var i, len, attributeName; - - startTagName = startTagName.toLowerCase(); - endTagName = endTagName.toLowerCase(); - if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) { - return false; - } + function htmlAttribute() { + var parsedResult = sequence( [ + whitespace, + asciiAlphabetLiteral, + htmlAttributeEquals, + choice( [ + doubleQuotedHtmlAttributeValue, + singleQuotedHtmlAttributeValue + ] ) + ] ); + return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ]; + } - for ( i = 0, len = attributes.length; i < len; i += 2 ) { - attributeName = attributes[ i ]; - if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 && - ( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) { - return false; - } - } + /** + * Checks if HTML is allowed + * + * @param {string} startTagName HTML start tag name + * @param {string} endTagName HTML start tag name + * @param {Object} attributes array of consecutive key value pairs, + * with index 2 * n being a name and 2 * n + 1 the associated value + * @return {boolean} true if this is HTML is allowed, false otherwise + */ + function isAllowedHtml( startTagName, endTagName, attributes ) { + var i, len, attributeName; - return true; + startTagName = startTagName.toLowerCase(); + endTagName = endTagName.toLowerCase(); + if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) { + return false; } - function htmlAttributes() { - var parsedResult = nOrMore( 0, htmlAttribute )(); - // Un-nest attributes array due to structure of jQueryMsg operations (see emit). - return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult ); + for ( i = 0, len = attributes.length; i < len; i += 2 ) { + attributeName = attributes[ i ]; + if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 && + ( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) { + return false; + } } - // Subset of allowed HTML markup. - // Most elements and many attributes allowed on the server are not supported yet. - function html() { - var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult, - wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos, - startCloseTagPos, endOpenTagPos, endCloseTagPos, - result = null; - - // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match. - // 1. open through closeHtmlTag - // 2. expression - // 3. openHtmlEnd through close - // This will allow recording the positions to reconstruct if HTML is to be treated as text. - - startOpenTagPos = pos; - parsedOpenTagResult = sequence( [ - openHtmlStartTag, - asciiAlphabetLiteral, - htmlAttributes, - optionalForwardSlash, - closeHtmlTag - ] ); + return true; + } - if ( parsedOpenTagResult === null ) { - return null; - } + function htmlAttributes() { + var parsedResult = nOrMore( 0, htmlAttribute )(); + // Un-nest attributes array due to structure of jQueryMsg operations (see emit). + return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult ); + } - endOpenTagPos = pos; - startTagName = parsedOpenTagResult[ 1 ]; + // Subset of allowed HTML markup. + // Most elements and many attributes allowed on the server are not supported yet. + function html() { + var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult, + wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos, + startCloseTagPos, endOpenTagPos, endCloseTagPos, + result = null; - parsedHtmlContents = nOrMore( 0, expression )(); + // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match. + // 1. open through closeHtmlTag + // 2. expression + // 3. openHtmlEnd through close + // This will allow recording the positions to reconstruct if HTML is to be treated as text. + + startOpenTagPos = pos; + parsedOpenTagResult = sequence( [ + openHtmlStartTag, + asciiAlphabetLiteral, + htmlAttributes, + optionalForwardSlash, + closeHtmlTag + ] ); - startCloseTagPos = pos; - parsedCloseTagResult = sequence( [ - openHtmlEndTag, - asciiAlphabetLiteral, - closeHtmlTag - ] ); + if ( parsedOpenTagResult === null ) { + return null; + } - if ( parsedCloseTagResult === null ) { - // Closing tag failed. Return the start tag and contents. - return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ] - .concat( parsedHtmlContents ); - } + endOpenTagPos = pos; + startTagName = parsedOpenTagResult[ 1 ]; - endCloseTagPos = pos; - endTagName = parsedCloseTagResult[ 1 ]; - wrappedAttributes = parsedOpenTagResult[ 2 ]; - attributes = wrappedAttributes.slice( 1 ); - if ( isAllowedHtml( startTagName, endTagName, attributes ) ) { - result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ] - .concat( parsedHtmlContents ); - } else { - // HTML is not allowed, so contents will remain how - // it was, while HTML markup at this level will be - // treated as text - // E.g. assuming script tags are not allowed: - // - // - // - // results in '<script>' and '</script>' - // (not treated as an HTML tag), surrounding a fully - // parsed HTML link. - // - // Concatenate everything from the tag, flattening the contents. - result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ] - .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) ); - } + parsedHtmlContents = nOrMore( 0, expression )(); - return result; + startCloseTagPos = pos; + parsedCloseTagResult = sequence( [ + openHtmlEndTag, + asciiAlphabetLiteral, + closeHtmlTag + ] ); + + if ( parsedCloseTagResult === null ) { + // Closing tag failed. Return the start tag and contents. + return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ] + .concat( parsedHtmlContents ); } - // ... tag. The tags are stripped and the contents are returned unparsed. - function nowiki() { - var parsedResult, plainText, - result = null; + endCloseTagPos = pos; + endTagName = parsedCloseTagResult[ 1 ]; + wrappedAttributes = parsedOpenTagResult[ 2 ]; + attributes = wrappedAttributes.slice( 1 ); + if ( isAllowedHtml( startTagName, endTagName, attributes ) ) { + result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ] + .concat( parsedHtmlContents ); + } else { + // HTML is not allowed, so contents will remain how + // it was, while HTML markup at this level will be + // treated as text + // E.g. assuming script tags are not allowed: + // + // + // + // results in '<script>' and '</script>' + // (not treated as an HTML tag), surrounding a fully + // parsed HTML link. + // + // Concatenate everything from the tag, flattening the contents. + result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ] + .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) ); + } - parsedResult = sequence( [ - makeStringParser( '' ), - // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much - makeRegexParser( /^.*?(?=<\/nowiki>)/ ), - makeStringParser( '' ) - ] ); - if ( parsedResult !== null ) { - plainText = parsedResult[ 1 ]; - result = [ 'CONCAT' ].concat( plainText ); - } + return result; + } - return result; + // ... tag. The tags are stripped and the contents are returned unparsed. + function nowiki() { + var parsedResult, plainText, + result = null; + + parsedResult = sequence( [ + makeStringParser( '' ), + // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much + makeRegexParser( /^.*?(?=<\/nowiki>)/ ), + makeStringParser( '' ) + ] ); + if ( parsedResult !== null ) { + plainText = parsedResult[ 1 ]; + result = [ 'CONCAT' ].concat( plainText ); } - templateName = transform( - // see $wgLegalTitleChars - // not allowing : due to the need to catch "PLURAL:$1" - makeRegexParser( /^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ ), - function ( result ) { return result.toString(); } - ); - function templateParam() { - var expr, result; - result = sequence( [ - pipe, - nOrMore( 0, paramExpression ) - ] ); - if ( result === null ) { - return null; - } - expr = result[ 1 ]; - // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw. - return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ]; + return result; + } + + templateName = transform( + // see $wgLegalTitleChars + // not allowing : due to the need to catch "PLURAL:$1" + makeRegexParser( /^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ ), + function ( result ) { return result.toString(); } + ); + function templateParam() { + var expr, result; + result = sequence( [ + pipe, + nOrMore( 0, paramExpression ) + ] ); + if ( result === null ) { + return null; } + expr = result[ 1 ]; + // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw. + return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ]; + } - function templateWithReplacement() { - var result = sequence( [ - templateName, - colon, - replacement + function templateWithReplacement() { + var result = sequence( [ + templateName, + colon, + replacement + ] ); + return result === null ? null : [ result[ 0 ], result[ 2 ] ]; + } + function templateWithOutReplacement() { + var result = sequence( [ + templateName, + colon, + paramExpression + ] ); + return result === null ? null : [ result[ 0 ], result[ 2 ] ]; + } + function templateWithOutFirstParameter() { + var result = sequence( [ + templateName, + colon + ] ); + return result === null ? null : [ result[ 0 ], '' ]; + } + colon = makeStringParser( ':' ); + templateContents = choice( [ + function () { + var res = sequence( [ + // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}} + // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}} + choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ), + nOrMore( 0, templateParam ) ] ); - return result === null ? null : [ result[ 0 ], result[ 2 ] ]; - } - function templateWithOutReplacement() { - var result = sequence( [ + return res === null ? null : res[ 0 ].concat( res[ 1 ] ); + }, + function () { + var res = sequence( [ templateName, - colon, - paramExpression + nOrMore( 0, templateParam ) ] ); - return result === null ? null : [ result[ 0 ], result[ 2 ] ]; + if ( res === null ) { + return null; + } + return [ res[ 0 ] ].concat( res[ 1 ] ); } - function templateWithOutFirstParameter() { - var result = sequence( [ - templateName, - colon - ] ); - return result === null ? null : [ result[ 0 ], '' ]; + ] ); + openTemplate = makeStringParser( '{{' ); + closeTemplate = makeStringParser( '}}' ); + nonWhitespaceExpression = choice( [ + template, + wikilink, + extlink, + replacement, + literalWithoutSpace + ] ); + paramExpression = choice( [ + template, + wikilink, + extlink, + replacement, + literalWithoutBar + ] ); + + expression = choice( [ + template, + wikilink, + extlink, + replacement, + nowiki, + html, + literal + ] ); + + // Used when only {{-transformation is wanted, for 'text' + // or 'escaped' formats + curlyBraceTransformExpression = choice( [ + template, + replacement, + curlyBraceTransformExpressionLiteral + ] ); + + /** + * Starts the parse + * + * @param {Function} rootExpression Root parse function + * @return {Array|null} + */ + function start( rootExpression ) { + var result = nOrMore( 0, rootExpression )(); + if ( result === null ) { + return null; } - colon = makeStringParser( ':' ); - templateContents = choice( [ - function () { - var res = sequence( [ - // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}} - // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}} - choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ), - nOrMore( 0, templateParam ) - ] ); - return res === null ? null : res[ 0 ].concat( res[ 1 ] ); - }, - function () { - var res = sequence( [ - templateName, - nOrMore( 0, templateParam ) - ] ); - if ( res === null ) { - return null; - } - return [ res[ 0 ] ].concat( res[ 1 ] ); - } - ] ); - openTemplate = makeStringParser( '{{' ); - closeTemplate = makeStringParser( '}}' ); - nonWhitespaceExpression = choice( [ - template, - wikilink, - extlink, - replacement, - literalWithoutSpace - ] ); - paramExpression = choice( [ - template, - wikilink, - extlink, - replacement, - literalWithoutBar - ] ); + return [ 'CONCAT' ].concat( result ); + } + // everything above this point is supposed to be stateless/static, but + // I am deferring the work of turning it into prototypes & objects. It's quite fast enough + // finally let's do some actual work... - expression = choice( [ - template, - wikilink, - extlink, - replacement, - nowiki, - html, - literal - ] ); + result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression ); - // Used when only {{-transformation is wanted, for 'text' - // or 'escaped' formats - curlyBraceTransformExpression = choice( [ - template, - replacement, - curlyBraceTransformExpressionLiteral - ] ); + /* + * For success, the p must have gotten to the end of the input + * and returned a non-null. + * n.b. This is part of language infrastructure, so we do not throw an internationalizable message. + */ + if ( result === null || pos !== input.length ) { + throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input ); + } + return result; + } - /** - * Starts the parse - * - * @param {Function} rootExpression Root parse function - * @return {Array|null} - */ - function start( rootExpression ) { - var result = nOrMore( 0, rootExpression )(); - if ( result === null ) { - return null; +}; + +/** + * Class that primarily exists to emit HTML from parser ASTs. + * + * @private + * @class + * @param {Object} language + * @param {Object} magic + */ +mw.jqueryMsg.HtmlEmitter = function ( language, magic ) { + var jmsg = this; + this.language = language; + // eslint-disable-next-line no-jquery/no-each-util + $.each( magic, function ( key, val ) { + jmsg[ key.toLowerCase() ] = function () { + return val; + }; + } ); + + /** + * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.) + * Walk entire node structure, applying replacements and template functions when appropriate + * + * @param {Mixed} node Abstract syntax tree (top node or subnode) + * @param {Array} replacements for $1, $2, ... $n + * @return {Mixed} single-string node or array of nodes suitable for jQuery appending + */ + this.emit = function ( node, replacements ) { + var ret, subnodes, operation, + jmsg = this; + switch ( typeof node ) { + case 'string': + case 'number': + ret = node; + break; + // typeof returns object for arrays + case 'object': + // node is an array of nodes + // eslint-disable-next-line no-jquery/no-map-util + subnodes = $.map( node.slice( 1 ), function ( n ) { + return jmsg.emit( n, replacements ); + } ); + operation = node[ 0 ].toLowerCase(); + if ( typeof jmsg[ operation ] === 'function' ) { + ret = jmsg[ operation ]( subnodes, replacements ); + } else { + throw new Error( 'Unknown operation "' + operation + '"' ); } - return [ 'CONCAT' ].concat( result ); - } - // everything above this point is supposed to be stateless/static, but - // I am deferring the work of turning it into prototypes & objects. It's quite fast enough - // finally let's do some actual work... - - result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression ); - - /* - * For success, the p must have gotten to the end of the input - * and returned a non-null. - * n.b. This is part of language infrastructure, so we do not throw an internationalizable message. - */ - if ( result === null || pos !== input.length ) { - throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input ); - } - return result; + break; + case 'undefined': + // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined + // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information? + // The logical thing is probably to return the empty string here when we encounter undefined. + ret = ''; + break; + default: + throw new Error( 'Unexpected type in AST: ' + typeof node ); } - + return ret; }; - +}; + +// For everything in input that follows double-open-curly braces, there should be an equivalent parser +// function. For instance {{PLURAL ... }} will be processed by 'plural'. +// If you have 'magic words' then configure the parser to have them upon creation. +// +// An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to). +// Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on) +mw.jqueryMsg.HtmlEmitter.prototype = { /** - * Class that primarily exists to emit HTML from parser ASTs. + * Parsing has been applied depth-first we can assume that all nodes here are single nodes + * Must return a single node to parents -- a jQuery with synthetic span + * However, unwrap any other synthetic spans in our children and pass them upwards * - * @private - * @class - * @param {Object} language - * @param {Object} magic + * @param {Mixed[]} nodes Some single nodes, some arrays of nodes + * @return {jQuery} */ - mw.jqueryMsg.HtmlEmitter = function ( language, magic ) { - var jmsg = this; - this.language = language; + concat: function ( nodes ) { + var $span = $( '' ).addClass( 'mediaWiki_htmlEmitter' ); // eslint-disable-next-line no-jquery/no-each-util - $.each( magic, function ( key, val ) { - jmsg[ key.toLowerCase() ] = function () { - return val; - }; + $.each( nodes, function ( i, node ) { + // Let jQuery append nodes, arrays of nodes and jQuery objects + // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings) + appendWithoutParsing( $span, node ); } ); + return $span; + }, - /** - * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.) - * Walk entire node structure, applying replacements and template functions when appropriate - * - * @param {Mixed} node Abstract syntax tree (top node or subnode) - * @param {Array} replacements for $1, $2, ... $n - * @return {Mixed} single-string node or array of nodes suitable for jQuery appending - */ - this.emit = function ( node, replacements ) { - var ret, subnodes, operation, - jmsg = this; - switch ( typeof node ) { - case 'string': - case 'number': - ret = node; - break; - // typeof returns object for arrays - case 'object': - // node is an array of nodes - // eslint-disable-next-line no-jquery/no-map-util - subnodes = $.map( node.slice( 1 ), function ( n ) { - return jmsg.emit( n, replacements ); - } ); - operation = node[ 0 ].toLowerCase(); - if ( typeof jmsg[ operation ] === 'function' ) { - ret = jmsg[ operation ]( subnodes, replacements ); - } else { - throw new Error( 'Unknown operation "' + operation + '"' ); - } - break; - case 'undefined': - // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined - // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information? - // The logical thing is probably to return the empty string here when we encounter undefined. - ret = ''; - break; - default: - throw new Error( 'Unexpected type in AST: ' + typeof node ); - } - return ret; - }; - }; - - // For everything in input that follows double-open-curly braces, there should be an equivalent parser - // function. For instance {{PLURAL ... }} will be processed by 'plural'. - // If you have 'magic words' then configure the parser to have them upon creation. - // - // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to). - // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on) - mw.jqueryMsg.HtmlEmitter.prototype = { - /** - * Parsing has been applied depth-first we can assume that all nodes here are single nodes - * Must return a single node to parents -- a jQuery with synthetic span - * However, unwrap any other synthetic spans in our children and pass them upwards - * - * @param {Mixed[]} nodes Some single nodes, some arrays of nodes - * @return {jQuery} - */ - concat: function ( nodes ) { - var $span = $( '' ).addClass( 'mediaWiki_htmlEmitter' ); - // eslint-disable-next-line no-jquery/no-each-util - $.each( nodes, function ( i, node ) { - // Let jQuery append nodes, arrays of nodes and jQuery objects - // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings) - appendWithoutParsing( $span, node ); - } ); - return $span; - }, + /** + * Return escaped replacement of correct index, or string if unavailable. + * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ]. + * if the specified parameter is not found return the same string + * (e.g. "$99" -> parameter 98 -> not found -> return "$99" ) + * + * TODO: Throw error if nodes.length > 1 ? + * + * @param {Array} nodes List of one element, integer, n >= 0 + * @param {Array} replacements List of at least n strings + * @return {string|jQuery} replacement + */ + replace: function ( nodes, replacements ) { + var index = parseInt( nodes[ 0 ], 10 ); - /** - * Return escaped replacement of correct index, or string if unavailable. - * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ]. - * if the specified parameter is not found return the same string - * (e.g. "$99" -> parameter 98 -> not found -> return "$99" ) - * - * TODO: Throw error if nodes.length > 1 ? - * - * @param {Array} nodes List of one element, integer, n >= 0 - * @param {Array} replacements List of at least n strings - * @return {string|jQuery} replacement - */ - replace: function ( nodes, replacements ) { - var index = parseInt( nodes[ 0 ], 10 ); + if ( index < replacements.length ) { + return replacements[ index ]; + } else { + // index not found, fallback to displaying variable + return '$' + ( index + 1 ); + } + }, - if ( index < replacements.length ) { - return replacements[ index ]; - } else { - // index not found, fallback to displaying variable - return '$' + ( index + 1 ); - } - }, + /** + * Transform wiki-link + * + * TODO: + * It only handles basic cases, either no pipe, or a pipe with an explicit + * anchor. + * + * It does not attempt to handle features like the pipe trick. + * However, the pipe trick should usually not be present in wikitext retrieved + * from the server, since the replacement is done at save time. + * It may, though, if the wikitext appears in extension-controlled content. + * + * @param {string[]} nodes + * @return {jQuery} + */ + wikilink: function ( nodes ) { + var page, anchor, url, $el; + + page = textify( nodes[ 0 ] ); + // Strip leading ':', which is used to suppress special behavior in wikitext links, + // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]] + if ( page.charAt( 0 ) === ':' ) { + page = page.slice( 1 ); + } + url = mw.util.getUrl( page ); - /** - * Transform wiki-link - * - * TODO: - * It only handles basic cases, either no pipe, or a pipe with an explicit - * anchor. - * - * It does not attempt to handle features like the pipe trick. - * However, the pipe trick should usually not be present in wikitext retrieved - * from the server, since the replacement is done at save time. - * It may, though, if the wikitext appears in extension-controlled content. - * - * @param {string[]} nodes - * @return {jQuery} - */ - wikilink: function ( nodes ) { - var page, anchor, url, $el; - - page = textify( nodes[ 0 ] ); - // Strip leading ':', which is used to suppress special behavior in wikitext links, - // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]] - if ( page.charAt( 0 ) === ':' ) { - page = page.slice( 1 ); - } - url = mw.util.getUrl( page ); + if ( nodes.length === 1 ) { + // [[Some Page]] or [[Namespace:Some Page]] + anchor = page; + } else { + // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]] + anchor = nodes[ 1 ]; + } - if ( nodes.length === 1 ) { - // [[Some Page]] or [[Namespace:Some Page]] - anchor = page; - } else { - // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]] - anchor = nodes[ 1 ]; - } + $el = $( '' ).attr( { + title: page, + href: url + } ); + return appendWithoutParsing( $el, anchor ); + }, - $el = $( '' ).attr( { - title: page, - href: url - } ); - return appendWithoutParsing( $el, anchor ); - }, + /** + * Converts array of HTML element key value pairs to object + * + * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a + * name and 2 * n + 1 the associated value + * @return {Object} Object mapping attribute name to attribute value + */ + htmlattributes: function ( nodes ) { + var i, len, mapping = {}; + for ( i = 0, len = nodes.length; i < len; i += 2 ) { + mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] ); + } + return mapping; + }, - /** - * Converts array of HTML element key value pairs to object - * - * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a - * name and 2 * n + 1 the associated value - * @return {Object} Object mapping attribute name to attribute value - */ - htmlattributes: function ( nodes ) { - var i, len, mapping = {}; - for ( i = 0, len = nodes.length; i < len; i += 2 ) { - mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] ); - } - return mapping; - }, + /** + * Handles an (already-validated) HTML element. + * + * @param {Array} nodes Nodes to process when creating element + * @return {jQuery} + */ + htmlelement: function ( nodes ) { + var tagName, attributes, contents, $element; - /** - * Handles an (already-validated) HTML element. - * - * @param {Array} nodes Nodes to process when creating element - * @return {jQuery} - */ - htmlelement: function ( nodes ) { - var tagName, attributes, contents, $element; - - tagName = nodes.shift(); - attributes = nodes.shift(); - contents = nodes; - $element = $( document.createElement( tagName ) ).attr( attributes ); - return appendWithoutParsing( $element, contents ); - }, + tagName = nodes.shift(); + attributes = nodes.shift(); + contents = nodes; + $element = $( document.createElement( tagName ) ).attr( attributes ); + return appendWithoutParsing( $element, contents ); + }, - /** - * Transform parsed structure into external link. - * - * The "href" can be: - * - a jQuery object, treat it as "enclosing" the link text. - * - a function, treat it as the click handler. - * - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying. - * - * TODO: throw an error if nodes.length > 2 ? - * - * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string} - * @return {jQuery} - */ - extlink: function ( nodes ) { - var $el, - arg = nodes[ 0 ], - contents = nodes[ 1 ]; - if ( arg instanceof $ && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) { - $el = arg; + /** + * Transform parsed structure into external link. + * + * The "href" can be: + * - a jQuery object, treat it as "enclosing" the link text. + * - a function, treat it as the click handler. + * - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying. + * + * TODO: throw an error if nodes.length > 2 ? + * + * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string} + * @return {jQuery} + */ + extlink: function ( nodes ) { + var $el, + arg = nodes[ 0 ], + contents = nodes[ 1 ]; + if ( arg instanceof $ && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) { + $el = arg; + } else { + $el = $( '' ); + if ( typeof arg === 'function' ) { + $el.attr( { + role: 'button', + tabindex: 0 + } ).on( 'click keypress', function ( e ) { + if ( + e.type === 'click' || + e.type === 'keypress' && e.which === 13 + ) { + arg.call( this, e ); + } + } ); } else { - $el = $( '' ); - if ( typeof arg === 'function' ) { - $el.attr( { - role: 'button', - tabindex: 0 - } ).on( 'click keypress', function ( e ) { - if ( - e.type === 'click' || - e.type === 'keypress' && e.which === 13 - ) { - arg.call( this, e ); - } - } ); - } else { - $el.attr( 'href', textify( arg ) ); - } + $el.attr( 'href', textify( arg ) ); } - return appendWithoutParsing( $el.empty(), contents ); - }, - - /** - * Transform parsed structure into pluralization - * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number). - * So convert it back with the current language's convertNumber. - * - * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ] - * @return {string|jQuery} selected pluralized form according to current language - */ - plural: function ( nodes ) { - var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count, - explicitPluralForms = {}; + } + return appendWithoutParsing( $el.empty(), contents ); + }, - count = parseFloat( this.language.convertNumber( textify( nodes[ 0 ] ), true ) ); - forms = nodes.slice( 1 ); - for ( formIndex = 0; formIndex < forms.length; formIndex++ ) { - form = forms[ formIndex ]; - - if ( form instanceof $ && form.hasClass( 'mediaWiki_htmlEmitter' ) ) { - // This is a nested node, may be an explicit plural form like 5=[$2 linktext] - firstChild = form.contents().get( 0 ); - if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) { - firstChildText = firstChild.textContent; - if ( /^\d+=/.test( firstChildText ) ) { - explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 ); - // Use the digit part as key and rest of first text node and - // rest of child nodes as value. - firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 ); - explicitPluralForms[ explicitPluralFormNumber ] = form; - forms[ formIndex ] = undefined; - } + /** + * Transform parsed structure into pluralization + * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number). + * So convert it back with the current language's convertNumber. + * + * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ] + * @return {string|jQuery} selected pluralized form according to current language + */ + plural: function ( nodes ) { + var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count, + explicitPluralForms = {}; + + count = parseFloat( this.language.convertNumber( textify( nodes[ 0 ] ), true ) ); + forms = nodes.slice( 1 ); + for ( formIndex = 0; formIndex < forms.length; formIndex++ ) { + form = forms[ formIndex ]; + + if ( form instanceof $ && form.hasClass( 'mediaWiki_htmlEmitter' ) ) { + // This is a nested node, may be an explicit plural form like 5=[$2 linktext] + firstChild = form.contents().get( 0 ); + if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) { + firstChildText = firstChild.textContent; + if ( /^\d+=/.test( firstChildText ) ) { + explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 ); + // Use the digit part as key and rest of first text node and + // rest of child nodes as value. + firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 ); + explicitPluralForms[ explicitPluralFormNumber ] = form; + forms[ formIndex ] = undefined; } - } else if ( /^\d+=/.test( form ) ) { - // Simple explicit plural forms like 12=a dozen - explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 ); - explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 ); - forms[ formIndex ] = undefined; } + } else if ( /^\d+=/.test( form ) ) { + // Simple explicit plural forms like 12=a dozen + explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 ); + explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 ); + forms[ formIndex ] = undefined; } + } - // Remove explicit plural forms from the forms. They were set undefined in the above loop. - // eslint-disable-next-line no-jquery/no-map-util - forms = $.map( forms, function ( form ) { - return form; - } ); - - return this.language.convertPlural( count, forms, explicitPluralForms ); - }, - - /** - * Transform parsed structure according to gender. - * - * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}. - * - * The first node must be one of: - * - the mw.user object (or a compatible one) - * - an empty string - indicating the current user, same effect as passing the mw.user object - * - a gender string ('male', 'female' or 'unknown') - * - * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ] - * @return {string|jQuery} Selected gender form according to current language - */ - gender: function ( nodes ) { - var gender, - maybeUser = nodes[ 0 ], - forms = nodes.slice( 1 ); - - if ( maybeUser === '' ) { - maybeUser = mw.user; - } - - // If we are passed a mw.user-like object, check their gender. - // Otherwise, assume the gender string itself was passed . - if ( maybeUser && maybeUser.options instanceof mw.Map ) { - gender = maybeUser.options.get( 'gender' ); - } else { - gender = textify( maybeUser ); - } - - return this.language.gender( gender, forms ); - }, + // Remove explicit plural forms from the forms. They were set undefined in the above loop. + // eslint-disable-next-line no-jquery/no-map-util + forms = $.map( forms, function ( form ) { + return form; + } ); - /** - * Transform parsed structure into grammar conversion. - * Invoked by putting `{{grammar:form|word}}` in a message - * - * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}] - * @return {string|jQuery} selected grammatical form according to current language - */ - grammar: function ( nodes ) { - var form = nodes[ 0 ], - word = nodes[ 1 ]; - // These could be jQuery objects (passed as message parameters), - // in which case we can't transform them (like rawParams() in PHP). - if ( typeof form === 'string' && typeof word === 'string' ) { - return this.language.convertGrammar( word, form ); - } - return word; - }, + return this.language.convertPlural( count, forms, explicitPluralForms ); + }, - /** - * Tranform parsed structure into a int: (interface language) message include - * Invoked by putting `{{int:othermessage}}` into a message - * - * TODO Syntax in the included message is not parsed, this seems like a bug? - * - * @param {Array} nodes List of nodes - * @return {string} Other message - */ - int: function ( nodes ) { - var msg = textify( nodes[ 0 ] ); - return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) ); - }, + /** + * Transform parsed structure according to gender. + * + * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}. + * + * The first node must be one of: + * - the mw.user object (or a compatible one) + * - an empty string - indicating the current user, same effect as passing the mw.user object + * - a gender string ('male', 'female' or 'unknown') + * + * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ] + * @return {string|jQuery} Selected gender form according to current language + */ + gender: function ( nodes ) { + var gender, + maybeUser = nodes[ 0 ], + forms = nodes.slice( 1 ); - /** - * Get localized namespace name from canonical name or namespace number. - * Invoked by putting `{{ns:foo}}` into a message - * - * @param {Array} nodes List of nodes - * @return {string} Localized namespace name - */ - ns: function ( nodes ) { - var ns = textify( nodes[ 0 ] ).trim(); - if ( !/^\d+$/.test( ns ) ) { - ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ]; - } - ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ]; - return ns || ''; - }, + if ( maybeUser === '' ) { + maybeUser = mw.user; + } - /** - * Takes an unformatted number (arab, no group separators and . as decimal separator) - * and outputs it in the localized digit script and formatted with decimal - * separator, according to the current language. - * - * @param {Array} nodes List of nodes - * @return {number|string|jQuery} Formatted number - */ - formatnum: function ( nodes ) { - var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R', - number = nodes[ 0 ]; - - // These could be jQuery objects (passed as message parameters), - // in which case we can't transform them (like rawParams() in PHP). - if ( typeof number === 'string' || typeof number === 'number' ) { - return this.language.convertNumber( number, isInteger ); - } - return number; - }, + // If we are passed a mw.user-like object, check their gender. + // Otherwise, assume the gender string itself was passed . + if ( maybeUser && maybeUser.options instanceof mw.Map ) { + gender = maybeUser.options.get( 'gender' ); + } else { + gender = textify( maybeUser ); + } - /** - * Lowercase text - * - * @param {Array} nodes List of nodes - * @return {string} The given text, all in lowercase - */ - lc: function ( nodes ) { - return textify( nodes[ 0 ] ).toLowerCase(); - }, + return this.language.gender( gender, forms ); + }, - /** - * Uppercase text - * - * @param {Array} nodes List of nodes - * @return {string} The given text, all in uppercase - */ - uc: function ( nodes ) { - return textify( nodes[ 0 ] ).toUpperCase(); - }, + /** + * Transform parsed structure into grammar conversion. + * Invoked by putting `{{grammar:form|word}}` in a message + * + * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}] + * @return {string|jQuery} selected grammatical form according to current language + */ + grammar: function ( nodes ) { + var form = nodes[ 0 ], + word = nodes[ 1 ]; + // These could be jQuery objects (passed as message parameters), + // in which case we can't transform them (like rawParams() in PHP). + if ( typeof form === 'string' && typeof word === 'string' ) { + return this.language.convertGrammar( word, form ); + } + return word; + }, - /** - * Lowercase first letter of input, leaving the rest unchanged - * - * @param {Array} nodes List of nodes - * @return {string} The given text, with the first character in lowercase - */ - lcfirst: function ( nodes ) { - var text = textify( nodes[ 0 ] ); - return text.charAt( 0 ).toLowerCase() + text.slice( 1 ); - }, + /** + * Tranform parsed structure into a int: (interface language) message include + * Invoked by putting `{{int:othermessage}}` into a message + * + * TODO Syntax in the included message is not parsed, this seems like a bug? + * + * @param {Array} nodes List of nodes + * @return {string} Other message + */ + int: function ( nodes ) { + var msg = textify( nodes[ 0 ] ); + return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) ); + }, - /** - * Uppercase first letter of input, leaving the rest unchanged - * - * @param {Array} nodes List of nodes - * @return {string} The given text, with the first character in uppercase - */ - ucfirst: function ( nodes ) { - var text = textify( nodes[ 0 ] ); - return text.charAt( 0 ).toUpperCase() + text.slice( 1 ); + /** + * Get localized namespace name from canonical name or namespace number. + * Invoked by putting `{{ns:foo}}` into a message + * + * @param {Array} nodes List of nodes + * @return {string} Localized namespace name + */ + ns: function ( nodes ) { + var ns = textify( nodes[ 0 ] ).trim(); + if ( !/^\d+$/.test( ns ) ) { + ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ]; } - }; + ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ]; + return ns || ''; + }, /** - * @method - * @member jQuery - * @see mw.jqueryMsg#getPlugin + * Takes an unformatted number (arab, no group separators and . as decimal separator) + * and outputs it in the localized digit script and formatted with decimal + * separator, according to the current language. + * + * @param {Array} nodes List of nodes + * @return {number|string|jQuery} Formatted number */ - $.fn.msg = mw.jqueryMsg.getPlugin(); - - // Replace the default message parser with jqueryMsg - oldParser = mw.Message.prototype.parser; - mw.Message.prototype.parser = function () { - // Fall back to mw.msg's simple parser where possible - if ( - // Plain text output always uses the simple parser - this.format === 'plain' || - ( - // jqueryMsg parser is needed for messages containing wikitext - !/\{\{|[<>[&]/.test( this.map.get( this.key ) ) && - // jqueryMsg parser is needed when jQuery objects or DOM nodes are passed in as parameters - !this.parameters.some( function ( param ) { - return param instanceof $ || ( param && param.nodeType !== undefined ); - } ) - ) - ) { - return oldParser.apply( this ); + formatnum: function ( nodes ) { + var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R', + number = nodes[ 0 ]; + + // These could be jQuery objects (passed as message parameters), + // in which case we can't transform them (like rawParams() in PHP). + if ( typeof number === 'string' || typeof number === 'number' ) { + return this.language.convertNumber( number, isInteger ); } + return number; + }, - if ( !Object.prototype.hasOwnProperty.call( this.map, this.format ) ) { - this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( { - messages: this.map, - // For format 'escaped', escaping part is handled by mediawiki.js - format: this.format - } ); - } - return this.map[ this.format ]( this.key, this.parameters ); - }; + /** + * Lowercase text + * + * @param {Array} nodes List of nodes + * @return {string} The given text, all in lowercase + */ + lc: function ( nodes ) { + return textify( nodes[ 0 ] ).toLowerCase(); + }, /** - * Parse the message to DOM nodes, rather than HTML string like #parse. + * Uppercase text * - * This method is only available when jqueryMsg is loaded. + * @param {Array} nodes List of nodes + * @return {string} The given text, all in uppercase + */ + uc: function ( nodes ) { + return textify( nodes[ 0 ] ).toUpperCase(); + }, + + /** + * Lowercase first letter of input, leaving the rest unchanged * - * @since 1.27 - * @method parseDom - * @member mw.Message - * @return {jQuery} + * @param {Array} nodes List of nodes + * @return {string} The given text, with the first character in lowercase */ - mw.Message.prototype.parseDom = ( function () { - var $wrapper = $( '
' ); - return function () { - return $wrapper.msg( this.key, this.parameters ).contents().detach(); - }; - }() ); + lcfirst: function ( nodes ) { + var text = textify( nodes[ 0 ] ); + return text.charAt( 0 ).toLowerCase() + text.slice( 1 ); + }, + + /** + * Uppercase first letter of input, leaving the rest unchanged + * + * @param {Array} nodes List of nodes + * @return {string} The given text, with the first character in uppercase + */ + ucfirst: function ( nodes ) { + var text = textify( nodes[ 0 ] ); + return text.charAt( 0 ).toUpperCase() + text.slice( 1 ); + } +}; + +/** + * @method + * @member jQuery + * @see mw.jqueryMsg#getPlugin + */ +$.fn.msg = mw.jqueryMsg.getPlugin(); + +// Replace the default message parser with jqueryMsg +oldParser = mw.Message.prototype.parser; +mw.Message.prototype.parser = function () { + // Fall back to mw.msg's simple parser where possible + if ( + // Plain text output always uses the simple parser + this.format === 'plain' || + ( + // jqueryMsg parser is needed for messages containing wikitext + !/\{\{|[<>[&]/.test( this.map.get( this.key ) ) && + // jqueryMsg parser is needed when jQuery objects or DOM nodes are passed in as parameters + !this.parameters.some( function ( param ) { + return param instanceof $ || ( param && param.nodeType !== undefined ); + } ) + ) + ) { + return oldParser.apply( this ); + } + if ( !Object.prototype.hasOwnProperty.call( this.map, this.format ) ) { + this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( { + messages: this.map, + // For format 'escaped', escaping part is handled by mediawiki.js + format: this.format + } ); + } + return this.map[ this.format ]( this.key, this.parameters ); +}; + +/** + * Parse the message to DOM nodes, rather than HTML string like #parse. + * + * This method is only available when jqueryMsg is loaded. + * + * @since 1.27 + * @method parseDom + * @member mw.Message + * @return {jQuery} + */ +mw.Message.prototype.parseDom = ( function () { + var $wrapper = $( '
' ); + return function () { + return $wrapper.msg( this.key, this.parameters ).contents().detach(); + }; }() ); diff --git a/resources/src/mediawiki.rcfilters/.eslintrc.json b/resources/src/mediawiki.rcfilters/.eslintrc.json new file mode 100644 index 0000000000..ad8dbb3e85 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "parserOptions": { + "sourceType": "module" + } +} diff --git a/resources/src/mediawiki.rcfilters/Controller.js b/resources/src/mediawiki.rcfilters/Controller.js index ce5d4071c2..b6284fb0af 100644 --- a/resources/src/mediawiki.rcfilters/Controller.js +++ b/resources/src/mediawiki.rcfilters/Controller.js @@ -1,1230 +1,1227 @@ -( 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.pollingRate = require( './config.json' ).StructuredChangeFiltersLiveUpdatePollingRate; - - 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 no-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 - } ); +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.pollingRate = require( './config.json' ).StructuredChangeFiltersLiveUpdatePollingRate; + + 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 no-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 = { + 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' ), - 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 - ); - } ) - } - ] + labelPrefixKey: { default: 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' }, + separator: ';', + fullCoverage: true, + filters: items + } ] }; - - views.display = { + views.invert = { groups: [ { - name: 'display', + name: 'invertGroup', 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 ) ) - } - ] - } - ] + filters: [ { + name: 'invert', + default: '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 no-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' ); - - 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 ( 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 + } ] }; + } - 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'; + // 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 + ); + } ) } - } - - 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 ) - }; + 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 ) ) + } + ] + } + ] }; - /** - * 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 + // 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 no-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 ] ); } - 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 ); + // If the default value isn't in the group, add it + if ( groupData.default !== undefined ) { + extraValues.push( String( groupData.default ) ); } + controller.addNumberValuesToGroup( groupData, extraValues ); } } ); - }; + } ); - /** - * 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() ); - }; + // Initialize the model + this.filtersModel.initializeFilters( filterStructure, views ); - /** - * Empty all selected filters - */ - Controller.prototype.emptyFilters = function () { - var highlightedFilterNames = this.filtersModel.getHighlightedItems() - .map( function ( filterItem ) { return { name: filterItem.getName() }; } ); + this.uriProcessor = new UriProcessor( + this.filtersModel, + { normalizeTarget: this.normalizeTarget } + ); - if ( this.applyParamChange( {} ) ) { - // Only update the changes list if there was a change to actual filters - this.updateChangesList(); - } else { - this.uriProcessor.updateURL(); + if ( !mw.user.isAnon() ) { + try { + parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' ); + } catch ( err ) { + parsedSavedQueries = {}; } - if ( highlightedFilterNames ) { - this._trackHighlight( 'clearAll', highlightedFilterNames ); + // 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' ); + + 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() }; - /** - * 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 ); + 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 ) }; - - /** - * 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(); +}; + +/** + * 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; + }; - this.filtersModel.reassessFilterInteractions( filterItem ); - - // Log filter grouping - this.trackFilterGroupings( 'removefilter' ); - } - - if ( isHighlighted ) { - this._trackHighlight( 'clear', filterName ); - } - }; + arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ]; - /** - * Toggle the highlight feature on and off - */ - Controller.prototype.toggleHighlight = function () { - this.filtersModel.toggleHighlight(); - this.uriProcessor.updateURL(); + // Normalize the arbitrary values and the default value for a range + if ( groupData.range ) { + arbitraryValues = arbitraryValues.map( function ( val ) { + return normalizeWithinRange( groupData.range, val ); + } ); - if ( this.filtersModel.isHighlightEnabled() ) { - mw.hook( 'RcFilters.highlight.enable' ).fire(); + // Normalize the default, since that's user defined + if ( groupData.default !== undefined ) { + groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) ); } - }; + } - /** - * Toggle the namespaces inverted feature on and off - */ - Controller.prototype.toggleInvertedNamespaces = function () { - this.filtersModel.toggleInvertedNamespaces(); + // 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 ( - this.filtersModel.getFiltersByView( 'namespaces' ).filter( - function ( filterItem ) { return filterItem.isSelected(); } - ).length + // 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 ) { - // 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(); + // 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 ); + } } - }; - - /** - * 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(); + } ); +}; + +/** + * 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(); - }; - - /** - * 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 ); + } 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(); - 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 ); - } - }; + if ( highlightedFilterNames ) { + this._trackHighlight( 'clearAll', highlightedFilterNames ); + } +}; - /** - * Set a timeout for the next live update. - * @private - */ - Controller.prototype._scheduleLiveUpdate = function () { - setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 ); - }; +/** + * 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 ); - /** - * Perform a live update. - * @private - */ - Controller.prototype._doLiveUpdate = function () { - if ( !this._shouldCheckForNewChanges() ) { - // skip this turn and check back later - this._scheduleLiveUpdate(); - return; - } + if ( !filterItem ) { + // If no filter was found, break + return; + } - this._checkForNewChanges() - .then( function ( statusCode ) { - // no result is 204 with the 'peek' param - // logged out is 205 - var newChanges = statusCode === 200; + isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected; - if ( !this._shouldCheckForNewChanges() ) { - // by the time the response is received, - // it may not be appropriate anymore - return; - } + if ( filterItem.isSelected() !== isSelected ) { + this.filtersModel.toggleFilterSelected( filterName, isSelected ); - // 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(); + this.updateChangesList(); - if ( - currentMatchingQuery && - currentMatchingQuery.getID() === queryID - ) { - // If the query we want to load is the one that is already - // loaded, don't reload it - return; - } + // 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 ( this.applyParamChange( params ) ) { - // Update changes list only if there was a difference in filter selection + 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(); - } 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 ); + this.filtersModel.reassessFilterInteractions( filterItem ); - // 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 no-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 ); - } - }; + // Log filter grouping + this.trackFilterGroupings( 'removefilter' ); + } - /** - * Synchronize the URL with the current state of the filters - * without adding an history entry. - */ - Controller.prototype.replaceUrl = function () { + 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(); - }; - - /** - * 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(); + } +}; + +/** + * 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; + } - // Only update and fetch new results if it is requested - if ( fetchChangesList ) { - this.updateChangesList(); - } - }; + // 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; + } - /** - * 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 ) ); + 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() }; - - /** - * 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(); + 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 no-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; - /** - * 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(); - } + // 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 { - content: content, - status: jqXHR.status + changes: 'NO_RESULTS', + // We need empty result set, to avoid exceptions because of undefined value + fieldset: $( [] ), + noResultsDetails: 'NO_RESULTS_NETWORK_ERROR' }; - }, - // 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 = $( '
' ).append( $( $.parseHTML( - data ? data.content : '' - ) ) ); - return this._extractChangesListInfo( $parsed, data.status ); - }.bind( this ) - ); - }; + $parsed = $( '
' ).append( $( $.parseHTML( + data ? data.content : '' + ) ) ); - /** - * 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() - } + return this._extractChangesListInfo( $parsed, data.status ); + }.bind( this ) ); - }; - - /** - * 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; +}; + +/** + * 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() } - }; - - /** - * 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() ) - ); - }; + ); +}; + +/** + * 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(); + } ); - /** - * 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 ) - ); - }; + 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() + } + ); + } ); - module.exports = Controller; -}() ); + // 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; diff --git a/resources/src/mediawiki.rcfilters/HighlightColors.js b/resources/src/mediawiki.rcfilters/HighlightColors.js index a4ef73bbf0..42bfae6a36 100644 --- a/resources/src/mediawiki.rcfilters/HighlightColors.js +++ b/resources/src/mediawiki.rcfilters/HighlightColors.js @@ -1,12 +1,10 @@ -( 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' ]; +/** + * 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; -}() ); +module.exports = HighlightColors; diff --git a/resources/src/mediawiki.rcfilters/UriProcessor.js b/resources/src/mediawiki.rcfilters/UriProcessor.js index 37874d5e5f..3b69654bf7 100644 --- a/resources/src/mediawiki.rcfilters/UriProcessor.js +++ b/resources/src/mediawiki.rcfilters/UriProcessor.js @@ -1,296 +1,294 @@ -( 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( +/* 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, - 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 ]; - } - } + // 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 path - parts = mw.Uri.decode( uri.path ).match( re ); + // target in title param + if ( uri.query.title ) { + parts = uri.query.title.match( re ); if ( parts ) { - uri.path = parts[ 1 ]; + uri.query.title = 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 ); + } + + // 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 ]; } - }; - - /** - * 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; -}() ); + } ); + + 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; diff --git a/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js b/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js index 64d2e79cf7..70677b91d4 100644 --- a/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js @@ -1,169 +1,167 @@ -( 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; -}() ); +/** + * 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; diff --git a/resources/src/mediawiki.rcfilters/dm/FilterGroup.js b/resources/src/mediawiki.rcfilters/dm/FilterGroup.js index db504b5937..8bd5eb20b7 100644 --- a/resources/src/mediawiki.rcfilters/dm/FilterGroup.js +++ b/resources/src/mediawiki.rcfilters/dm/FilterGroup.js @@ -1,994 +1,992 @@ -( 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() ); +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 + } ); - // Check for filters that should be initially selected by their default value - if ( this.isSticky() ) { - // eslint-disable-next-line no-jquery/no-each-util - $.each( this.defaultFilters, function ( filterName, filterValue ) { - model.getItemByName( filterName ).toggleSelected( filterValue ); + if ( filter.subset ) { + filter.subset = filter.subset.map( function ( el ) { + return el.filter; } ); - } - // 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(); + 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 ); + } ); - // 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; - } + // Set translated subset + filterItem.setSubset( subsetNames ); } - if ( this.isSticky() ) { - // If this group is sticky, then change the default according to the - // current selection. - this.defaultParams = this.getParamRepresentation( this.getSelectedState() ); - } + items.push( filterItem ); + // Store default parameter state; in this case, default is defined per filter if ( - changed || - this.active !== active || - this.currSelected !== item + model.getType() === 'send_unselected_if_any' || + model.getType() === 'boolean' ) { - this.active = active; - this.currSelected = item; - - this.emit( 'update' ); + // 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; } - }; - - /** - * 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(); - } ); - }; + } ); + + // 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 ) ); - /** - * Check whether all items selected - * - * @return {boolean} All items are selected - */ - FilterGroup.prototype.areAllSelected = function () { - var selected = [], - unselected = []; + // Store default filter state based on default params + this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() ); - this.getItems().forEach( function ( filterItem ) { - if ( filterItem.isSelected() ) { - selected.push( filterItem ); - } else { - unselected.push( filterItem ); - } + // Check for filters that should be initially selected by their default value + if ( this.isSticky() ) { + // eslint-disable-next-line no-jquery/no-each-util + $.each( this.defaultFilters, function ( filterName, filterValue ) { + model.getItemByName( filterName ).toggleSelected( filterValue ); } ); - - if ( unselected.length === 0 ) { - return true; + } + + // 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 ); + } + } ); - // 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(); + 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() ); } ); - }; - - /** - * 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 && ( + } ); +}; + +/** + * 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 ) || - // Any selected items are in conflict individually - selectedItems.some( function ( selectedFilter ) { + // All selected items are in conflict individually + selectedItems.every( 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 no-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 no-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 = []; - +}; + +/** + * 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 no-jquery/no-each-util - $.each( filterRepresentation, function ( name, value ) { - // Collect values + $.each( filters, function ( name, value ) { if ( value ) { - values.push( filterParamNames[ name ] ); + selected.push( 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 ); + 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; + } } - 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 ); + if ( filterRepresentation[ item.getName() ] ) { + areAnySelected = true; } - - 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 no-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(); + } ); + + // 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 no-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; } - }.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() ] ); + } ); + } else if ( this.getType() === 'string_options' ) { + values = []; + + // eslint-disable-next-line no-jquery/no-each-util + $.each( filterRepresentation, function ( name, value ) { + // Collect values + if ( value ) { + values.push( filterParamNames[ name ] ); } + } ); - 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; - }; + 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(); - /** - * Get current selected state of all filter items in this group - * - * @return {Object} Selected state - */ - FilterGroup.prototype.getSelectedState = function () { - var state = {}; + expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0'; + paramToFilterMap[ paramName ] = filterItem; - this.getItems().forEach( function ( filterItem ) { - state[ filterItem.getName() ] = filterItem.getValue(); + if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) { + areAnySelected = true; + } } ); - 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 ) ); + // eslint-disable-next-line no-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() ]; + } } ); - }; - - /** - * 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' + } 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(); + } ) ); - }; - - /** - * 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; - }; + // 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() ]; - /** - * Toggle the visibility of this group - * - * @param {boolean} [isVisible] Item is visible - */ - FilterGroup.prototype.toggleVisible = function ( isVisible ) { - isVisible = isVisible === undefined ? !this.visible : isVisible; + result[ filterItem.getName() ] = selected; + oneWasSelected = oneWasSelected || selected; + } ); + } - if ( this.visible !== isVisible ) { - this.visible = isVisible; - this.emit( 'update' ); + // 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() ] ); } - }; - - /** - * 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; -}() ); + 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; diff --git a/resources/src/mediawiki.rcfilters/dm/FilterItem.js b/resources/src/mediawiki.rcfilters/dm/FilterItem.js index 1138c4e6f8..8725f51c9a 100644 --- a/resources/src/mediawiki.rcfilters/dm/FilterItem.js +++ b/resources/src/mediawiki.rcfilters/dm/FilterItem.js @@ -1,406 +1,403 @@ -( 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; +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() }; - - /* 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 no-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'; - } +}; + +/** + * 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 no-jquery/no-each-util + $.each( conflicts, function ( filterName, conflict ) { + if ( !conflict.item.isSelected() ) { + return; } - if ( messageKey ) { - // Build message - return mw.msg( - messageKey, - mw.language.listToText( affectingItems ), - affectingItems.length - ); + if ( !conflictMessage ) { + conflictMessage = conflict[ key ]; + group = conflict.group; } - // 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' ); + if ( group === conflict.group ) { + itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) ); } - }; + } ); - /** - * 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' ); - } + return { + message: conflictMessage, + names: itemLabels }; - /** - * 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' ); - } - }; +}; + +/** + * @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() ); + } - /** - * 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' ); + 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'; } - }; - - /** - * Check whether the item is visible - * - * @return {boolean} Item is visible - */ - FilterItem.prototype.isVisible = function () { - return this.visible; - }; - - module.exports = FilterItem; - -}() ); + } + + 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; diff --git a/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js index d1b9f7a973..07c484bcf1 100644 --- a/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js @@ -1,1044 +1,1064 @@ -( 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() ); +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 no-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 ); } ) ); - } ); - - // Update coverage for the changed group - if ( groupModel.isFullCoverage() ) { - allSelected = groupModel.areAllSelected(); - groupModel.getItems().forEach( function ( filterItem ) { - filterItem.toggleFullyCovered( allSelected ); - } ); } + + // 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; } ); - // 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(); + // 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|undefined} Conflicted item or undefined when not found + */ +FiltersViewModel.prototype.getFirstConflictedItem = function () { + var i, filterItem, items = this.getItems(); + for ( i = 0; i < items.length; i++ ) { + filterItem = items[ i ]; + if ( filterItem.isSelected() && filterItem.isConflicted() ) { + return filterItem; + } + } +}; + +/** + * 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 = {}; - // For each item, see if that item is still conflicting // eslint-disable-next-line no-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|undefined} Conflicted item or undefined when not found - */ - FiltersViewModel.prototype.getFirstConflictedItem = function () { - var i, filterItem, items = this.getItems(); - for ( i = 0; i < items.length; i++ ) { - filterItem = items[ i ]; - if ( filterItem.isSelected() && filterItem.isConflicted() ) { - return filterItem; - } - } - }; - - /** - * 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 no-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( + $.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: filterName, - item: filter + filter: groupItem.getName(), + item: groupItem } ); - } 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 no-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 no-jquery/no-each-util - $.each( groupConflictResult, function ( group, conflicts ) { - model.groups[ group ].setConflicts( conflicts ); - } ); + result[ key ] = adjustedConflicts; + } ); - // Set conflicts for items - // eslint-disable-next-line no-jquery/no-each-util - $.each( filterConflictResult, function ( filterName, conflicts ) { - var filterItem = model.getItemByName( filterName ); - // set conflicts for items in the group - filterItem.setConflicts( conflicts ); - } ); + 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 ) : ''; + } + } ); - // Create a map between known parameters and their models - // eslint-disable-next-line no-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; + // Collect views + allViews = $.extend( true, { + default: { + title: mw.msg( 'rcfilters-filterlist-title' ), + groups: filterGroups + } + }, views ); + + // Go over all views + // eslint-disable-next-line no-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 } ) + ); } - } ); - - this.setSearch( '' ); - - this.updateHighlightedState(); - // Finish initialization - this.emit( 'initialize' ); - }; + model.groups[ group ].initializeFilters( groupData.filters, groupData.default ); + items = items.concat( model.groups[ group ].getItems() ); - /** - * 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 no-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(); + // Prepare conflicts + if ( groupData.conflicts ) { + // Group conflicts + groupConflictMap[ group ] = groupData.conflicts; } - } ); - 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 no-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 ]; - } + groupData.filters.forEach( function ( itemData ) { + var filterItem = model.groups[ group ].getItemByParamName( itemData.name ); + // Filter conflicts + if ( itemData.conflicts ) { + filterConflictMap[ filterItem.getName() ] = itemData.conflicts; + } + } ); } ); - - 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( + } ); + + // 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 no-jquery/no-each-util + $.each( groupConflictResult, function ( group, conflicts ) { + model.groups[ group ].setConflicts( conflicts ); + } ); + + // Set conflicts for items + // eslint-disable-next-line no-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 no-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 no-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.getEmptyParameterState(), - this.getCurrentParameterState() + this.getParametersFromFilters( {} ), + this.getEmptyHighlightParameters() ); - }; - - /** - * 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 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 no-jquery/no-each-util + $.each( this.getEmptyParameterState(), function ( param, value ) { + if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) { + result[ param ] = parameters[ param ]; } - - 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 no-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 no-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(); - } + } ); + + // 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 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 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 no-jquery/no-each-util + $.each( this.groups, function ( groupName, groupModel ) { + if ( groupModel.getView() === view ) { + result[ groupName ] = groupModel; } - - 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 no-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 no-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 no-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() ] ); - } ); + } ); + + 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 no-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(); } - - // eslint-disable-next-line no-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 no-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 no-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 `_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 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 no-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 no-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 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 = {}; - + } + } ); + + 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 no-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 ) { - if ( filterItem.isHighlightSupported() ) { - result[ filterItem.getName() + '_color' ] = null; - } + groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {}; + groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] ); } ); + } + + // eslint-disable-next-line no-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 no-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 no-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 `_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 = []; + return result; +}; - if ( this.isHighlightEnabled() ) { - this.getHighlightedItems().forEach( function ( filterItem ) { - var color = filterItem.getHighlightColor(); +/** + * Get an array of currently applied highlight colors + * + * @return {string[]} Currently applied highlight colors + */ +FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () { + var result = []; - if ( result.indexOf( color ) === -1 ) { - result.push( color ); - } - } ); - } + if ( this.isHighlightEnabled() ) { + this.getHighlightedItems().forEach( function ( filterItem ) { + var color = filterItem.getHighlightColor(); - 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(); + if ( result.indexOf( color ) === -1 ) { + result.push( color ); + } } ); - - 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; + } + + 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'; } ); - }; - - /** - * 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 ); +}; + +/** + * 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 ); } - }; - - /** - * 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 ); + }.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 ] ); } - // 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 + 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 ) === 0 || + 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' && @@ -1050,250 +1070,227 @@ 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 no-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 no-jquery/no-each-util + $.each( this.views, function ( name, data ) { + if ( data.trigger === trigger ) { + result = name; } - - 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 = []; - + } ); + + 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 no-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 no-jquery/no-each-util - $.each( this.views, function ( name, data ) { - if ( data.trigger === trigger ) { - result = name; - } + // Check if the group is visible at all + groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 ); + groupModel.setVisibleItems( visibleGroups[ groupName ] || [] ); } ); - 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 no-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; - -}() ); + 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; diff --git a/resources/src/mediawiki.rcfilters/dm/ItemModel.js b/resources/src/mediawiki.rcfilters/dm/ItemModel.js index 2dc578e054..ae8ac5fdb7 100644 --- a/resources/src/mediawiki.rcfilters/dm/ItemModel.js +++ b/resources/src/mediawiki.rcfilters/dm/ItemModel.js @@ -1,276 +1,274 @@ -( 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; +/** + * 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() }; - - /* 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' - ]; +}; + +/** + * 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 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; -}() ); + 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; diff --git a/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js b/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js index aa407b91ee..19de282e39 100644 --- a/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js +++ b/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js @@ -1,415 +1,413 @@ -( 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 no-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 +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 no-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; - } + if ( obj.data && obj.data.filters ) { + obj.data = model.convertToParameters( obj.data ); } } ); - 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 ) - ); + this.converted = true; + savedQueries.version = '2'; + } - // Highlights: appending _color to keys - newData.highlights = {}; - // eslint-disable-next-line no-jquery/no-each-util - $.each( data.highlights, function ( highlightedFilterName, value ) { - if ( value ) { - newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ]; - } - } ); + // Initialize the query items + // eslint-disable-next-line no-jquery/no-each-util + $.each( savedQueries.queries || {}, function ( id, obj ) { + var normalizedData = obj.data, + isDefault = String( savedQueries.default ) === String( id ); - // 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 no-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; + 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; } - } ); - // Correct the invert state for effective selection - if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) { - 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; + } } - - // Add item - this.addItems( [ - new SavedQueryItemModel( - randomID, - label, - normalizedData, - { default: isDefault } - ) - ] ); - - if ( isDefault ) { - this.setDefault( randomID ); + } ); + + 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 no-jquery/no-each-util + $.each( data.highlights, function ( highlightedFilterName, value ) { + if ( value ) { + newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ]; } - - 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 ] ); + } ); + + // 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 no-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; } - }; - - /** - * 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; + } ); + + // 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 ); } - 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.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 ) { - obj.queries[ item.getID() ] = item.getState(); + item.toggleDefault( item.getID() === itemID ); } ); - 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; -}() ); + 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; diff --git a/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js b/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js index 1774391690..27e93e3071 100644 --- a/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js +++ b/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js @@ -1,127 +1,125 @@ -( 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; +/** + * 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() }; - - /* 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; -}() ); +}; + +/** + * 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; diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index a69dc55051..4e5e0fe721 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -1,162 +1,158 @@ /*! * JavaScript for Special:RecentChanges */ -( function () { - - 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; +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 ]; } - /** - * @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, - { - 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 - } - ] - } - ] - }; - } + return namespaces; +} - mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget( - controller, - filtersModel, - savedQueriesModel, - changesListModel, +/** + * @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 - getNamespaces( [ 'Media' ] ), - require( './config.json' ).RCFiltersChangeTags, - 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.initFormWidget( specialPage ); + 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 + } + ); - $( 'a.mw-helplink' ).attr( - 'href', - 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review' - ); + // 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.replaceUrl(); + controller.initialize( + mw.config.get( 'wgStructuredChangeFilters' ), + // All namespaces without Media namespace + getNamespaces( [ 'Media' ] ), + require( './config.json' ).RCFiltersChangeTags, + conditionalViews + ); - mainWrapperWidget.setTopSection( specialPage ); + mainWrapperWidget.initFormWidget( specialPage ); - /** - * 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(); - } + $( 'a.mw-helplink' ).attr( + 'href', + 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review' + ); - // Import i18n messages from config - mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) ); + controller.replaceUrl(); - // Early execute of init - if ( document.readyState === 'interactive' || document.readyState === 'complete' ) { - init(); - } else { - $( init ); - } + mainWrapperWidget.setTopSection( specialPage ); + + /** + * 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(); +} + +// Import i18n messages from config +mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) ); - module.exports = mw.rcfilters; +// Early execute of init +if ( document.readyState === 'interactive' || document.readyState === 'complete' ) { + init(); +} else { + $( init ); +} -}() ); +module.exports = mw.rcfilters; diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.js index b32fb38dbf..5bf991680b 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.js @@ -1,61 +1,59 @@ -( function () { - /** - * @class - * @singleton - */ - mw.rcfilters = { - 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 ) { - elements = Array.isArray( elements ) ? elements : [ elements ]; - - elements.forEach( function ( element ) { - if ( arr.indexOf( element ) === -1 ) { - arr.push( element ); - } - } ); +/** + * @class + * @singleton + */ +mw.rcfilters = { + 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 ) { + elements = Array.isArray( elements ) ? elements : [ elements ]; - return arr; - }, - normalizeParamOptions: function ( givenOptions, legalOptions ) { - var result = []; - - if ( givenOptions.indexOf( 'all' ) > -1 ) { - // If anywhere in the values there's 'all', we - // treat it as if only 'all' was selected. - // Example: param=valid1,valid2,all - // Result: param=all - return [ 'all' ]; + elements.forEach( function ( element ) { + if ( arr.indexOf( element ) === -1 ) { + arr.push( element ); } + } ); - // Get rid of any dupe and invalid parameter, only output - // valid ones - // Example: param=valid1,valid2,invalid1,valid1 - // Result: param=valid1,valid2 - givenOptions.forEach( function ( value ) { - if ( - legalOptions.indexOf( value ) > -1 && - result.indexOf( value ) === -1 - ) { - result.push( value ); - } - } ); + return arr; + }, + normalizeParamOptions: function ( givenOptions, legalOptions ) { + var result = []; - return result; + if ( givenOptions.indexOf( 'all' ) > -1 ) { + // If anywhere in the values there's 'all', we + // treat it as if only 'all' was selected. + // Example: param=valid1,valid2,all + // Result: param=all + return [ 'all' ]; } + + // Get rid of any dupe and invalid parameter, only output + // valid ones + // Example: param=valid1,valid2,invalid1,valid1 + // Result: param=valid1,valid2 + givenOptions.forEach( function ( value ) { + if ( + legalOptions.indexOf( value ) > -1 && + result.indexOf( value ) === -1 + ) { + result.push( value ); + } + } ); + + return result; } - }; + } +}; - module.exports = mw.rcfilters; -}() ); +module.exports = mw.rcfilters; diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js index 23b05e812b..4764bd8395 100644 --- a/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js @@ -1,174 +1,171 @@ -( 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' +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: $( '
' ).append( + // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget + changesLimitPopupWidget.$element, + datePopupWidget.$element + ) + } } ); - - 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: $( '
' ).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; - -}() ); + 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; diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js index d78c42b633..a0c0d80c5d 100644 --- a/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js @@ -1,84 +1,82 @@ -( function () { - var ValuePickerWidget = require( './ValuePickerWidget.js' ), - ChangesLimitPopupWidget; +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 || {}; +/** + * 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 ); + // Parent + ChangesLimitPopupWidget.parent.call( this, config ); - this.limitModel = limitModel; - this.groupByPageItemModel = groupByPageItemModel; + this.limitModel = limitModel; + this.groupByPageItemModel = groupByPageItemModel; - this.valuePicker = new ValuePickerWidget( - this.limitModel, - { - label: mw.msg( 'rcfilters-limit-title' ) - } - ); + this.valuePicker = new ValuePickerWidget( + this.limitModel, + { + label: mw.msg( 'rcfilters-limit-title' ) + } + ); - this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( { - selected: this.groupByPageItemModel.isSelected() - } ); + 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' } ); + // 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 - ); - }; + // 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 */ +/* Initialization */ - OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget ); +OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget ); - /* Events */ +/* Events */ - /** - * @event limit - * @param {string} name Item name - * - * A limit item was chosen - */ +/** + * @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 - */ +/** + * @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() ); - }; +/** + * Respond to group by page model update + */ +ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () { + this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() ); +}; - module.exports = ChangesLimitPopupWidget; -}() ); +module.exports = ChangesLimitPopupWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js index ba7f4d11a6..09b802ef89 100644 --- a/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js @@ -1,388 +1,386 @@ -( 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(); +/** + * 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 = $( '
' ) + .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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' ) + .text( mw.message( 'rcfilters-noresults-conflict' ).text() ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' ) + .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() ) + ); } else { - this.clearHighlight(); - } - }; + $message + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' ) + .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) ) + ); - /** - * 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(); + // 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( ' ' ); + } ); } - }; - /** - * Respond to changes list model invalidate - */ - ChangesListWrapperWidget.prototype.onModelInvalidate = function () { - $( 'body' ).addClass( 'mw-rcfilters-ui-loading' ); - }; + this.$element.append( $message ); + } else { + if ( !isInitialDOM ) { + this.$element.empty().append( $changesListContent ); - /** - * 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 = $( '
' ) - .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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' ) - .text( mw.message( 'rcfilters-noresults-conflict' ).text() ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' ) - .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() ) - ); - } else { - $message - .append( - $( '
' ) - .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( ' ' ); - } ); + if ( from ) { + this.emphasizeNewChanges( from ); } - - this.$element.append( $message ); - } else { - if ( !isInitialDOM ) { - this.$element.empty().append( $changesListContent ); - - if ( from ) { - this.emphasizeNewChanges( from ); - } - } - - // Apply highlight - this.applyHighlight(); - } - this.$element.prepend( $( '
' ).addClass( 'mw-changeslist-overlay' ) ); + // Apply highlight + this.applyHighlight(); - 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' ); - } ); - }; + this.$element.prepend( $( '
' ).addClass( 'mw-changeslist-overlay' ) ); - /** 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 ); - }; + loaderPromise.done( function () { + if ( !isInitialDOM && !isEmpty ) { + // Make sure enhanced RC re-initializes correctly + mw.hook( 'wikipage.content' ).fire( widget.$element ); + } - /** - * 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 ]; + $( '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' }; - - /** - * 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; - } + 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 = $( '
' ) - .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' ); - - $firstNew.after( $indicator ); } - - // FIXME: Use CSS transition - // eslint-disable-next-line no-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; + } ); + + if ( $firstNew ) { + $indicator = $( '
' ) + .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' ); + + $firstNew.after( $indicator ); + } + + // FIXME: Use CSS transition + // eslint-disable-next-line no-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 ); } ); - - // 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 ); } ); - // 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 !OO.compare( firstChildClasses, classesInThisRow ); } ); - }; - - /** - * @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 () { + // 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.length ) { - $( this ).attr( 'title', mw.msg( - 'rcfilters-highlighted-filters-list', - filters.join( mw.msg( 'comma-separator' ) ) - ) ); + if ( !filters ) { + filters = []; + $( this ).data( 'highlightedFilters', filters ); + } + if ( filters.indexOf( filterItem.getLabel() ) === -1 ) { + filters.push( filterItem.getLabel() ); } - } ); - if ( this.inEnhancedMode() ) { - this.updateEnhancedParentHighlight(); + }.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' ) ) + ) ); } - // Turn on highlights - this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' ); - }; + } ); + 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 ) ); - /** - * 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' ); - }; + 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; -}() ); +module.exports = ChangesListWrapperWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js b/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js index 490d54e460..b6e21cf2a0 100644 --- a/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js @@ -1,66 +1,64 @@ -( 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 || {}; +/** + * 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 ); + // 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 ) ); - }; + // 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 */ +/* Initialization */ - OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget ); +OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget ); - /* Events */ +/* Events */ - /** - * @event userChange - * @param {boolean} Current state of the checkbox - * - * The user has checked or unchecked this checkbox - */ +/** + * @event userChange + * @param {boolean} Current state of the checkbox + * + * The user has checked or unchecked this checkbox + */ - /* Methods */ +/* 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. - }; +/** + * @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' ) ); - }; +/** + * Respond to checkbox change by a user and emit 'userChange'. + */ +CheckboxInputWidget.prototype.onUserChange = function () { + this.emit( 'userChange', this.$input.prop( 'checked' ) ); +}; - module.exports = CheckboxInputWidget; -}() ); +module.exports = CheckboxInputWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js b/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js index 1ac0d495b3..226821cfa7 100644 --- a/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js @@ -1,72 +1,70 @@ -( function () { - var ValuePickerWidget = require( './ValuePickerWidget.js' ), - DatePopupWidget; +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 || {}; +/** + * 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 ); + // Parent + DatePopupWidget.parent.call( this, config ); + // Mixin constructors + OO.ui.mixin.LabelElement.call( this, config ); - this.model = model; + 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; } - } - ); + 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' ] } ); + // 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 - ); - }; + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-datePopupWidget' ) + .append( + this.$label + .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ), + this.hoursValuePicker.$element, + this.daysValuePicker.$element + ); +}; - /* Initialization */ +/* Initialization */ - OO.inheritClass( DatePopupWidget, OO.ui.Widget ); - OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement ); +OO.inheritClass( DatePopupWidget, OO.ui.Widget ); +OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement ); - /* Events */ +/* Events */ - /** - * @event days - * @param {string} name Item name - * - * A days item was chosen - */ +/** + * @event days + * @param {string} name Item name + * + * A days item was chosen + */ - module.exports = DatePopupWidget; -}() ); +module.exports = DatePopupWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js b/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js index 13277555b7..fb591d0028 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js @@ -1,85 +1,83 @@ -( 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; -}() ); +/** + * 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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js index 13963413f7..3735af26b1 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js @@ -1,184 +1,182 @@ -( 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: $( '
' ) - .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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' ) - .append( this.backButton.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' ) - .append( this.$label, this.helpIcon.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' ) - .append( this.invertNamespacesButton.$element ), - $( '
' ) - .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' ) +/** + * 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: $( '
' ) + .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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' ) + .append( this.backButton.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' ) + .append( this.$label, this.helpIcon.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' ) + .append( this.invertNamespacesButton.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' ) + .append( this.highlightButton.$element ) + ) + ) ); - }; - - 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; -}() ); +}; + +/* 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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js index 4080f4d645..b4b0e9dc95 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js @@ -1,96 +1,94 @@ -( function () { - var ItemMenuOptionWidget = require( './ItemMenuOptionWidget.js' ), - FilterMenuOptionWidget; +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 || {}; +/** + * 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; + this.controller = controller; + this.invertModel = invertModel; + this.model = itemModel; - // Parent - FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config ); + // Parent + FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config ); - // Event - this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } ); + // Event + this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } ); - this.$element - .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' ); - }; + this.$element + .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' ); +}; - /* Initialization */ - OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget ); +/* Initialization */ +OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget ); - /* Static properties */ +/* Static properties */ - // We do our own scrolling to top - FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false; +// We do our own scrolling to top +FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false; - /* Methods */ +/* Methods */ - /** - * @inheritdoc - */ - FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () { - // Parent - FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this ); +/** + * @inheritdoc + */ +FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () { + // Parent + FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this ); - this.setCurrentMuteState(); - }; + this.setCurrentMuteState(); +}; - /** - * Respond to item group model update event - */ - FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () { - 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() - ) +/** + * 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; -}() ); +module.exports = FilterMenuOptionWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js index 5b9e3590fe..abcce813a0 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js @@ -1,127 +1,124 @@ -( 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 = $( '
' ) - .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ), - $popupContent = $( '
' ) - .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: $( '
' ) - .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 ); +/** + * 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 = $( '
' ) + .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ), + $popupContent = $( '
' ) + .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: $( '
' ) + .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 + ); } - // 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; - -}() ); + // 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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js index bda898b49d..98eea71832 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js @@ -1,50 +1,48 @@ -( 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; -}() ); +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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js index dc6fc12e1d..085e22b0ac 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js @@ -1,777 +1,775 @@ -( 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 = $( '
' ) - .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: $( '
' ) - .append( new ViewSwitchWidget( this.controller, this.model ).$element ), - views: [ 'default' ] - }, - { - name: 'feedback', - // Feedback footer, appears on all views - $element: $( '
' ) - .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, +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 = $( '
' ) + .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: $( '
' ) + .append( new ViewSwitchWidget( this.controller, this.model ).$element ), + views: [ 'default' ] + }, { - $overlay: this.$overlay + name: 'feedback', + // Feedback footer, appears on all views + $element: $( '
' ) + .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 + ) } - ); - - 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' - } ); + ] + }, + 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.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 ) { + this.saveQueryButton.$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 = $( '
' ) - .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( - $( '
' ) - .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' ) - } ) - ] + this.saveQueryButton.connect( this, { + click: 'onSaveQueryButtonClick', + saveCurrent: 'setSavedQueryVisibility' } ); - - // Rearrange the UI so the select widget is at the right of the input - this.$element.append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' ) - .append( this.input.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' ) - .append( this.viewsSelectWidget.$element ) - ) - ) + 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 = $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + this.$content + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' ) ); - // Event - this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } ); - + if ( !mw.user.isAnon() ) { rcFiltersRow.append( $( '
' ) .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' ) - .append( this.resetButton.$element ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' ) + .append( this.saveQueryButton.$element ) ); - - // Build the content - $contentWrapper.append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' ) - .append( title.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' ) - .append( this.savedQueryTitle.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' ) - .append( - this.hideShowButton.$element - ) - ), - $( '
' ) - .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 ); + } + + // 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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' ) + .append( this.input.$element ), + $( '
' ) + .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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' ) + .append( this.resetButton.$element ) + ); + + // Build the content + $contentWrapper.append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' ) + .append( title.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' ) + .append( this.savedQueryTitle.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' ) + .append( + this.hideShowButton.$element + ) + ), + $( '
' ) + .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(); - }; - - /** - * 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( '' ); + mw.hook( 'RcFilters.popup.open' ).fire(); - // 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(); + 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 ) + ); } - }; - - /** - * 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() ); - } + } 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() ); - } + } + + 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 ); } + }.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( + } +}; +/** + * @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, - menuConfig + this.model.getInvertModel(), + filterItem, + { + $overlay: this.$overlay + } ); - }; - - /** - * @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' ) - ) { + } +}; + +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 - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' ); + .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' ); 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 || + .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.min === undefined || - newScrollTop - containerScrollTop >= threshold.min - ) && - ( - threshold.max === undefined || - newScrollTop - containerScrollTop <= threshold.max - ) + threshold.max === undefined || + newScrollTop - containerScrollTop <= threshold.max ) - ) { - $( container ).animate( { - scrollTop: newScrollTop - } ); - } - }; + ) + ) { + $( container ).animate( { + scrollTop: newScrollTop + } ); + } +}; - module.exports = FilterTagMultiselectWidget; -}() ); +module.exports = FilterTagMultiselectWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js index cb297f68ea..ce9656eb27 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js @@ -1,139 +1,137 @@ -( 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 - } +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 = $( '
' ) + .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' ); + + $bottom = $( '
' ) + .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' ) + .append( + this.showNewChangesLink.$element, + this.numChangesAndDateWidget.$element ); - this.liveUpdateButton = new LiveUpdateButtonWidget( - this.controller, - this.changesListModel - ); + if ( this.controller.pollingRate ) { + $bottom.prepend( this.liveUpdateButton.$element ); + } - this.numChangesAndDateWidget = new ChangesLimitAndDateButtonWidget( - this.controller, - this.model, - { - $overlay: this.$overlay - } + this.$element + .addClass( 'mw-rcfilters-ui-filterWrapperWidget' ) + .append( + this.$top, + this.filterTagWidget.$element, + $bottom ); - - 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 = $( '
' ) - .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' ); - - $bottom = $( '
' ) - .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' ) - .append( - this.showNewChangesLink.$element, - this.numChangesAndDateWidget.$element - ); - - if ( this.controller.pollingRate ) { - $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; -}() ); +}; + +/* 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; diff --git a/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js index dbf177612f..7d69fb6be1 100644 --- a/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js @@ -1,176 +1,174 @@ -( 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 ); +/** + * 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(); } - 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(); + 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 ); } - - // Get rid of all
s, which are inside rcshowhide - // If we still have content in rcshowhide, the
s are - // gone. Instead, the CSS now has a rule to mark all 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; -}() ); + // 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
s, which are inside rcshowhide + // If we still have content in rcshowhide, the
s are + // gone. Instead, the CSS now has a rule to mark all 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; diff --git a/resources/src/mediawiki.rcfilters/ui/GroupWidget.js b/resources/src/mediawiki.rcfilters/ui/GroupWidget.js index 73b874cbe4..6634e30684 100644 --- a/resources/src/mediawiki.rcfilters/ui/GroupWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/GroupWidget.js @@ -1,45 +1,43 @@ -( 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 no-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; -}() ); +/** + * 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 no-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; diff --git a/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js b/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js index cb5f8eb844..082b65b1a1 100644 --- a/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js @@ -1,125 +1,123 @@ -( 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 ); +/** + * 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 ); } - 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' ); - } + 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; -}() ); + } +}; + +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; diff --git a/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js index 4c467dfae2..5a6901338f 100644 --- a/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js @@ -1,68 +1,65 @@ -( 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 || {}; +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 ) ); + // 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 = new HighlightColorPickerWidget( controller ); - this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } ); + this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } ); - this.$body.append( this.colorPicker.$element ); - }; + this.$body.append( this.colorPicker.$element ); +}; - /* Initialization */ +/* Initialization */ - OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget ); +OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget ); - /* Methods */ +/* 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 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 ); - }; +/** + * 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 ); - }; +/** + * When the user chooses a color in the color picker, close the popup. + */ +HighlightPopupWidget.prototype.onChooseColor = function () { + this.toggle( false ); +}; - module.exports = HighlightPopupWidget; - -}() ); +module.exports = HighlightPopupWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js index 56ed628f5d..710bd653eb 100644 --- a/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js @@ -1,172 +1,169 @@ -( 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 = $( '
' ) - .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() - } ); - +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 = $( '
' ) + .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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' ) + .append( $( '' ).append( this.$label ) ) + ); + if ( this.itemModel.getDescription() ) { $label.append( $( '
' ) - .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' ) - .append( $( '' ).append( this.$label ) ) + .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' ) + .append( $( '' ).text( this.itemModel.getDescription() ) ) ); - if ( this.itemModel.getDescription() ) { - $label.append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' ) - .append( $( '' ).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 = 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() + ); + 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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' ) + .append( layout.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' ) + .append( this.excludeLabel.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' ) + .append( this.highlightButton.$element ) + ) + ) ); - layout = new OO.ui.FieldLayout( this.checkboxWidget, { - label: $label, - align: 'inline' + if ( this.itemModel.getIdentifiers() ) { + this.itemModel.getIdentifiers().forEach( function ( ident ) { + classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident ); } ); - // 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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' ) - .append( layout.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' ) - .append( this.excludeLabel.$element ), - $( '
' ) - .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.$element.addClass( classes ); + } - this.updateUiBasedOnState(); - }; + this.updateUiBasedOnState(); +}; - /* Initialization */ +/* Initialization */ - OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget ); +OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget ); - /* Static properties */ +/* Static properties */ - // We do our own scrolling to top - ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false; +// We do our own scrolling to top +ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false; - /* Methods */ +/* 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() ); - }; +/** + * Respond to item model update event + */ +ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () { + this.checkboxWidget.setSelected( this.itemModel.isSelected() ); - /** - * Get the name of this filter - * - * @return {string} Filter name - */ - ItemMenuOptionWidget.prototype.getName = function () { - return this.itemModel.getName(); - }; + 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() ); +}; - ItemMenuOptionWidget.prototype.getModel = function () { - return this.itemModel; - }; +/** + * Get the name of this filter + * + * @return {string} Filter name + */ +ItemMenuOptionWidget.prototype.getName = function () { + return this.itemModel.getName(); +}; - module.exports = ItemMenuOptionWidget; +ItemMenuOptionWidget.prototype.getModel = function () { + return this.itemModel; +}; -}() ); +module.exports = ItemMenuOptionWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js index 3ccb6e25dd..04289c744e 100644 --- a/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js @@ -1,72 +1,69 @@ -( 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 || {}; +/** + * 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 ) ); + // Parent + LiveUpdateButtonWidget.parent.call( this, $.extend( { + label: mw.message( 'rcfilters-liveupdates-button' ).text() + }, config ) ); - this.controller = controller; - this.model = changesListModel; + this.controller = controller; + this.model = changesListModel; - // Events - this.connect( this, { click: 'onClick' } ); - this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } ); + // Events + this.connect( this, { click: 'onClick' } ); + this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } ); - this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' ); + this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' ); - this.setState( false ); - }; + this.setState( false ); +}; - /* Initialization */ +/* Initialization */ - OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget ); +OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget ); - /* Methods */ +/* Methods */ - /** - * Respond to the button being clicked - */ - LiveUpdateButtonWidget.prototype.onClick = function () { - this.controller.toggleLiveUpdate(); - }; +/** + * 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() ); - }; +/** + * 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 ); - }; +/** + * 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; - -}() ); +module.exports = LiveUpdateButtonWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js index bc1cac8d69..31edb77b64 100644 --- a/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js @@ -1,142 +1,140 @@ -( 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 = $( '
' ).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 ); +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 = $( '
' ).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 + } + ); - /* Events */ + this.changesListWidget = new ChangesListWrapperWidget( + model, changesListModel, controller, this.$changesListContainer ); - // 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 ) } ); + /* Events */ - // Initialize - this.$filtersContainer.append( this.filtersWidget.$element ); - $( 'body' ) - .append( this.$overlay ) - .addClass( 'mw-rcfilters-ui-initialized' ); - }; + // 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 ) } ); - /* Initialization */ + // Initialize + this.$filtersContainer.append( this.filtersWidget.$element ); + $( 'body' ) + .append( this.$overlay ) + .addClass( 'mw-rcfilters-ui-initialized' ); +}; - OO.inheritClass( MainWrapperWidget, OO.ui.Widget ); +/* Initialization */ - /* Methods */ +OO.inheritClass( MainWrapperWidget, OO.ui.Widget ); - /** - * Set the content of the top section, depending on the type of special page. - * - * @param {string} specialPage - */ - MainWrapperWidget.prototype.setTopSection = function ( specialPage ) { - var topSection; +/* Methods */ - if ( specialPage === 'Recentchanges' ) { - topSection = new RcTopSectionWidget( - this.savedLinksListWidget, this.$topSection - ); - this.filtersWidget.setTopSection( topSection.$element ); - } +/** + * 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 === 'Recentchangeslinked' ) { - topSection = new RclTopSectionWidget( - this.savedLinksListWidget, this.controller, - this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ), - this.model.getGroup( 'page' ).getItemByParamName( 'target' ) - ); + 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 ); - } + this.filtersWidget.setTopSection( topSection.$element ); + } - if ( specialPage === 'Watchlist' ) { - topSection = new WatchlistTopSectionWidget( - this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection - ); + 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; -}() ); + 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; diff --git a/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js index 3914337c1a..c7fa334879 100644 --- a/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js @@ -1,58 +1,55 @@ -( 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; - -}() ); +/** + * 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; diff --git a/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js b/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js index 864d0cf80f..1e7502018d 100644 --- a/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js @@ -1,368 +1,366 @@ -( 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 = $( '
' ).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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' ) - ); - this.setClippableElement( this.$body ); - this.setClippableContainer( this.$element ); - - header = new FilterMenuHeaderWidget( - this.controller, - this.model, - { - $overlay: this.$overlay - } +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 = $( '
' ).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( + $( '
' ) + .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 ) ); - 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: $( '
' ) - .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 ); - } + // 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: $( '
' ) + .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 no-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 no-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(); + }.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 no-jquery/no-each-util + $.each( groups, function ( groupName, groupModel ) { + if ( !groupModel.isHidden() ) { + viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0; + viewGroupCount[ groupModel.getView() ]++; } - - 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; - } + } ); + + // eslint-disable-next-line no-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 + } + ) + ); } - if ( !itemWasSelected ) { - this.selectItem( null ); - } + // 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.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; + 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; } + } - nextItem = nextItem && nextItem.constructor.static.selectable ? - nextItem : null; - - // Select the next item - this.selectItem( nextItem ); + if ( !itemWasSelected ) { + this.selectItem( null ); } - }; - - /** - * 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; -}() ); + } + + 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; diff --git a/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js index 6de9c401a6..3d56fba648 100644 --- a/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js @@ -1,116 +1,114 @@ -( 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; +/** + * 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 || {}; + config = config || {}; - // Parent - RcTopSectionWidget.parent.call( this, config ); + // Parent + RcTopSectionWidget.parent.call( this, config ); - this.$topLinks = $topLinks; + this.$topLinks = $topLinks; - toplinksTitle = new OO.ui.ButtonWidget( { - framed: false, - indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up', - flags: [ 'progressive' ], - label: $( '' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents() - } ); + toplinksTitle = new OO.ui.ButtonWidget( { + framed: false, + indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up', + flags: [ 'progressive' ], + label: $( '' ).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 + .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' ) ); + 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 = $( '
' ) - .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' ); - this.$tableTopLinks = $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' ); + // Create two positions for the toplinks to toggle between + // in the table (first cell) or up above it + this.$top = $( '
' ) + .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' ); + this.$tableTopLinks = $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' ); - // Initialize - this.$element - .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - this.$tableTopLinks, + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + this.$tableTopLinks, + $( '
' ) + .addClass( 'mw-rcfilters-ui-table-placeholder' ) + .addClass( 'mw-rcfilters-ui-cell' ), + !mw.user.isAnon() ? $( '
' ) - .addClass( 'mw-rcfilters-ui-table-placeholder' ) - .addClass( 'mw-rcfilters-ui-cell' ), - !mw.user.isAnon() ? - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' ) - .append( savedLinksListWidget.$element ) : - null - ) - ) - ); + .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 ); + // Hack: For jumpiness reasons, this should be a sibling of -head + $( '.rcfilters-head' ).before( this.$top ); - // Initialize top links position - widget.switchTopLinks( topLinksCookieValue ); - }; + // Initialize top links position + widget.switchTopLinks( topLinksCookieValue ); +}; - /* Initialization */ +/* Initialization */ - OO.inheritClass( RcTopSectionWidget, OO.ui.Widget ); +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'; +/** + * 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' ); - }; + 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; -}() ); +module.exports = RcTopSectionWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js b/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js index 6eb0d5b252..382b54c8e5 100644 --- a/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js @@ -1,82 +1,80 @@ -( 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 || {}; +/** + * 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 ); + // Parent + RclTargetPageWidget.parent.call( this, config ); - this.controller = controller; - this.model = targetPageModel; + 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 - } ); + 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' } ); + // Events + this.model.connect( this, { update: 'updateUiBasedOnModel' } ); - this.titleSearch.$input.on( { - blur: this.onLookupInputBlur.bind( this ) - } ); + this.titleSearch.$input.on( { + blur: this.onLookupInputBlur.bind( this ) + } ); - this.titleSearch.lookupMenu.connect( this, { - choose: 'onLookupMenuItemChoose' - } ); + this.titleSearch.lookupMenu.connect( this, { + choose: 'onLookupMenuItemChoose' + } ); - // Initialize - this.$element - .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' ) - .append( this.titleSearch.$element ); + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' ) + .append( this.titleSearch.$element ); - this.updateUiBasedOnModel(); - }; + this.updateUiBasedOnModel(); +}; - /* Initialization */ +/* Initialization */ - OO.inheritClass( RclTargetPageWidget, OO.ui.Widget ); +OO.inheritClass( RclTargetPageWidget, OO.ui.Widget ); - /* Methods */ +/* Methods */ - /** - * Respond to the user choosing a title - */ - RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () { - this.titleSearch.$input.trigger( 'blur' ); - }; +/** + * 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 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 ); - }; +/** + * 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; -}() ); +module.exports = RclTargetPageWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js b/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js index e2c58d036a..46f2de92df 100644 --- a/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js @@ -1,76 +1,74 @@ -( 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 || {}; +/** + * 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' ) ) - } ); + 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 ) ); + // 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.controller = controller; + this.model = showLinkedToModel; - this.getMenu().connect( this, { choose: 'onUserChooseItem' } ); - this.model.connect( this, { update: 'onModelUpdate' } ); + 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(); - }; + // force an initial update of the component based on the state + this.onModelUpdate(); +}; - /* Initialization */ +/* Initialization */ - OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget ); +OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget ); - /* Methods */ +/* 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 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' - ) ); - }; +/** + * 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; -}() ); +module.exports = RclToOrFromWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js index d968b9e735..560f3d8b33 100644 --- a/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js @@ -1,73 +1,71 @@ -( function () { - var RclToOrFromWidget = require( './RclToOrFromWidget.js' ), - RclTargetPageWidget = require( './RclTargetPageWidget.js' ), - RclTopSectionWidget; +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 || {}; +/** + * 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 ); + // Parent + RclTopSectionWidget.parent.call( this, config ); - this.controller = controller; + this.controller = controller; - toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel ); - targetPage = new RclTargetPageWidget( controller, targetPageModel ); + toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel ); + targetPage = new RclTargetPageWidget( controller, targetPageModel ); - // Initialize - this.$element - .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .append( toOrFromWidget.$element ) + ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .append( targetPage.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-table-placeholder' ) + .addClass( 'mw-rcfilters-ui-cell' ), + !mw.user.isAnon() ? $( '
' ) .addClass( 'mw-rcfilters-ui-cell' ) - .append( toOrFromWidget.$element ) - ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .append( targetPage.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-table-placeholder' ) - .addClass( 'mw-rcfilters-ui-cell' ), - !mw.user.isAnon() ? - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' ) - .append( savedLinksListWidget.$element ) : - null - ) - ) - ); - }; + .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' ) + .append( savedLinksListWidget.$element ) : + null + ) + ) + ); +}; - /* Initialization */ +/* Initialization */ - OO.inheritClass( RclTopSectionWidget, OO.ui.Widget ); +OO.inheritClass( RclTopSectionWidget, OO.ui.Widget ); - module.exports = RclTopSectionWidget; -}() ); +module.exports = RclTopSectionWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js index 8c3d5508a4..1c66c6e7f0 100644 --- a/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js @@ -1,191 +1,189 @@ -( 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 = $( '
' ); - - 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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' ) - .append( layout.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' ) - .append( checkBoxLayout.$element ), - $( '
' ) - .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; +/** + * 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 = $( '
' ); + + 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 } - }; - - /** - * 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 () { + }, 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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' ) + .append( layout.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' ) + .append( checkBoxLayout.$element ), + $( '
' ) + .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 ); - }; - - /** - * 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' ); - } - }; + 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; -}() ); +module.exports = SaveFiltersPopupButtonWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js b/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js index ceb5ef87cb..4057c48015 100644 --- a/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js @@ -1,333 +1,330 @@ -( 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' ] - } ); +/** + * 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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .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 + ), + $( '
' ) + .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 ); - - // 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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .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 - ), - $( '
' ) - .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 + } +}; + +/** + * 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 ); - }; - - /** - * 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' ) - ); + } +}; + +/** + * 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' ); } - }; - - /** - * Get item ID - * - * @return {string} Query identifier - */ - SavedLinksListItemWidget.prototype.getID = function () { - return this.model.getID(); - }; - - module.exports = SavedLinksListItemWidget; - -}() ); + 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; diff --git a/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js b/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js index 5422daf683..a29a93fdab 100644 --- a/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js @@ -1,159 +1,157 @@ -( 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 = $( '
' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' ) - .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ), - $( '
' ) - .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; +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 = $( '
' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' ) + .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ), + $( '
' ) + .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 } - - 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; -}() ); + } ); + + // 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; diff --git a/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js index d66c5b588d..985e2c5002 100644 --- a/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js @@ -1,225 +1,223 @@ -( 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: $( '
' ) - .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 = $( '
' ) - .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( $( '
' ).append( - $( '' ).html( - mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse() - ) - ).contents() ); - } else { - this.setLabel( - $( '' ).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 ); +/** + * 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: $( '
' ) + .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' ) + .append( this.popupLabel.$element ), + $floatableContainer: this.$element, + classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ] } - }; - - /** - * 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; -}() ); + }, config ) ); + + this.popupTimeoutShow = null; + this.popupTimeoutHide = null; + + this.$highlight = $( '
' ) + .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( $( '
' ).append( + $( '' ).html( + mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse() + ) + ).contents() ); + } else { + this.setLabel( + $( '' ).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; diff --git a/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js b/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js index ebd81c8358..3ce63eee11 100644 --- a/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js @@ -1,114 +1,112 @@ -( 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 - ); +/** + * 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; }; - /* 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() - } ); + // 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.selectWidget.clearItems(); + this.selectWidget.addItems( items ); - this.selectCurrentModelItem(); - }; + 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 ]; +/** + * 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() ); - } - }; + if ( selectedItem ) { + this.selectWidget.selectItemByData( selectedItem.getName() ); + } +}; - module.exports = ValuePickerWidget; -}() ); +module.exports = ValuePickerWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js b/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js index c00d4145c5..e366277ca1 100644 --- a/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js @@ -1,84 +1,82 @@ -( function () { - var GroupWidget = require( './GroupWidget.js' ), - ViewSwitchWidget; +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 || {}; +/** + * 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 ); + // Parent + ViewSwitchWidget.parent.call( this, config ); - this.controller = controller; - this.model = model; + 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' ) - } ) - ] - } ); + 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' } ); + // 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, - $( '
' ) - .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' ) - .append( this.buttons.$element ) - ); - }; + this.$element + .addClass( 'mw-rcfilters-ui-viewSwitchWidget' ) + .append( + new OO.ui.LabelWidget( { + label: mw.msg( 'rcfilters-advancedfilters' ) + } ).$element, + $( '
' ) + .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' ) + .append( this.buttons.$element ) + ); +}; - /* Initialize */ +/* Initialize */ - OO.inheritClass( ViewSwitchWidget, OO.ui.Widget ); +OO.inheritClass( ViewSwitchWidget, OO.ui.Widget ); - /** - * Respond to model update event - */ - ViewSwitchWidget.prototype.onModelUpdate = function () { - var currentView = this.model.getCurrentView(); +/** + * 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 ); - } ); - }; + 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() ); - }; +/** + * 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; -}() ); +module.exports = ViewSwitchWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js index 16c0533d8b..7796148afe 100644 --- a/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js @@ -1,88 +1,86 @@ -( 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 || {}; +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 ); + // Parent + WatchlistTopSectionWidget.parent.call( this, config ); - editWatchlistButton = new OO.ui.ButtonWidget( { - label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ), - icon: 'edit', - href: require( '../config.json' ).StructuredChangeFiltersEditWatchlistUrl - } ); - markSeenButton = new MarkSeenButtonWidget( controller, changesListModel ); + editWatchlistButton = new OO.ui.ButtonWidget( { + label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ), + icon: 'edit', + href: require( '../config.json' ).StructuredChangeFiltersEditWatchlistUrl + } ); + markSeenButton = new MarkSeenButtonWidget( controller, changesListModel ); - $topTable = $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' ) - .append( $watchlistDetails ) - ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' ) - .append( editWatchlistButton.$element ) - ) - ); + $topTable = $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' ) + .append( $watchlistDetails ) + ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' ) + .append( editWatchlistButton.$element ) + ) + ); - $bottomTable = $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .append( markSeenButton.$element ) - ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' ) - .append( savedLinksListWidget.$element ) - ) - ); + $bottomTable = $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .append( markSeenButton.$element ) + ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' ) + .append( savedLinksListWidget.$element ) + ) + ); - $separator = $( '
' ) - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' ); + $separator = $( '
' ) + .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' ); - this.$element - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' ) - .append( $topTable, $separator, $bottomTable ); - }; + this.$element + .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' ) + .append( $topTable, $separator, $bottomTable ); +}; - /* Initialization */ +/* Initialization */ - OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget ); +OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget ); - module.exports = WatchlistTopSectionWidget; -}() ); +module.exports = WatchlistTopSectionWidget; -- 2.20.1