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

Change subject: Template search: Add template autosuggest ability
......................................................................


Template search: Add template autosuggest ability

Add a TagMultiselectWidget subclass that can search the MediaWiki for
templates. Does not show suggestions for items already selected.

Issue: T165302
Change-Id: I9c1c6ca8c1b6c3fdb2d0c2fb100d8d1b4a38770d
---
M AdvancedSearch.hooks.php
M extension.json
M modules/dm/ext.advancedSearch.SearchModel.js
M modules/ext.advancedSearch.init.js
A modules/ui/ext.advancedSearch.TemplateSearch.js
A tests/qunit/ui/TemplateSearch.test.js
6 files changed, 387 insertions(+), 5 deletions(-)

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



diff --git a/AdvancedSearch.hooks.php b/AdvancedSearch.hooks.php
index 98c6c95..2ffab78 100644
--- a/AdvancedSearch.hooks.php
+++ b/AdvancedSearch.hooks.php
@@ -79,12 +79,14 @@
                                'tests/qunit/ui/NamespaceFilters.test.js',
                                'tests/qunit/ui/NamespacePresets.test.js',
                                'tests/qunit/ui/SearchPreview.test.js',
+                               'tests/qunit/ui/TemplateSearch.test.js',
                                'tests/qunit/dm/SearchModel.test.js'
                        ],
                        'dependencies' => [
                                'ext.advancedSearch.ui.NamespaceFilters',
                                'ext.advancedSearch.ui.NamespacePresets',
                                'ext.advancedSearch.ui.SearchPreview',
+                               'ext.advancedSearch.ui.TemplateSearch',
                                'ext.advancedSearch.dm.SearchModel',
                                'oojs-ui'
                        ],
diff --git a/extension.json b/extension.json
index 615a3e2..f0a73bd 100644
--- a/extension.json
+++ b/extension.json
@@ -90,6 +90,7 @@
                                "ext.advancedSearch.ui.OptionalElementLayout",
                                "ext.advancedSearch.ui.OptionalElementLayout",
                                "ext.advancedSearch.ui.SearchPreview",
+                               "ext.advancedSearch.ui.TemplateSearch",
                                "ext.advancedSearch.ui.TextInput"
                        ]
                },
@@ -197,6 +198,14 @@
                                "advancedSearch-filesize-smaller-than"
                        ]
                },
+               "ext.advancedSearch.ui.TemplateSearch": {
+                       "scripts": [
+                               
"modules/ui/ext.advancedSearch.TemplateSearch.js"
+                       ],
+                       "dependencies": [
+                               "oojs-ui"
+                       ]
+               },
                "ext.advancedSearch.ui.TextInput": {
                        "scripts": [
                                "modules/ui/ext.advancedSearch.TextInput.js"
diff --git a/modules/dm/ext.advancedSearch.SearchModel.js 
b/modules/dm/ext.advancedSearch.SearchModel.js
index a137045..e34cbeb 100644
--- a/modules/dm/ext.advancedSearch.SearchModel.js
+++ b/modules/dm/ext.advancedSearch.SearchModel.js
@@ -65,7 +65,7 @@
 
                // TODO check for allowed options?
 
-               if ( OO.compare( this.searchOptions[ optionId ], value ) ) {
+               if ( this.searchOptions[ optionId ] !== undefined && 
OO.compare( this.searchOptions[ optionId ], value ) ) {
                        return;
                }
 
diff --git a/modules/ext.advancedSearch.init.js 
b/modules/ext.advancedSearch.init.js
index f34b979..e923804 100644
--- a/modules/ext.advancedSearch.init.js
+++ b/modules/ext.advancedSearch.init.js
@@ -162,11 +162,10 @@
                                return optionalQuotes( val );
                        },
                        init: function () {
-                               var widget = new 
mw.libs.advancedSearch.ui.ArbitraryWordInput(
+                               return new 
mw.libs.advancedSearch.ui.ArbitraryWordInput(
                                        state,
                                        { optionId: 'or' }
                                );
-                               return widget;
                        }
                },
 
@@ -190,7 +189,14 @@
                                        } ).join( ' ' );
                                }
                                return 'hastemplate:' + optionalQuotes( val );
