Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.searchSuggest / searchSuggest.js
blob4d09439d8c7ef0cca943ed9f57aaf49f5c51edb6
1 /*!
2  * Add search suggestions to the search form.
3  */
4 ( function () {
5         // eslint-disable-next-line no-jquery/no-map-util
6         const searchNS = $.map( mw.config.get( 'wgFormattedNamespaces' ), ( nsName, nsID ) => {
7                 if ( nsID >= 0 && mw.user.options.get( 'searchNs' + nsID ) ) {
8                 // Cast string key to number
9                         return Number( nsID );
10                 }
11         } );
13         /**
14          * Convenience library for making searches for titles that match a string.
15          * Loaded via the `mediawiki.searchSuggest` ResourceLoader library.
16          *
17          * @example
18          * mw.loader.using('mediawiki.searchSuggest').then(() => {
19          *   var api = new mw.Api();
20          *   mw.searchSuggest.request(api, 'Dogs that', ( results ) => {
21          *     alert( `Results that match: ${results.join( '\n' );}` );
22          *   });
23          * });
24          * @namespace mw.searchSuggest
25          */
26         mw.searchSuggest = {
27                 /**
28                  * @typedef {Object} mw.searchSuggest~ResponseMetaData
29                  * @property {string} type the contents of the X-OpenSearch-Type response header.
30                  * @property {string} searchId the contents of the X-Search-ID response header.
31                  * @property {string} query
32                  */
33                 /**
34                  * @callback mw.searchSuggest~ResponseFunction
35                  * @param {string[]} titles titles of pages that match search
36                  * @param {ResponseMetaData} meta meta data relating to search.
37                  */
38                 /**
39                  * Queries the wiki and calls response with the result.
40                  *
41                  * @param {mw.Api} api
42                  * @param {string} query
43                  * @param {ResponseFunction} response
44                  * @param {string|number} [limit]
45                  * @param {string|number|string[]|number[]} [namespace]
46                  * @return {jQuery.Deferred}
47                  */
48                 request: function ( api, query, response, limit, namespace ) {
49                         return api.get( {
50                                 formatversion: 2,
51                                 action: 'opensearch',
52                                 search: query,
53                                 namespace: namespace || searchNS,
54                                 limit
55                         } ).done( ( data, jqXHR ) => {
56                                 response( data[ 1 ], {
57                                         type: jqXHR.getResponseHeader( 'X-OpenSearch-Type' ),
58                                         searchId: jqXHR.getResponseHeader( 'X-Search-ID' ),
59                                         query
60                                 } );
61                         } );
62                 }
63         };
65         $( () => {
66                 let api;
67                 // Region where the suggestions box will appear directly below
68                 // (using the same width). Can be a container element or the input
69                 // itself, depending on what suits best in the environment.
70                 // For Vector the suggestion box should align with the simpleSearch
71                 // container's borders, in other skins it should align with the input
72                 // element (not the search form, as that would leave the buttons
73                 // vertically between the input and the suggestions).
74                 const $searchRegion = $( '#simpleSearch, #searchInput' ).first(),
75                         $searchInput = $( '#searchInput' );
76                 let previousSearchText = $searchInput.val();
78                 function serializeObject( fields ) {
79                         const obj = {};
81                         for ( let i = 0; i < fields.length; i++ ) {
82                                 obj[ fields[ i ].name ] = fields[ i ].value;
83                         }
85                         return obj;
86                 }
88                 // Compute form data for search suggestions functionality.
89                 function getFormData( context ) {
90                         if ( !context.formData ) {
91                                 // Compute common parameters for links' hrefs
92                                 const $form = context.config.$region.closest( 'form' );
94                                 let baseHref = $form.attr( 'action' ) || '';
95                                 baseHref += baseHref.indexOf( '?' ) > -1 ? '&' : '?';
97                                 const linkParams = serializeObject( $form.serializeArray() );
99                                 context.formData = {
100                                         textParam: context.data.$textbox.attr( 'name' ),
101                                         linkParams: linkParams,
102                                         baseHref: baseHref
103                                 };
104                         }
106                         return context.formData;
107                 }
109                 /**
110                  * Callback that's run when the user changes the search input text
111                  * 'this' is the search input box (jQuery object)
112                  *
113                  * @ignore
114                  */
115                 function onBeforeUpdate() {
116                         const searchText = this.val();
118                         if ( searchText && searchText !== previousSearchText ) {
119                                 mw.track( 'mediawiki.searchSuggest', {
120                                         action: 'session-start'
121                                 } );
122                         }
123                         previousSearchText = searchText;
124                 }
126                 /**
127                  * Defines the location of autocomplete. Typically either
128                  * header, which is in the top right of vector (for example)
129                  * and content which identifies the main search bar on
130                  * Special:Search. Defaults to header for skins that don't set
131                  * explicitly.
132                  *
133                  * @ignore
134                  * @param {Object} context
135                  * @return {string}
136                  */
137                 function getInputLocation( context ) {
138                         return context.config.$region
139                                 .closest( 'form' )
140                                 .find( '[data-search-loc]' )
141                                 .data( 'search-loc' ) || 'header';
142                 }
144                 /**
145                  * Callback that's run when suggestions have been updated either from the cache or the API
146                  * 'this' is the search input box (jQuery object)
147                  *
148                  * @ignore
149                  * @param {Object} metadata
150                  */
151                 function onAfterUpdate( metadata ) {
152                         const context = this.data( 'suggestionsContext' );
154                         mw.track( 'mediawiki.searchSuggest', {
155                                 action: 'impression-results',
156                                 numberOfResults: context.config.suggestions.length,
157                                 resultSetType: metadata.type || 'unknown',
158                                 searchId: metadata.searchId || null,
159                                 query: metadata.query,
160                                 inputLocation: getInputLocation( context )
161                         } );
162                 }
164                 // The function used to render the suggestions.
165                 function renderFunction( text, context ) {
166                         const formData = getFormData( context ),
167                                 textboxConfig = context.data.$textbox.data( 'mw-searchsuggest' ) || {};
169                         // linkParams object is modified and reused
170                         formData.linkParams[ formData.textParam ] = text;
172                         // Allow trackers to attach tracking information, such
173                         // as wprov, to clicked links.
174                         mw.track( 'mediawiki.searchSuggest', {
175                                 action: 'render-one',
176                                 formData: formData,
177                                 index: context.config.suggestions.indexOf( text )
178                         } );
180                         // this is the container <div>, jQueryfied
181                         this.text( text );
183                         // wrap only as link, if the config doesn't disallow it
184                         if ( textboxConfig.wrapAsLink !== false ) {
185                                 this.wrap(
186                                         $( '<a>' )
187                                                 .attr( 'href', formData.baseHref + $.param( formData.linkParams ) )
188                                                 .attr( 'title', text )
189                                                 .addClass( 'mw-searchSuggest-link' )
190                                 );
191                         }
192                 }
194                 // The function used when the user makes a selection
195                 function selectFunction( $input, source ) {
196                         const context = $input.data( 'suggestionsContext' ),
197                                 text = $input.val(),
198                                 url = $( this ).parent( 'a' ).attr( 'href' );
200                         // We want to track a click-result XOR a submit-form action.
201                         // If the source was 'click' (or otherwise non-'keyboard'),
202                         // track it and then let the rest of the event proceed as normal.
203                         // If the source was 'keyboard', and we have a URL
204                         // (from the <a> that the result was wrapped in, see renderFunction()),
205                         // then also track a click, prevent the regular form submit,
206                         // and instead directly navigate to the URL as if it had been clicked.
207                         // If the source was 'keyboard', but we have no URL,
208                         // then we have to let the regular form submit go through,
209                         // so skip the click tracking in that case to avoid duplicate tracking.
210                         if ( source === 'keyboard' && url || source !== 'keyboard' ) {
211                                 mw.track( 'mediawiki.searchSuggest', {
212                                         action: 'click-result',
213                                         numberOfResults: context.config.suggestions.length,
214                                         index: context.config.suggestions.indexOf( text )
215                                 } );
217                                 if ( source === 'keyboard' ) {
218                                         window.location.assign( url );
219                                         // prevent default and stop propagation
220                                         return false;
221                                 }
222                         }
224                         // allow the form to be submitted
225                         return true;
226                 }
228                 function specialRenderFunction( query, context ) {
229                         const $el = this,
230                                 formData = getFormData( context );
232                         // linkParams object is modified and reused
233                         formData.linkParams[ formData.textParam ] = query;
235                         mw.track( 'mediawiki.searchSuggest', {
236                                 action: 'render-one',
237                                 formData: formData,
238                                 index: context.config.suggestions.indexOf( query )
239                         } );
241                         if ( mw.user.options.get( 'search-match-redirect' ) && $el.children().length === 0 ) {
242                                 $el
243                                         .append(
244                                                 $( '<div>' )
245                                                         .addClass( 'special-label' )
246                                                         .text( mw.msg( 'searchsuggest-containing' ) ),
247                                                 $( '<div>' )
248                                                         .addClass( 'special-query' )
249                                                         .text( query )
250                                         )
251                                         .show();
252                         } else {
253                                 $el.find( '.special-query' )
254                                         .text( query );
255                         }
257                         // eslint-disable-next-line no-jquery/no-class-state
258                         if ( $el.parent().hasClass( 'mw-searchSuggest-link' ) ) {
259                                 $el.parent().attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' );
260                         } else {
261                                 $el.wrap(
262                                         $( '<a>' )
263                                                 .attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' )
264                                                 .addClass( 'mw-searchSuggest-link' )
265                                 );
266                         }
267                 }
269                 // Generic suggestions functionality for all search boxes
270                 const searchboxesSelectors = [
271                         // Primary searchbox on every page in standard skins
272                         '#searchInput',
273                         // Generic selector for skins with multiple searchboxes (used by CologneBlue)
274                         // and for MediaWiki itself (special pages with page title inputs)
275                         '.mw-searchInput'
276                 ];
277                 $( searchboxesSelectors.join( ', ' ) )
278                         .suggestions( {
279                                 fetch: function ( query, response, maxRows ) {
280                                         const node = this[ 0 ];
282                                         api = api || new mw.Api();
284                                         $.data( node, 'request', mw.searchSuggest.request( api, query, response, maxRows ) );
285                                 },
286                                 cancel: function () {
287                                         const node = this[ 0 ],
288                                                 request = $.data( node, 'request' );
290                                         if ( request ) {
291                                                 request.abort();
292                                                 $.removeData( node, 'request' );
293                                         }
294                                 },
295                                 result: {
296                                         render: renderFunction,
297                                         select: function () {
298                                                 // allow the form to be submitted
299                                                 return true;
300                                         }
301                                 },
302                                 update: {
303                                         before: onBeforeUpdate,
304                                         after: onAfterUpdate
305                                 },
306                                 cache: true,
307                                 highlightInput: true
308                         } )
309                         .on( 'paste cut drop', function () {
310                                 // make sure paste and cut events from the mouse and drag&drop events
311                                 // trigger the keypress handler and cause the suggestions to update
312                                 $( this ).trigger( 'keypress' );
313                         } )
314                         // In most skins (at least Monobook and Vector), the font-size is messed up in <body>.
315                         // (they use 2 elements to get a sensible font-height). So, instead of making exceptions for
316                         // each skin or adding more stylesheets, just copy it from the active element so auto-fit.
317                         .each( function () {
318                                 const $this = $( this );
319                                 $this
320                                         .data( 'suggestions-context' )
321                                         .data.$container.css( 'fontSize', $this.css( 'fontSize' ) );
322                         } );
324                 // Ensure that the thing is actually present!
325                 if ( $searchRegion.length === 0 ) {
326                         // Don't try to set anything up if simpleSearch is disabled sitewide.
327                         // The loader code loads us if the option is present, even if we're
328                         // not actually enabled (anymore).
329                         return;
330                 }
332                 // Special suggestions functionality and tracking for skin-provided search box
333                 $searchInput.suggestions( {
334                         update: {
335                                 before: onBeforeUpdate,
336                                 after: onAfterUpdate
337                         },
338                         result: {
339                                 render: renderFunction,
340                                 select: selectFunction
341                         },
342                         special: {
343                                 render: specialRenderFunction,
344                                 select: function ( $input, source ) {
345                                         const context = $input.data( 'suggestionsContext' ),
346                                                 text = $input.val();
347                                         if ( source === 'mouse' ) {
348                                                 // mouse click won't trigger form submission, so we need to send a click event
349                                                 mw.track( 'mediawiki.searchSuggest', {
350                                                         action: 'click-result',
351                                                         numberOfResults: context.config.suggestions.length,
352                                                         index: context.config.suggestions.indexOf( text )
353                                                 } );
354                                         } else {
355                                                 $input.closest( 'form' )
356                                                         .append(
357                                                                 $( '<input>' )
358                                                                         .prop( {
359                                                                                 type: 'hidden',
360                                                                                 value: 1
361                                                                         } )
362                                                                         .attr( 'name', 'fulltext' )
363                                                         );
364                                         }
365                                         return true; // allow the form to be submitted
366                                 }
367                         },
368                         $region: $searchRegion
369                 } );
371                 const $searchForm = $searchInput.closest( 'form' );
372                 $searchForm
373                         // Track the form submit event.
374                         // Note that the form is mainly submitted for manual user input;
375                         // selecting a suggestion is tracked as a click instead (see selectFunction()).
376                         .on( 'submit', () => {
377                                 const context = $searchInput.data( 'suggestionsContext' );
378                                 mw.track( 'mediawiki.searchSuggest', {
379                                         action: 'submit-form',
380                                         numberOfResults: context.config.suggestions.length,
381                                         $form: context.config.$region.closest( 'form' ),
382                                         inputLocation: getInputLocation( context ),
383                                         index: context.config.suggestions.indexOf(
384                                                 context.data.$textbox.val()
385                                         )
386                                 } );
387                         } );
389                 // Check to see if the fulltext search button is placed before the go search button
390                 if ( $searchForm.find( '.mw-fallbackSearchButton ~ .searchButton' ).length ) {
391                         // Submitting the form with enter should always trigger "search within pages"
392                         // for JavaScript capable browsers.
393                         // If it is, remove the "full text search" fallback button.
394                         // In skins, where the "full text search" button
395                         // precedes the "search by title" button, e.g. Vector this is done for
396                         // non-JavaScript support. If the "search by title" button is first,
397                         // and two search buttons are shown e.g. MonoBook no change is needed.
398                         $searchForm.find( '.mw-fallbackSearchButton' ).remove();
399                 }
400         } );
402 }() );