From 7725c755867ac19af417dcb66fe66d0e8d3f6f9a Mon Sep 17 00:00:00 2001 From: Moriel Schottlender Date: Mon, 29 May 2017 18:04:35 +0300 Subject: [PATCH] RCFilters: Add edit tags drop down Fetches the tags from the wiki and displays them as additional filters for RCFilters. Bug: T159942 Bug: T161650 Bug: T164130 Change-Id: I7bfa99cd5aeb34b6c7de74c15aac158ee40eac2f --- includes/changetags/ChangeTags.php | 26 +++++++ includes/specials/SpecialRecentchanges.php | 47 +++++++++++ languages/i18n/en.json | 2 + languages/i18n/qqq.json | 2 + resources/Resources.php | 2 + .../dm/mw.rcfilters.dm.FiltersViewModel.js | 77 +++++++++++++++++-- .../mw.rcfilters.Controller.js | 6 +- .../mediawiki.rcfilters/mw.rcfilters.init.js | 6 +- .../mw.rcfilters.ui.FilterWrapperWidget.less | 2 +- ...rcfilters.ui.FilterTagMultiselectWidget.js | 32 +++----- .../ui/mw.rcfilters.ui.FilterWrapperWidget.js | 42 ++++++---- .../ui/mw.rcfilters.ui.FormWrapperWidget.js | 3 +- 12 files changed, 203 insertions(+), 44 deletions(-) diff --git a/includes/changetags/ChangeTags.php b/includes/changetags/ChangeTags.php index ff6a8730c5..6ba9c10a70 100644 --- a/includes/changetags/ChangeTags.php +++ b/includes/changetags/ChangeTags.php @@ -120,6 +120,32 @@ class ChangeTags { return $msg->parse(); } + /** + * Get the message object for the tag's long description. + * + * Checks if message key "mediawiki:tag-$tag-description" exists. If it does not, + * or if message is disabled, returns false. Otherwise, returns the message object + * for the long description. + * + * @param string $tag Tag + * @param IContextSource $context + * @return Message|bool Message object of the tag long description or false if + * there is no description. + */ + public static function tagLongDescriptionMessage( $tag, IContextSource $context ) { + $msg = $context->msg( "tag-$tag-description" ); + if ( !$msg->exists() ) { + return false; + } + if ( $msg->isDisabled() ) { + // The message exists but is disabled, hide the description. + return false; + } + + // Message exists and isn't disabled, use it. + return $msg; + } + /** * Add tags to a change given its rc_id, rev_id and/or log_id * diff --git a/includes/specials/SpecialRecentchanges.php b/includes/specials/SpecialRecentchanges.php index cbf2e370a0..5ec2064fb2 100644 --- a/includes/specials/SpecialRecentchanges.php +++ b/includes/specials/SpecialRecentchanges.php @@ -189,9 +189,56 @@ class SpecialRecentChanges extends ChangesListSpecialPage { 'wgStructuredChangeFiltersEnableExperimentalViews', $wgStructuredChangeFiltersEnableExperimentalViews ); + $out->addJsConfigVars( + 'wgRCFiltersChangeTags', + $this->buildChangeTagList() + ); } } + /** + * Fetch the change tags list for the front end + * + * @return Array Tag data + */ + protected function buildChangeTagList() { + function stripAllHtml( $input ) { + return trim( html_entity_decode( strip_tags( $input ) ) ); + } + + $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 ); + $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 ); + $tagStats = ChangeTags::tagUsageStatistics(); + + $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats ); + + // Sort by hits + asort( $tagHitCounts ); + + // Build the list and data + $result = []; + foreach ( $tagHitCounts as $tagName => $hits ) { + if ( + // Only get active tags + isset( $explicitlyDefinedTags[ $tagName ] ) || + isset( $softwareActivatedTags[ $tagName ] ) + ) { + // Parse description + $desc = ChangeTags::tagLongDescriptionMessage( $tagName, $this->getContext() ); + + $result[] = [ + 'name' => $tagName, + 'label' => stripAllHtml( ChangeTags::tagDescription( $tagName, $this->getContext() ) ), + 'description' => $desc ? stripAllHtml( $desc->parse() ) : '', + 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ), + 'hits' => $hits, + ]; + } + } + + return $result; + } + /** * @inheritdoc */ diff --git a/languages/i18n/en.json b/languages/i18n/en.json index bcb9f2df92..f6a7e58dde 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1438,6 +1438,8 @@ "rcfilters-filter-excluded": "Excluded", "rcfilters-tag-prefix-namespace": ":$1", "rcfilters-tag-prefix-namespace-inverted": ":not $1", + "rcfilters-tag-prefix-tags": "#$1", + "rcfilters-view-tags": "Tags", "rcnotefrom": "Below {{PLURAL:$5|is the change|are the changes}} since $3, $4 (up to $1 shown).", "rclistfromreset": "Reset date selection", "rclistfrom": "Show new changes starting from $2, $3", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 1aeb6a31db..562b60f846 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1628,6 +1628,8 @@ "rcfilters-filter-excluded": "Label for a menu item in [[Special:RecentChanges]] noting that the item is being excluded from the results.", "rcfilters-tag-prefix-namespace": "Prefix for the namespace tags in [[Special:RecentChanges]]. Namespace tags use a colon (:) as prefix. Please keep this format.\n\nParameters:\n* $1 - Filter name.", "rcfilters-tag-prefix-namespace-inverted": "Prefix for the namespace inverted tags in [[Special:RecentChanges]]. Namespace tags use a colon (:) as prefix. Please keep this format.\n\nParameters:\n* $1 - Filter name.", + "rcfilters-tag-prefix-tags": "Prefix for the edit tags in [[Special:RecentChanges]]. Edit tags use a hash (#) as prefix. Please keep this format.\n\nParameters:\n* $1 - Tag display name.", + "rcfilters-view-tags": "Title for the tags view in [[Special:RecentChanges]]", "rcnotefrom": "This message is displayed at [[Special:RecentChanges]] when viewing recentchanges from some specific time.\n\nThe corresponding message is {{msg-mw|Rclistfrom}}.\n\nParameters:\n* $1 - the maximum number of changes that are displayed\n* $2 - (Optional) a date and time\n* $3 - a date\n* $4 - a time\n* $5 - Number of changes are displayed, for use with PLURAL", "rclistfromreset": "Used on [[Special:RecentChanges]] to reset a selection of a certain date range.", "rclistfrom": "Used on [[Special:RecentChanges]]. Parameters:\n* $1 - (Currently not use) date and time. The date and the time adds to the rclistfrom description.\n* $2 - time. The time adds to the rclistfrom link description (with split of date and time).\n* $3 - date. The date adds to the rclistfrom link description (with split of date and time).\n\nThe corresponding message is {{msg-mw|Rcnotefrom}}.", diff --git a/resources/Resources.php b/resources/Resources.php index 87a62cd4fb..575ff5a1a6 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1840,6 +1840,8 @@ return [ 'rcfilters-filter-excluded', 'rcfilters-tag-prefix-namespace', 'rcfilters-tag-prefix-namespace-inverted', + 'rcfilters-tag-prefix-tags', + 'rcfilters-view-tags', 'blanknamespace', 'namespaces', 'invert', diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js index 53a11707bd..ebffaa0e6f 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js @@ -208,8 +208,9 @@ * * @param {Array} filters Filter group definition * @param {Object} [namespaces] Namespace definition + * @param {Object[]} [tags] Tag array definition */ - mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters, namespaces ) { + mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters, namespaces, tags ) { var filterItem, filterConflictResult, groupConflictResult, model = this, items = [], @@ -363,8 +364,37 @@ items = items.concat( model.groups.namespace.getItems() ); } + tags = tags || []; + if ( + mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) && + tags.length > 0 + ) { + // Define view + this.views.tags = { name: 'tags', label: mw.msg( 'rcfilters-view-tags' ), trigger: '#' }; + + // Add the group + model.groups.tags = new mw.rcfilters.dm.FilterGroup( + 'tags', + { + type: 'string_options', + view: 'tags', + title: 'rcfilters-view-tags', // Message key + labelPrefixKey: 'rcfilters-tag-prefix-tags', + separator: '|', + fullCoverage: false + } + ); + + // Add tag items to group + model.groups.tags.initializeFilters( tags ); + + // Add item references to the model, for lookup + items = items.concat( model.groups.tags.getItems() ); + } + // Add item references to the model, for lookup this.addItems( items ); + // Expand conflicts groupConflictResult = expandConflictDefinitions( groupConflictMap ); filterConflictResult = expandConflictDefinitions( filterConflictMap ); @@ -793,12 +823,12 @@ groupTitle, result = {}, flatResult = [], - view = query.indexOf( this.getViewTrigger( 'namespaces' ) ) === 0 ? 'namespaces' : 'default', + view = this.getViewByTrigger( query.substr( 0, 1 ) ), items = this.getFiltersByView( view ); // Normalize so we can search strings regardless of case and view query = query.toLowerCase(); - if ( view === 'namespaces' ) { + if ( view !== 'default' ) { query = query.substr( 1 ); } @@ -810,7 +840,12 @@ for ( i = 0; i < items.length; i++ ) { if ( searchIsEmpty || - items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 + 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 ] ); @@ -826,7 +861,12 @@ searchIsEmpty || items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 || items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 || - groupTitle.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 ] ); @@ -892,6 +932,33 @@ return this.views[ this.getCurrentView() ].label; }; + /** + * Get an array of all available view names + * + * @return {string} Available view names + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getAvailableViews = function () { + return Object.keys( this.views ); + }; + + /** + * Get the view that fits the given trigger + * + * @param {string} trigger Trigger + * @return {string} Name of view + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) { + var result = 'default'; + + $.each( this.views, function ( name, data ) { + if ( data.trigger === trigger ) { + result = name; + } + } ); + + return result; + }; + /** * Toggle the highlight feature on and off. * Propagate the change to filter items. diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js index 5e430c3ea9..20f28b3c93 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -25,14 +25,16 @@ * * @param {Array} filterStructure Filter definition and structure for the model * @param {Object} [namespaceStructure] Namespace definition + * @param {Object} [tagList] Tag definition */ - mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure ) { + mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList ) { var parsedSavedQueries, uri = new mw.Uri(), $changesList = $( '.mw-changeslist' ).first().contents(); // Initialize the model - this.filtersModel.initializeFilters( filterStructure, namespaceStructure ); + this.filtersModel.initializeFilters( filterStructure, namespaceStructure, tagList ); + this._buildBaseFilterState(); this.uriProcessor = new mw.rcfilters.UriProcessor( diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index 03edca30cf..fc5b2211f1 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -24,7 +24,11 @@ new mw.rcfilters.ui.ChangesListWrapperWidget( filtersModel, changesListModel, $( '.mw-changeslist, .mw-changeslist-empty' ) ); - controller.initialize( mw.config.get( 'wgStructuredChangeFilters' ), mw.config.get( 'wgFormattedNamespaces' ) ); + controller.initialize( + mw.config.get( 'wgStructuredChangeFilters' ), + mw.config.get( 'wgFormattedNamespaces' ), + mw.config.get( 'wgRCFiltersChangeTags' ) + ); // eslint-disable-next-line no-new new mw.rcfilters.ui.FormWrapperWidget( diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less index 00ec87c822..1a29459f48 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less @@ -4,7 +4,7 @@ // Make sure this uses the interface direction, not the content direction direction: ltr; - &-namespaceToggle { + &-viewToggleButtons { margin-top: 1em; } } diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js index b1927c62a0..268138fbda 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js @@ -167,11 +167,7 @@ * @param {string} value Value of the input */ mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) { - var view = 'default'; - - if ( value.indexOf( this.model.getViewTrigger( 'namespaces' ) ) === 0 ) { - view = 'namespaces'; - } + var view = this.model.getViewByTrigger( value.substr( 0, 1 ) ); this.controller.switchView( view ); }; @@ -279,26 +275,20 @@ mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.updateElementsForView = function () { var view = this.model.getCurrentView(), inputValue = this.input.getValue(), - newInputValue = inputValue; + inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) ); - switch ( view ) { - case 'namespaces': - if ( inputValue.indexOf( this.model.getViewTrigger( 'namespaces' ) ) !== 0 ) { - // Add the prefix to the input - newInputValue = this.model.getViewTrigger( 'namespaces' ) + inputValue; - } - break; - default: - case 'default': - if ( inputValue.indexOf( this.model.getViewTrigger( 'namespaces' ) ) === 0 ) { - // Remove the prefix - newInputValue = inputValue.substr( 1 ); - } - break; + 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( newInputValue ); + this.input.setValue( inputValue ); }; /** diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js index e0076213ab..462651404e 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js @@ -33,16 +33,27 @@ { $overlay: this.$overlay } ); - this.namespaceButton = new OO.ui.ButtonWidget( { - label: mw.msg( 'namespaces' ), - icon: 'article', - classes: [ 'mw-rcfilters-ui-filterWrapperWidget-namespaceToggle' ] + this.viewToggle = new OO.ui.ButtonSelectWidget( { + classes: [ 'mw-rcfilters-ui-filterWrapperWidget-viewToggleButtons' ], + items: [ + new OO.ui.ButtonOptionWidget( { + data: 'namespaces', + label: mw.msg( 'namespaces' ), + icon: 'article', + classes: [ 'mw-rcfilters-ui-filterWrapperWidget-viewToggleButtons-namespaces' ] + } ), + new OO.ui.ButtonOptionWidget( { + data: 'tags', + label: mw.msg( 'rcfilters-view-tags' ), + icon: 'tag', + classes: [ 'mw-rcfilters-ui-filterWrapperWidget-viewToggleButtons-tags' ] + } ) + ] } ); - this.namespaceButton.setActive( this.model.getCurrentView() === 'namespaces' ); // Events this.model.connect( this, { update: 'onModelUpdate' } ); - this.namespaceButton.connect( this, { click: 'onNamespaceToggleClick' } ); + this.viewToggle.connect( this, { select: 'onViewToggleSelect' } ); // Initialize this.$element @@ -63,9 +74,9 @@ this.$element.append( this.filterTagWidget.$element, - this.namespaceButton.$element + this.viewToggle.$element ); - this.namespaceButton.toggle( !!mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) ); + this.viewToggle.toggle( !!mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) ); }; /* Initialization */ @@ -79,15 +90,20 @@ * Respond to model update event */ mw.rcfilters.ui.FilterWrapperWidget.prototype.onModelUpdate = function () { - // Synchronize the state of the toggle button with the current view - this.namespaceButton.setActive( this.model.getCurrentView() === 'namespaces' ); + // Synchronize the state of the toggle buttons with the current view + this.viewToggle.selectItemByData( this.model.getCurrentView() ); }; /** * Respond to namespace toggle button click + * + * @param {OO.ui.ButtonWidget} buttonWidget The button that was clicked */ - mw.rcfilters.ui.FilterWrapperWidget.prototype.onNamespaceToggleClick = function () { - this.controller.switchView( 'namespaces' ); - this.filterTagWidget.focus(); + mw.rcfilters.ui.FilterWrapperWidget.prototype.onViewToggleSelect = function ( buttonWidget ) { + if ( buttonWidget ) { + this.controller.switchView( buttonWidget.getData() ); + this.filterTagWidget.focus(); + } }; + }( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js index 477290ee2d..ec10d44712 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js @@ -129,9 +129,10 @@ this.parentNode.removeChild( this ); } ); - // Hide namespaces + // Hide namespaces and tags if ( mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) ) { $namespaceSelect.closest( 'tr' ).detach(); + this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach(); } // Collapse legend -- 2.20.1