jenkins-bot has submitted this change and it was merged. ( 
https://gerrit.wikimedia.org/r/378103 )

Change subject: RCLFilters: convert related changes tool to new UX
......................................................................


RCLFilters: convert related changes tool to new UX

Bug: T172161
Change-Id: I96af7ba583d03e6ff9833ac3b5f4b80cfd0ee626
---
M languages/i18n/en.json
M languages/i18n/qqq.json
M resources/Resources.php
M resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js
M resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
M resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js
M resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
M resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js
M resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
A 
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less
M resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js
A resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js
A resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js
A resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js
M tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js
M tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js
16 files changed, 502 insertions(+), 68 deletions(-)

Approvals:
  jenkins-bot: Verified
  Mooeypoo: Looks good to me, approved



diff --git a/languages/i18n/en.json b/languages/i18n/en.json
index 9b1e04a..bea009b 100644
--- a/languages/i18n/en.json
+++ b/languages/i18n/en.json
@@ -1481,6 +1481,11 @@
        "rcfilters-watchlist-showupdated": "Changes to pages you haven't 
visited since the changes occurred are in <strong>bold</strong>, with solid 
markers.",
        "rcfilters-preference-label": "Hide the improved version of Recent 
Changes",
        "rcfilters-preference-help": "Rolls back the 2017 interface redesign 
and all tools added then and since.",
+       "rcfilters-filter-showlinkedfrom-label": "Show changes on pages linked 
from:",
+       "rcfilters-filter-showlinkedfrom-option-label": "Show changes on pages 
linked FROM a page",
+       "rcfilters-filter-showlinkedto-label": "Show changes on pages linked 
to:",
+       "rcfilters-filter-showlinkedto-option-label": "Show changes on pages 
linked TO a page",
+       "rcfilters-target-page-placeholder": "Select a page",
        "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",
diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json
index 60cbed1..2e09de5 100644
--- a/languages/i18n/qqq.json
+++ b/languages/i18n/qqq.json
@@ -1676,6 +1676,11 @@
        "rcfilters-watchlist-showupdated": "Message at the top of 
[[Special:Watchlist]] when the Structured filters are enabled that describes 
what unseen changes look like.\n\nCf. {{msg-mw|wlheader-showupdated}}",
        "rcfilters-preference-label": "Option in RecentChanges tab of 
[[Special:Preferences]].",
        "rcfilters-preference-help": "Explanation for the option in the 
RecentChanges tab of [[Special:Preferences]].",
+       "rcfilters-filter-showlinkedfrom-label": "Label that indicates that the 
page is showing changes that link FROM the target page. Used on 
[[Special:Recentchangeslinked]] when structured filters are enabled.",
+       "rcfilters-filter-showlinkedfrom-option-label": "Menu option to show 
changes FROM the target page. Used on [[Special:Recentchangeslinked]] when 
structured filters are enabled.",
+       "rcfilters-filter-showlinkedto-label": "Label that indicates that the 
page is showing changes that link TO the target page. Used on 
[[Special:Recentchangeslinked]] when structured filters are enabled.",
+       "rcfilters-filter-showlinkedto-option-label": "Menu option to show 
changes TO the target page. Used on [[Special:Recentchangeslinked]] when 
structured filters are enabled.",
+       "rcfilters-target-page-placeholder": "Placeholder text for the title 
lookup [[Special:Recentchangeslinked]] when structured filters are enabled.",
        "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 0665a2a..6b55ef9 100644
--- a/resources/Resources.php
+++ b/resources/Resources.php
@@ -1807,6 +1807,9 @@
                        
'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js',
                        
'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js',
                        
'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js',
+                       
'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js',
+                       
'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js',
+                       
'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js',
                        
'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js',
                        
'resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js',
                        
'resources/src/mediawiki.rcfilters/mw.rcfilters.init.js',
@@ -1836,6 +1839,7 @@
                        
'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less',
                        
'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less',
                        
'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RcTopSectionWidget.less',
+                       
'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less',
                        
'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.WatchlistTopSectionWidget.less',
                ],
                'skinStyles' => [
@@ -1906,6 +1910,11 @@
                        'rcfilters-watchlist-markseen-button',
                        'rcfilters-watchlist-edit-watchlist-button',
                        'rcfilters-other-review-tools',
+                       'rcfilters-filter-showlinkedfrom-label',
+                       'rcfilters-filter-showlinkedfrom-option-label',
+                       'rcfilters-filter-showlinkedto-label',
+                       'rcfilters-filter-showlinkedto-option-label',
+                       'rcfilters-target-page-placeholder',
                        'blanknamespace',
                        'namespaces',
                        'tags-title',
@@ -1921,6 +1930,7 @@
                        'mediawiki.language',
                        'mediawiki.user',
                        'mediawiki.util',
+                       'mediawiki.widgets',
                        'mediawiki.rcfilters.filters.dm',
                        'oojs-ui.styles.icons-content',
                        'oojs-ui.styles.icons-moderation',
diff --git 
a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js 
b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js
index c6eb635..1950b93 100644
--- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js
+++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js
@@ -150,6 +150,8 @@
                                // 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;
                        }
                } );
 