-                       }
+                       },
+                       init: function () {
+                               return new 
mw.libs.advancedSearch.ui.TemplateSearch(
+                                       state,
+                                       { optionId: 'hastemplate' }
+                               );
+                       },
+                       customEventHandling: true
                },
                {
                        group: 'structure',
@@ -348,7 +354,10 @@
                                } );
                        },
                        widget = widgetInit();
-               widget.on( 'change', createMultiSelectChangeHandler( option.id 
) );
+
+               if ( !option.customEventHandling ) {
+                       widget.on( 'change', createMultiSelectChangeHandler( 
option.id ) );
+               }
 
                if ( !optionSets[ option.group ] ) {
                        optionSets[ option.group ] = new OO.ui.FieldsetLayout( {
diff --git a/modules/ui/ext.advancedSearch.TemplateSearch.js 
b/modules/ui/ext.advancedSearch.TemplateSearch.js
new file mode 100644
index 0000000..dba7f90
--- /dev/null
+++ b/modules/ui/ext.advancedSearch.TemplateSearch.js
@@ -0,0 +1,156 @@
+( function ( mw, $ ) {
+       'use strict';
+
+       mw.libs = mw.libs || {};
+       mw.libs.advancedSearch = mw.libs.advancedSearch || {};
+       mw.libs.advancedSearch.ui = mw.libs.advancedSearch.ui || {};
+
+       /**
+        * @class
+        * @extends {OO.ui.TagMultiselectWidget}
+        * @constructor
+        *
+        * @param  {ext.advancedSearch.dm.SearchModel} store
+        * @param  {Object} config
+        */
+       mw.libs.advancedSearch.ui.TemplateSearch = function ( store, config ) {
+               var myConfig = $.extend( {}, config || {}, {
+                       allowArbitrary: true,
+                       input: {
+                               autocomplete: false
+                       }
+               } );
+               this.store = store;
+               this.optionId = config.optionId;
+               this.api = config.api || new mw.Api();
+
+               this.store.connect( this, { update: 'onStoreUpdate' } );
+
+               mw.libs.advancedSearch.ui.TemplateSearch.parent.call( this, 
myConfig );
+
+               this.$input = this.input.$input;
+
+               this.input.connect( this, { change: 'onLookupInputChange' } );
+
+               // Mixin constructor
+               OO.ui.mixin.LookupElement.call( this, myConfig );
+
+               this.populateFromStore();
+
+               this.connect( this, { change: 'onValueUpdate' } );
+       };
+
+       OO.inheritClass( mw.libs.advancedSearch.ui.TemplateSearch, 
OO.ui.TagMultiselectWidget );
+       OO.mixinClass( mw.libs.advancedSearch.ui.TemplateSearch, 
OO.ui.mixin.LookupElement );
+
+       mw.libs.advancedSearch.ui.TemplateSearch.prototype.onStoreUpdate = 
function () {
+               this.populateFromStore();
+       };
+
+       mw.libs.advancedSearch.ui.TemplateSearch.prototype.populateFromStore = 
function () {
+               // protect parent class from working with an undefined value
+               var value = this.store.getOption( this.optionId ) || [];
+
+               // avoid redundant event triggering if no value change is 
performed
+               if ( OO.compare( value, this.getValue() ) ) {
+                       return;
+               }
+
+               this.setValue( value );
+       };
+
+       /**
+        * Update external states on internal updates
+        */
+       mw.libs.advancedSearch.ui.TemplateSearch.prototype.onValueUpdate = 
function () {
+               this.store.storeOption( this.optionId, this.getValue() );
+       };
+
+       /**
+        * @inheritdoc OO.ui.mixin.LookupElement
+        */
+       mw.libs.advancedSearch.ui.TemplateSearch.prototype.getLookupRequest = 
function () {
+               var value = this.input.getValue();
+
+               // @todo More elegant way to prevent empty API requests?
+               if ( value.trim() === '' ) {
+                       return $.Deferred().reject();
+               }
+
+               return this.api.get( {
+                       action: 'opensearch',
+                       search: this.input.getValue(),
+                       namespace: 10
+               } );
+       };
+
+       /**
+        * @inheritdoc OO.ui.mixin.LookupElement
+        */
+       
mw.libs.advancedSearch.ui.TemplateSearch.prototype.getLookupCacheDataFromResponse
 = function ( response ) {
+               return response || [];
+       };
+
+       /**
+        * @inheritdoc OO.ui.mixin.LookupElement
+        */
+       
mw.libs.advancedSearch.ui.TemplateSearch.prototype.getLookupMenuOptionsFromData 
= function ( data ) {
+               var
+                       items = [],
+                       i, templateNameWithoutNamespace,
+                       currentValues = this.getValue();
+               for ( i = 0; i < data[ 1 ].length; i++ ) {
+                       templateNameWithoutNamespace = this.removeNamespace( 
data[ 1 ][ i ] );
+
+                       // do not show suggestions for items already selected
+                       if ( currentValues.indexOf( 
templateNameWithoutNamespace ) !== -1 ) {
+                               continue;
+                       }
+
+                       items.push( new OO.ui.MenuOptionWidget( {
+                               data: templateNameWithoutNamespace,
+                               label: templateNameWithoutNamespace
+                       } ) );
+               }
+               return items;
+       };
+
+       /**
+        * Get the name part of a page title containing a namespace
+        *
+        * @param {string} pageTitle
+        * @return {string}
+        */
+       mw.libs.advancedSearch.ui.TemplateSearch.prototype.removeNamespace = 
function ( pageTitle ) {
+               return mw.Title.newFromText( pageTitle ).getNameText();
+       };
+
+       /**
+        * Override behavior from OO.ui.mixin.LookupElement
+        *
+        * @param {OO.ui.TagItemWidget} item
+        */
+       
mw.libs.advancedSearch.ui.TemplateSearch.prototype.onLookupMenuItemChoose = 
function ( item ) {
+               this.addTag( item.getData() );
+               this.input.setValue( '' );
+       };
+
+       /**
+        * Override to make sure query caching is based on the correct (input) 
value
+        *
+        * @inheritdoc
+        */
+       mw.libs.advancedSearch.ui.TemplateSearch.prototype.getRequestQuery = 
function () {
+               return this.input.getValue();
+       };
+
+       /**
+        * Implemented because OO.ui.mixin.LookupElement expects it.
+        *
+        * @returns {boolean}
+        */
+       mw.libs.advancedSearch.ui.TemplateSearch.prototype.isReadOnly = 
function () {
+               return false;
+       };
+
+}( mediaWiki, jQuery ) );
diff --git a/tests/qunit/ui/TemplateSearch.test.js 
b/tests/qunit/ui/TemplateSearch.test.js
new file mode 100644
index 0000000..86d1f66
--- /dev/null
+++ b/tests/qunit/ui/TemplateSearch.test.js
@@ -0,0 +1,206 @@
+( function ( $, QUnit, sinon, mw ) {
+       'use strict';
+
+       var TemplateSearch,
+               sandbox,
+               store,
+               config;
+
+       QUnit.testStart( function () {
+               TemplateSearch = mw.libs.advancedSearch.ui.TemplateSearch;
+               sandbox = sinon.sandbox.create();
+               store = {
+                       connect: sandbox.stub(),
+                       getOption: sandbox.stub().withArgs( 'hastemplate' 
).returns( [] ),
+                       storeOption: sandbox.stub()
+               };
+               config = {
+                       optionId: 'hastemplate'
+               };
+       } );
+
+       QUnit.testDone( function () {
+               sandbox.restore();
+       } );
+
+       QUnit.module( 'ext.advancedSearch.ui.TemplateSearch' );
+
+       QUnit.test( 'Store data subscribed to and synced initially', 4, 
function ( assert ) {
+               var setValueSpy = sandbox.spy( TemplateSearch.prototype, 
'setValue' );
+               store.getOption.withArgs( 'hastemplate' ).returns( [ 'Burg' ] );
+
+               var templateSearch = new TemplateSearch( store, config );
+
+               assert.ok( templateSearch );
+               assert.ok( store.connect.calledOnce );
+               assert.ok( setValueSpy.withArgs( [ 'Burg' ] ).calledOnce );
+               assert.deepEqual( templateSearch.getValue(), [ 'Burg' ] );
+       } );
+
+       QUnit.test( 'Store update is applied', 1, function ( assert ) {
+               store.getOption.withArgs( 'hastemplate' ).returns( [ 'from', 
'beyond' ] );
+
+               var templateSearch = new TemplateSearch( store, config );
+
+               templateSearch.onStoreUpdate();
+
+               assert.deepEqual( templateSearch.getValue(), [ 'from', 'beyond' 
] );
+       } );
+
+       QUnit.test( 'Mixin method overridden to prevent problems', 1, function 
( assert ) {
+               var templateSearch = new TemplateSearch( store, config );
+               assert.notOk( templateSearch.isReadOnly() );
+       } );
+
+       QUnit.test( 'API response processed correctly', 8, function ( assert ) {
+               var templateSearch = new TemplateSearch( store, config );
+
+               var apiData = [
+                       'j',
+                       [
+                               'Template:Jochen',
+                               'Template:Jens',
+                               'Template:Johannes'
+                       ],
+                       [
+                               '',
+                               '',
+                               ''
+                       ],
+                       [
+                               'http://mywiki/index.php?title=Template:Jochen',
+                               'http://mywiki/index.php?title=Template:Jens',
+                               
'http://mywiki/index.php?title=Template:Johannes'
+                       ]
+               ];
+
+               var result = templateSearch.getLookupMenuOptionsFromData( 
apiData );
+               assert.ok( $.isArray( result ) );
+               assert.equal( result.length, 3 );
+
+               assert.equal( result[ 0 ].getLabel(), 'Jochen' );
+               assert.equal( result[ 0 ].getData(), 'Jochen' );
+
+               assert.equal( result[ 1 ].getLabel(), 'Jens' );
+               assert.equal( result[ 1 ].getData(), 'Jens' );
+
+               assert.equal( result[ 2 ].getLabel(), 'Johannes' );
+               assert.equal( result[ 2 ].getData(), 'Johannes' );
+       } );
+
+       QUnit.test( 'Items already selected are not suggested', 4, function ( 
assert ) {
+               var templateSearch = new TemplateSearch( store, config );
+               templateSearch.setValue( [ 'Jochen', 'Johannes' ] );
+               var apiData = [
+                       'j',
+                       [
+                               'Template:Jochen',
+                               'Template:Jens',
+                               'Template:Johannes'
+                       ],
+                       [
+                               '',
+                               '',
+                               ''
+                       ],
+                       [
+                               'http://mywiki/index.php?title=Template:Jochen',
+                               'http://mywiki/index.php?title=Template:Jens',
+                               
'http://mywiki/index.php?title=Template:Johannes'
+                       ]
+               ];
+
+               var result = templateSearch.getLookupMenuOptionsFromData( 
apiData );
+               assert.ok( $.isArray( result ) );
+               assert.equal( result.length, 1 );
+
+               assert.equal( result[ 0 ].getLabel(), 'Jens' );
+               assert.equal( result[ 0 ].getData(), 'Jens' );
+       } );
+
+       QUnit.test( 'Page titles post-processes nicely', 2, function ( assert ) 
{
+               var templateSearch = new TemplateSearch( store, config );
+               assert.equal( templateSearch.removeNamespace( 'Test' ), 'Test' 
);
+               assert.equal( templateSearch.removeNamespace( 'Template:Test' 
), 'Test' );
+       } );
+
+       QUnit.test( 'Value picked from menu is added to tags and stored', 6, 
function ( assert ) {
+               var templateSearch = new TemplateSearch( store, config );
+               templateSearch.addTag( 'Preexisting' );
+               templateSearch.input.setValue( 'My Templ' );
+               var item = new OO.ui.TagItemWidget();
+               item.setData( 'My Template' );
+
+               // reset storeOption as is was invoked by addTag( 'Preexisting' 
) before
+               store.storeOption = sandbox.stub();
+
+               templateSearch.onLookupMenuItemChoose( item );
+
+               var tags = templateSearch.getItems();
+               assert.ok( $.isArray( tags ) );
+               assert.equal( tags.length, 2 );
+               assert.equal( tags[ 0 ].getData(), 'Preexisting' );
+               assert.equal( tags[ 1 ].getData(), 'My Template' );
+
+               assert.ok( store.storeOption.withArgs( 'hastemplate', [ 
'Preexisting', 'My Template' ] ).calledOnce );
+
+               assert.equal( templateSearch.input.getValue(), '' );
+       } );
+
+       QUnit.test( 'Native browser autocomplete is not used', 1, function ( 
assert ) {
+               var templateSearch = new TemplateSearch( store, config );
+
+               assert.equal( $( templateSearch.$input ).attr( 'autocomplete' 
), 'off' );
+       } );
+
+       QUnit.test( 'Well-formed API request yields result', 4, function ( 
assert ) {
+               config.api = new mw.Api();
+               var getStub = sandbox.stub( config.api, 'get' ).withArgs( {
+                       action: 'opensearch',
+                       search: 'Burg',
+                       namespace: 10
+               } ).returns( $.Deferred().resolve( [
+                       'Burg',
+                       [
+                               'Template:Burg'
+                       ],
+                       [
+                               ''
+                       ],
+                       [
+                               'http://mywiki/index.php?title=Template:Burg'
+                       ]
+               ] ).promise() );
+
+               var templateSearch = new TemplateSearch( store, config );
+               $( templateSearch.$input ).val( 'Burg' );
+
+               var result = templateSearch.getLookupRequest();
+
+               assert.ok( getStub.calledOnce );
+
+               assert.equal( result.state(), 'resolved' );
+               result.done( function ( doneData ) {
+                       assert.equal( doneData[ 0 ], 'Burg' );
+                       assert.deepEqual( doneData[ 1 ], [ 'Template:Burg' ] );
+               } );
+       } );
+
+       QUnit.test( 'Empty query does not trigger API request', 3, function ( 
assert ) {
+               config.api = new mw.Api();
+               var getStub = sandbox.stub( config.api, 'get' );
+
+               var templateSearch = new TemplateSearch( store, config );
+               $( templateSearch.$input ).val( '' );
+
+               var result = templateSearch.getLookupRequest();
+
+               assert.notOk( getStub.called );
+
+               assert.equal( result.state(), 'rejected' );
+               result.fail( function () {
+                       assert.ok( true, 'A failed promise is returned' );
+               } );
+       } );
+
+}( jQuery, QUnit, sinon, mediaWiki ) );

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

Gerrit-MessageType: merged
Gerrit-Change-Id: I9c1c6ca8c1b6c3fdb2d0c2fb100d8d1b4a38770d
Gerrit-PatchSet: 21
Gerrit-Project: mediawiki/extensions/AdvancedSearch
Gerrit-Branch: master
Gerrit-Owner: Gabriel Birke <[email protected]>
Gerrit-Reviewer: Gabriel Birke <[email protected]>
Gerrit-Reviewer: Jeroen De Dauw <[email protected]>
Gerrit-Reviewer: Kai Nissen (WMDE) <[email protected]>
Gerrit-Reviewer: Pablo Grass (WMDE) <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

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

Reply via email to