RCFilters: Add edit tags drop down
authorMoriel Schottlender <moriel@gmail.com>
Mon, 29 May 2017 15:04:35 +0000 (18:04 +0300)
committerCatrope <roan@wikimedia.org>
Thu, 15 Jun 2017 19:33:34 +0000 (19:33 +0000)
Fetches the tags from the wiki and displays them as additional
filters for RCFilters.

Bug: T159942
Bug: T161650
Bug: T164130
Change-Id: I7bfa99cd5aeb34b6c7de74c15aac158ee40eac2f

12 files changed:
includes/changetags/ChangeTags.php
includes/specials/SpecialRecentchanges.php
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js

index ff6a873..6ba9c10 100644 (file)
@@ -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
         *
index cbf2e37..5ec2064 100644 (file)
@@ -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
         */
index bcb9f2d..f6a7e58 100644 (file)
        "rcfilters-filter-excluded": "Excluded",
        "rcfilters-tag-prefix-namespace": ":$1",
        "rcfilters-tag-prefix-namespace-inverted": "<strong>:not</strong> $1",
+       "rcfilters-tag-prefix-tags": "#$1",
+       "rcfilters-view-tags": "Tags",
        "rcnotefrom": "Below {{PLURAL:$5|is the change|are the changes}} since <strong>$3, $4</strong> (up to <strong>$1</strong> shown).",
        "rclistfromreset": "Reset date selection",
        "rclistfrom": "Show new changes starting from $2, $3",
index 1aeb6a3..562b60f 100644 (file)
        "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}}.",
index 87a62cd..575ff5a 100644 (file)
@@ -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',
index 53a1170..ebffaa0 100644 (file)
         *
         * @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 = [],
                        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 );
                        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 );
                }
 
                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 ] );
                                        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 ] );
                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.
index 5e430c3..20f28b3 100644 (file)
         *
         * @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(
index 03edca3..fc5b221 100644 (file)
                        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(
index 00ec87c..1a29459 100644 (file)
@@ -4,7 +4,7 @@
        // Make sure this uses the interface direction, not the content direction
        direction: ltr;
 
-       &-namespaceToggle {
+       &-viewToggleButtons {
                margin-top: 1em;
        }
 }
index b1927c6..268138f 100644 (file)
         * @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 );
        };
        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 );
        };
 
        /**
index e007621..4626514 100644 (file)
                        { $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 */
         * 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 ) );
index 477290e..ec10d44 100644 (file)
                        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