@@ -578,7 +580,7 @@
                        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.isSelected();
+                               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
@@ -598,7 +600,8 @@
                // Build result
                if (
                        this.getType() === 'send_unselected_if_any' ||
-                       this.getType() === 'boolean'
+                       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
@@ -615,6 +618,8 @@
                                        // 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' ) {
@@ -665,7 +670,8 @@
                paramRepresentation = paramRepresentation || {};
                if (
                        this.getType() === 'send_unselected_if_any' ||
-                       this.getType() === 'boolean'
+                       this.getType() === 'boolean' ||
+                       this.getType() === 'any_value'
                ) {
                        // Go over param representation; map and check for 
selections
                        this.getItems().forEach( function ( filterItem ) {
@@ -694,6 +700,8 @@
                                } 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' ) {
@@ -738,9 +746,9 @@
                // If any filters are missing, they will get a falsey value
                this.getItems().forEach( function ( filterItem ) {
                        if ( result[ filterItem.getName() ] === undefined ) {
-                               result[ filterItem.getName() ] = false;
+                               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
@@ -763,6 +771,13 @@
        };
 
        /**
+        * @return {*} The appropriate falsy value for this group type
+        */
+       mw.rcfilters.dm.FilterGroup.prototype.getFalsyValue = function () {
+               return this.getType() === 'any_value' ? '' : false;
+       };
+
+       /**
         * Get current selected state of all filter items in this group
         *
         * @return {Object} Selected state
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 4acbc55..8d22c23 100644
--- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
+++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
@@ -385,7 +385,8 @@
                $.each( this.groups, function ( group, groupModel ) {
                        if (
                                groupModel.getType() === 
'send_unselected_if_any' ||
-                               groupModel.getType() === 'boolean'
+                               groupModel.getType() === 'boolean' ||
+                               groupModel.getType() === 'any_value'
                        ) {
                                // Individual filters
                                groupModel.getItems().forEach( function ( 
filterItem ) {
@@ -414,18 +415,18 @@
         * @param {Object} params Parameters object
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.updateStateFromParams = 
function ( params ) {
+               var filtersValue;
                // For arbitrary numeric single_option values make sure the 
values
                // are normalized to fit within the limits
                $.each( this.getFilterGroups(), function ( groupName, 
groupModel ) {
                        params[ groupName ] = 
groupModel.normalizeArbitraryValue( params[ groupName ] );
                } );
 
-               // Update filter states
-               this.toggleFiltersSelected(
-                       this.getFiltersFromParameters(
-                               params
-                       )
-               );
+               // 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 ) {
@@ -619,7 +620,7 @@
        /**
         * Get the current selected state of the filters
         *
-        * @param {boolean} onlySelected return an object containing only the 
selected filters
+        * @param {boolean} [onlySelected] return an object containing only the 
filters with a value
         * @return {Object} Filters selected state
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function 
( onlySelected ) {
@@ -628,8 +629,8 @@
                        result = {};
 
                for ( i = 0; i < items.length; i++ ) {
-                       if ( !onlySelected || items[ i ].isSelected() ) {
-                               result[ items[ i ].getName() ] = items[ i 
].isSelected();
+                       if ( !onlySelected || items[ i ].getValue() ) {
+                               result[ items[ i ].getName() ] = items[ i 
].getValue();
                        }
                }
 
@@ -739,7 +740,7 @@
                        // all filters (set to false)
                        this.getItems().forEach( function ( filterItem ) {
                                groupItemDefinition[ filterItem.getGroupName() 
] = groupItemDefinition[ filterItem.getGroupName() ] || {};
-                               groupItemDefinition[ filterItem.getGroupName() 
][ filterItem.getName() ] = !!filterDefinition[ filterItem.getName() ];
+                               groupItemDefinition[ filterItem.getGroupName() 
][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ 
filterItem.getName() ] );
                        } );
                }
 
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js 
b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js
index 44b6c8c..d1e40ca 100644
--- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js
+++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js
@@ -14,6 +14,7 @@
         *  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
@@ -34,7 +35,7 @@
                this.label = config.label || this.name;
                this.labelPrefixKey = config.labelPrefixKey;
                this.description = config.description || '';
-               this.selected = !!config.selected;
+               this.setValue( config.value || config.selected );
 
                this.identifiers = config.identifiers || [];
 
@@ -151,7 +152,7 @@
         * @return {boolean} Filter is selected
         */
        mw.rcfilters.dm.ItemModel.prototype.isSelected = function () {
-               return this.selected;
+               return !!this.value;
        };
 
        /**
@@ -161,10 +162,38 @@
         * @fires update
         */
        mw.rcfilters.dm.ItemModel.prototype.toggleSelected = function ( 
isSelected ) {
-               isSelected = isSelected === undefined ? !this.selected : 
isSelected;
+               isSelected = isSelected === undefined ? !this.isSelected() : 
isSelected;
+               this.setValue( isSelected );
+       };
 
-               if ( this.selected !== isSelected ) {
-                       this.selected = isSelected;
+       /**
+        * Get the value
+        *
+        * @return {*}
+        */
+       mw.rcfilters.dm.ItemModel.prototype.getValue = function () {
+               return this.value;
+       };
+
+       /**
+        * Convert a given value to the appropriate representation based on 
group type
+        *
+        * @param {*} value
+        * @return {*}
+        */
+       mw.rcfilters.dm.ItemModel.prototype.coerceValue = function ( value ) {
+               return this.getGroupModel().getType() === 'any_value' ? value : 
!!value;
+       };
+
+       /**
+        * Set the value
+        *
+        * @param {*} newValue
+        */
+       mw.rcfilters.dm.ItemModel.prototype.setValue = function ( newValue ) {
+               newValue = this.coerceValue( newValue );
+               if ( this.value !== newValue ) {
+                       this.value = newValue;
                        this.emit( 'update' );
                }
        };
diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js 
b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
index 0bb6acf..ba54755 100644
--- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
+++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
@@ -185,6 +185,37 @@
                        ]
                };
 
+               views.recentChangesLinked = {
+                       groups: [
+                               {
+                                       name: 'page',
+                                       type: 'any_value',
+                                       title: '',
+                                       hidden: true,
+                                       isSticky: false,
+                                       filters: [
+                                               {
+                                                       name: 'target',
+                                                       'default': ''
+                                               }
+                                       ]
+                               },
+                               {
+                                       name: 'toOrFrom',
+                                       type: 'boolean',
+                                       title: '',
+                                       hidden: true,
+                                       isSticky: false,
+                                       filters: [
+                                               {
+                                                       name: 'showlinkedto',
+                                                       'default': false
+                                               }
+                                       ]
+                               }
+                       ]
+               };
+
                // 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:
@@ -521,6 +552,33 @@
        };
 
        /**
+        * Set the value of the 'showlinkedto' parameter
+        * @param {boolean} value
+        */
+       mw.rcfilters.Controller.prototype.setShowLinkedTo = function ( value ) {
+               var targetItem = this.filtersModel.getGroup( 'page' 
).getItemByParamName( 'target' ),
+                       showLinkedToItem = this.filtersModel.getGroup( 
'toOrFrom' ).getItemByParamName( 'showlinkedto' );
+
+               this.filtersModel.toggleFilterSelected( 
showLinkedToItem.getName(), value );
+               this.uriProcessor.updateURL();
+               // reload the results only when target is set
+               if ( targetItem.getValue() ) {
+                       this.updateChangesList();
+               }
+       };
+
+       /**
+        * Set the target page
+        * @param {string} page
+        */
+       mw.rcfilters.Controller.prototype.setTargetPage = function ( page ) {
+               var targetItem = this.filtersModel.getGroup( 'page' 
).getItemByParamName( 'target' );
+               targetItem.setValue( page );
+               this.uriProcessor.updateURL();
+               this.updateChangesList();
+       };
+
+       /**
         * Set the highlight color for a filter item
         *
         * @param {string} filterName Name of the filter item
@@ -850,7 +908,7 @@
        mw.rcfilters.Controller.prototype.updateStateFromUrl = function ( 
fetchChangesList ) {
                fetchChangesList = fetchChangesList === undefined ? true : 
!!fetchChangesList;
 
-               this.uriProcessor.updateModelBasedOnQuery( new mw.Uri().query );
+               this.uriProcessor.updateModelBasedOnQuery();
 
                // Update the sticky preferences, in case we received a value
                // from the URL
@@ -1000,10 +1058,11 @@
                                                };
                                        }
 
-                                       $parsed = $( '<div>' ).append( $( 
$.parseHTML( data.content ) ) );
+                                       $parsed = $( '<div>' ).append( $( 
$.parseHTML(
+                                               data ? data.content : ''
+                                       ) ) );
 
                                        return this._extractChangesListInfo( 
$parsed );
-
                                }.bind( this )
                        );
        };
diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js 
b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js
index 0392f34..3e1191f 100644
--- a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js
+++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js
@@ -57,38 +57,20 @@
        /**
         * Get an updated mw.Uri object based on the model state
         *
-        * @param {Object} [uriQuery] An external URI query to build the new uri
-        *  with. This is mainly for tests, to be able to supply external 
parameters
-        *  and make sure they are retained.
+        * @param {mw.Uri} [uri] An external URI to build the new uri
+        *  with. This is mainly for tests, to be able to supply external query
+        *  parameters and make sure they are retained.
         * @return {mw.Uri} Updated Uri
         */
-       mw.rcfilters.UriProcessor.prototype.getUpdatedUri = function ( uriQuery 
) {
-               var titlePieces,
-                       uri = new mw.Uri(),
-                       unrecognizedParams = this.getUnrecognizedParams( 
uriQuery || uri.query );
+       mw.rcfilters.UriProcessor.prototype.getUpdatedUri = function ( uri ) {
+               var normalizedUri = this._normalizeTargetInUri( uri || new 
mw.Uri() ),
+                       unrecognizedParams = this.getUnrecognizedParams( 
normalizedUri.query );
 
-               if ( uriQuery ) {
-                       // This is mainly for tests, to be able to give the 
method
-                       // an initial URI Query and test that it retains 
parameters
-                       uri.query = uriQuery;
-               }
-
-               // Normalize subpage to use &target= so we are always
-               // consistent in Special:RecentChangesLinked between the
-               // ?title=Special:RecentChangesLinked/TargetPage and
-               // ?title=Special:RecentChangesLinked&target=TargetPage
-               if ( uri.query.title && uri.query.title.indexOf( '/' ) !== -1 ) 
{
-                       titlePieces = uri.query.title.split( '/' );
-
-                       unrecognizedParams.title = titlePieces.shift();
-                       unrecognizedParams.target = titlePieces.join( '/' );
-               }
-
-               uri.query = this.filtersModel.getMinimizedParamRepresentation(
+               normalizedUri.query = 
this.filtersModel.getMinimizedParamRepresentation(
                        $.extend(
                                true,
                                {},
-                               uri.query,
+                               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
@@ -98,7 +80,44 @@
                );
 
                // Reapply unrecognized params and url version
-               uri.query = $.extend( true, {}, uri.query, unrecognizedParams, 
{ urlversion: '2' } );
+               normalizedUri.query = $.extend(
+                       true,
+                       {},
+                       normalizedUri.query,
+                       unrecognizedParams,
+                       { urlversion: '2' }
+               );
+
+               return normalizedUri;
+       };
+
+       /**
+        * Move the subpage to the target parameter
+        *
+        * @param {mw.Uri} uri
+        * @return {mw.Uri}
+        * @private
+        */
+       mw.rcfilters.UriProcessor.prototype._normalizeTargetInUri = function ( 
uri ) {
+               var parts,
+                       re = /^((?:\/.+\/)?.+:.+)\/(.+)$/; // matches 
[namespace:]Title/Subpage
+
+               // target in title param
+               if ( uri.query.title ) {
+                       parts = uri.query.title.match( re );
+                       if ( parts ) {
+                               uri.query.title = parts[ 1 ];
+                               uri.query.target = parts[ 2 ];
+                       }
+               }
+
+               // target in path
+               parts = uri.path.match( re );
+               if ( parts ) {
+                       uri.path = parts[ 1 ];
+                       uri.query.target = parts[ 2 ];
+               }
+
                return uri;
        };
 
@@ -154,15 +173,16 @@
         * 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 initialiation.
+        * This methods should only be called once on initialization.
         * After initialization, the model updates the URL, not the
         * other way around.
         *
         * @param {Object} [uriQuery] URI query
         */
        mw.rcfilters.UriProcessor.prototype.updateModelBasedOnQuery = function 
( uriQuery ) {
+               uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() 
).query;
                this.filtersModel.updateStateFromParams(
-                       this._getNormalizedQueryParams( uriQuery || new 
mw.Uri().query )
+                       this._getNormalizedQueryParams( uriQuery )
                );
        };
 
diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js 
b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
index 10bbcf6..6ec1200 100644
--- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
+++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
@@ -9,9 +9,8 @@
                 */
                init: function () {
                        var $topLinks,
-                               rcTopSection,
+                               topSection,
                                $watchlistDetails,
-                               wlTopSection,
                                namespaces,
                                savedQueriesPreferenceName = mw.config.get( 
'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
                                daysPreferenceName = mw.config.get( 
'wgStructuredChangeFiltersDaysPreferenceName' ),
@@ -75,25 +74,33 @@
 
                        controller.replaceUrl();
 
-                       if ( specialPage === 'Recentchanges' ||
-                               specialPage === 'Recentchangeslinked' ) {
+                       if ( specialPage === 'Recentchanges' ) {
                                $topLinks = $( '.mw-recentchanges-toplinks' 
).detach();
 
-                               rcTopSection = new 
mw.rcfilters.ui.RcTopSectionWidget(
+                               topSection = new 
mw.rcfilters.ui.RcTopSectionWidget(
                                        savedLinksListWidget, $topLinks
                                );
-                               filtersWidget.setTopSection( 
rcTopSection.$element );
-                       } // end Special:RC
+                               filtersWidget.setTopSection( 
topSection.$element );
+                       } // end Recentchanges
+
+                       if ( specialPage === 'Recentchangeslinked' ) {
+                               topSection = new 
mw.rcfilters.ui.RclTopSectionWidget(
+                                       savedLinksListWidget, controller,
+                                       filtersModel.getGroup( 'toOrFrom' 
).getItemByParamName( 'showlinkedto' ),
+                                       filtersModel.getGroup( 'page' 
).getItemByParamName( 'target' )
+                               );
+                               filtersWidget.setTopSection( 
topSection.$element );
+                       } // end Recentchangeslinked
 
                        if ( specialPage === 'Watchlist' ) {
                                $( '#contentSub, form#mw-watchlist-resetbutton' 
).detach();
                                $watchlistDetails = $( '.watchlistDetails' 
).detach().contents();
 
-                               wlTopSection = new 
mw.rcfilters.ui.WatchlistTopSectionWidget(
+                               topSection = new 
mw.rcfilters.ui.WatchlistTopSectionWidget(
                                        controller, changesListModel, 
savedLinksListWidget, $watchlistDetails
                                );
-                               filtersWidget.setTopSection( 
wlTopSection.$element );
-                       } // end Special:WL
+                               filtersWidget.setTopSection( 
topSection.$element );
+                       } // end Watchlist
 
                        /**
                         * Fired when initialization of the filtering interface 
for changes list is complete.
diff --git 
a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less
 
b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less
new file mode 100644
index 0000000..577c254
--- /dev/null
+++ 
b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less
@@ -0,0 +1,11 @@
+.mw-rcfilters-ui-rclToOrFromWidget {
+       min-width: 340px;
+
+       // need to be very specific to override bg-color
+       &.oo-ui-dropdownWidget.oo-ui-widget-enabled {
+               .oo-ui-dropdownWidget-handle {
+                       border: 0;
+                       background-color: transparent;
+               }
+       }
+}
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 6aa335a..237a635 100644
--- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js
+++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js
@@ -133,6 +133,9 @@
                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();
 
diff --git 
a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js 
b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js
new file mode 100644
index 0000000..d14681b
--- /dev/null
+++ 
b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js
@@ -0,0 +1,73 @@
+( function ( mw ) {
+       /**
+        * Widget to select and display target page on 
Special:RecentChangesLinked (AKA Related Changes)
+        *
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FilterItem} targetPageModel
+        * @param {Object} [config] Configuration object
+        */
+       mw.rcfilters.ui.RclTargetPageWidget = function 
MwRcfiltersUiRclTargetPageWidget(
+               controller, targetPageModel, config
+       ) {
+               config = config || {};
+
+               // Parent
+               mw.rcfilters.ui.RclTargetPageWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = targetPageModel;
+
+               this.titleSearch = new mw.widgets.TitleInputWidget( {
+                       validate: false,
+                       placeholder: mw.msg( 
'rcfilters-target-page-placeholder' )
+               } );
+
+               // Events
+               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+
+               this.titleSearch.$input.on( {
+                       blur: this.onLookupInputBlur.bind( this )
+               } );
+
+               this.titleSearch.lookupMenu.connect( this, {
+                       choose: 'onLookupMenuItemChoose'
+               } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
+                       .append( this.titleSearch.$element );
+
+               this.updateUiBasedOnModel();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( mw.rcfilters.ui.RclTargetPageWidget, OO.ui.Widget );
+
+       /* Methods */
+
+       /**
+        * Respond to the user choosing a title
+        */
+       mw.rcfilters.ui.RclTargetPageWidget.prototype.onLookupMenuItemChoose = 
function () {
+               this.titleSearch.$input.blur();
+       };
+
+       /**
+        * Respond to titleSearch $input blur
+        */
+       mw.rcfilters.ui.RclTargetPageWidget.prototype.onLookupInputBlur = 
function () {
+               this.controller.setTargetPage( this.titleSearch.getQueryValue() 
);
+       };
+
+       /**
+        * Respond to the model being updated
+        */
+       mw.rcfilters.ui.RclTargetPageWidget.prototype.updateUiBasedOnModel = 
function () {
+               this.titleSearch.setValue( this.model.getValue() );
+       };
+}( mediaWiki ) );
diff --git 
a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js 
b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js
new file mode 100644
index 0000000..e91fe9b
--- /dev/null
+++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js
@@ -0,0 +1,73 @@
+( function ( mw ) {
+       /**
+        * Widget to select to view changes that link TO or FROM the target page
+        * on Special:RecentChangesLinked (AKA Related Changes)
+        *
+        * @extends OO.ui.DropdownWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this 
widget is bound to
+        * @param {Object} [config] Configuration object
+        */
+       mw.rcfilters.ui.RclToOrFromWidget = function 
MwRcfiltersUiRclToOrFromWidget(
+               controller, showLinkedToModel, config
+       ) {
+               config = config || {};
+
+               this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
+                       data: 'from', // default (showlinkedto=0)
+                       label: mw.msg( 
'rcfilters-filter-showlinkedfrom-option-label' )
+               } );
+               this.showLinkedTo = new OO.ui.MenuOptionWidget( {
+                       data: 'to', // showlinkedto=1
+                       label: mw.msg( 
'rcfilters-filter-showlinkedto-option-label' )
+               } );
+
+               // Parent
+               mw.rcfilters.ui.RclToOrFromWidget.parent.call( this, $.extend( {
+                       classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
+                       menu: { items: [ this.showLinkedFrom, this.showLinkedTo 
] }
+               }, config ) );
+
+               this.controller = controller;
+               this.model = showLinkedToModel;
+
+               this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
+               this.model.connect( this, { update: 'onModelUpdate' } );
+
+               // force an initial update of the component based on the state
+               this.onModelUpdate();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( mw.rcfilters.ui.RclToOrFromWidget, 
OO.ui.DropdownWidget );
+
+       /* Methods */
+
+       /**
+        * Respond to the user choosing an item in the menu
+        *
+        * @param {OO.ui.MenuOptionWidget} chosenItem
+        */
+       mw.rcfilters.ui.RclToOrFromWidget.prototype.onUserChooseItem = function 
( chosenItem ) {
+               this.controller.setShowLinkedTo( chosenItem.getData() === 'to' 
);
+       };
+
+       /**
+        * Respond to model update
+        */
+       mw.rcfilters.ui.RclToOrFromWidget.prototype.onModelUpdate = function () 
{
+               this.getMenu().selectItem(
+                       this.model.isSelected() ?
+                               this.showLinkedTo :
+                               this.showLinkedFrom
+               );
+               this.setLabel( mw.msg(
+                       this.model.isSelected() ?
+                               'rcfilters-filter-showlinkedto-label' :
+                               'rcfilters-filter-showlinkedfrom-label'
+               ) );
+       };
+}( mediaWiki ) );
diff --git 
a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js 
b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js
new file mode 100644
index 0000000..2fdf365
--- /dev/null
+++ 
b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js
@@ -0,0 +1,66 @@
+( function ( mw ) {
+       /**
+        * Top section (between page title and filters) on 
Special:RecentChangesLinked (AKA RelatedChanges)
+        *
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 
'showlinkedto' parameter
+        * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 
'target' parameter
+        * @param {Object} [config] Configuration object
+        */
+       mw.rcfilters.ui.RclTopSectionWidget = function 
MwRcfiltersUiRclTopSectionWidget(
+               savedLinksListWidget, controller, showLinkedToModel, 
targetPageModel, config
+       ) {
+               var toOrFromWidget,
+                       targetPage;
+               config = config || {};
+
+               // Parent
+               mw.rcfilters.ui.RclTopSectionWidget.parent.call( this, config );
+
+               this.controller = controller;
+
+               toOrFromWidget = new mw.rcfilters.ui.RclToOrFromWidget( 
controller, showLinkedToModel );
+               targetPage = new mw.rcfilters.ui.RclTargetPageWidget( 
controller, targetPageModel );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 
'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       
.addClass( 'mw-rcfilters-ui-cell' )
+                                                                       
.append( toOrFromWidget.$element )
+                                                       ),
+                                               $( '<div>' )
+                                                       .addClass( 
'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       
.addClass( 'mw-rcfilters-ui-cell' )
+                                                                       
.append( targetPage.$element ),
+                                                               $( '<div>' )
+                                                                       
.addClass( 'mw-rcfilters-ui-table-placeholder' )
+                                                                       
.addClass( 'mw-rcfilters-ui-cell' ),
+                                                               
!mw.user.isAnon() ?
+                                                                       $( 
'<div>' )
+                                                                               
.addClass( 'mw-rcfilters-ui-cell' )
+                                                                               
.addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
+                                                                               
.append( savedLinksListWidget.$element ) :
+                                                                       null
+                                                       )
+                                       )
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( mw.rcfilters.ui.RclTopSectionWidget, OO.ui.Widget );
+}( mediaWiki ) );
diff --git 
a/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js 
b/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js
index e106b12..674bf07 100644
--- a/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js
+++ b/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js
@@ -60,19 +60,24 @@
 
        QUnit.test( 'getUpdatedUri', function ( assert ) {
                var uriProcessor,
-                       filtersModel = new mw.rcfilters.dm.FiltersViewModel();
+                       filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+                       makeUri = function ( queryParams ) {
+                               var uri = new mw.Uri();
+                               uri.query = queryParams;
+                               return uri;
+                       };
 
                filtersModel.initializeFilters( mockFilterStructure );
                uriProcessor = new mw.rcfilters.UriProcessor( filtersModel );
 
                assert.deepEqual(
-                       ( uriProcessor.getUpdatedUri( {} ) ).query,
+                       ( uriProcessor.getUpdatedUri( makeUri( {} ) ) ).query,
                        { urlversion: '2' },
                        'Empty model state with empty uri state, assumes the 
given uri is already normalized, and adds urlversion=2'
                );
 
                assert.deepEqual(
-                       ( uriProcessor.getUpdatedUri( { foo: 'bar' } ) ).query,
+                       ( uriProcessor.getUpdatedUri( makeUri( { foo: 'bar' } ) 
) ).query,
                        { urlversion: '2', foo: 'bar' },
                        'Empty model state with unrecognized params retains 
unrecognized params'
                );
@@ -84,13 +89,13 @@
                } );
 
                assert.deepEqual(
-                       ( uriProcessor.getUpdatedUri( {} ) ).query,
+                       ( uriProcessor.getUpdatedUri( makeUri( {} ) ) ).query,
                        { urlversion: '2', filter2: '1', group3: 'filter5' },
                        'Model state is reflected in the updated URI'
                );
 
                assert.deepEqual(
-                       ( uriProcessor.getUpdatedUri( { foo: 'bar' } ) ).query,
+                       ( uriProcessor.getUpdatedUri( makeUri( { foo: 'bar' } ) 
) ).query,
                        { urlversion: '2', filter2: '1', group3: 'filter5', 
foo: 'bar' },
                        'Model state is reflected in the updated URI with 
existing uri params'
                );
@@ -272,4 +277,38 @@
                } );
        } );
 
+       QUnit.test( '_normalizeTargetInUri', function ( assert ) {
+               var uriProcessor = new mw.rcfilters.UriProcessor( null ),
+                       cases = [
+                               {
+                                       input: 
'http://host/wiki/Special:RecentChangesLinked/Moai',
+                                       output: 
'http://host/wiki/Special:RecentChangesLinked?target=Moai',
+                                       message: 'Target as subpage in path'
+                               },
+                               {
+                                       input: 
'http://host/wiki/Special:RecentChangesLinked/Category:Foo',
+                                       output: 
'http://host/wiki/Special:RecentChangesLinked?target=Category:Foo',
+                                       message: 'Target as subpage in path 
(with namespace)'
+                               },
+                               {
+                                       input: 
'http://host/w/index.php?title=Special:RecentChangesLinked/Moai',
+                                       output: 
'http://host/w/index.php?title=Special:RecentChangesLinked&target=Moai',
+                                       message: 'Target as subpage in title 
param'
+                               },
+                               {
+                                       input: 
'http://host/wiki/Special:Watchlist',
+                                       output: 
'http://host/wiki/Special:Watchlist',
+                                       message: 'No target specified'
+                               }
+                       ];
+
+               cases.forEach( function ( testCase ) {
+                       assert.equal(
+                               uriProcessor._normalizeTargetInUri( new mw.Uri( 
testCase.input ) ).toString(),
+                               new mw.Uri( testCase.output ).toString(),
+                               testCase.message
+                       );
+               } );
+       } );
+
 }( mediaWiki, jQuery ) );
diff --git 
a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js 
b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js
index 271648f..18a2c9c 100644
--- a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js
+++ b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js
@@ -184,4 +184,22 @@
                        'Events emitted successfully.'
                );
        } );
+
+       QUnit.test( 'get/set boolean value', function ( assert ) {
+               var group = new mw.rcfilters.dm.FilterGroup( 'group1', { type: 
'boolean' } ),
+                       item = new mw.rcfilters.dm.FilterItem( 'filter1', group 
);
+
+               item.setValue( '1' );
+
+               assert.equal( item.getValue(), true, 'Value is coerced to 
boolean' );
+       } );
+
+       QUnit.test( 'get/set any value', function ( assert ) {
+               var group = new mw.rcfilters.dm.FilterGroup( 'group1', { type: 
'any_value' } ),
+                       item = new mw.rcfilters.dm.FilterItem( 'filter1', group 
);
+
+               item.setValue( '1' );
+
+               assert.equal( item.getValue(), '1', 'Value is kept as-is' );
+       } );
 }( mediaWiki ) );

-- 
To view, visit https://gerrit.wikimedia.org/r/378103
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I96af7ba583d03e6ff9833ac3b5f4b80cfd0ee626
Gerrit-PatchSet: 13
Gerrit-Project: mediawiki/core
Gerrit-Branch: master
Gerrit-Owner: Sbisson <[email protected]>
Gerrit-Reviewer: Mooeypoo <[email protected]>
Gerrit-Reviewer: Sbisson <[email protected]>
Gerrit-Reviewer: Siebrand <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to