wikimedia/mediawiki-core

View on GitHub
resources/src/mediawiki.searchSuggest/searchSuggest.js

Summary

Maintainability
C
1 day
Test Coverage
/*!
 * Add search suggestions to the search form.
 */
( function () {
    // eslint-disable-next-line no-jquery/no-map-util
    var searchNS = $.map( mw.config.get( 'wgFormattedNamespaces' ), function ( nsName, nsID ) {
            if ( nsID >= 0 && mw.user.options.get( 'searchNs' + nsID ) ) {
            // Cast string key to number
                return Number( nsID );
            }
        } ),
        // T251544: Collect search performance metrics to compare Vue search with
        // mediawiki.searchSuggest performance. Marks and Measures will only be
        // recorded on the Vector skin.
        shouldTestSearch = !!( mw.config.get( 'skin' ) === 'vector' &&
            window.performance &&
            performance.mark &&
            performance.measure &&
            performance.getEntriesByName &&
            performance.clearMarks ),

        loadStartMark = 'mwVectorLegacySearchLoadStart',
        queryMark = 'mwVectorLegacySearchQuery',
        renderMark = 'mwVectorLegacySearchRender',
        queryToRenderMeasure = 'mwVectorLegacySearchQueryToRender',
        loadStartToFirstRenderMeasure = 'mwVectorLegacySearchLoadStartToFirstRender';

    /**
     * Convenience library for making searches for titles that match a string.
     * Loaded via the `mediawiki.searchSuggest` ResourceLoader library.
     * @example
     * mw.loader.using('mediawiki.searchSuggest').then(() => {
     *   var api = new mw.Api();
     *   mw.searchSuggest.request(api, 'Dogs that', ( results ) => {
     *     alert( `Results that match: ${results.join( '\n' );}` );
     *   });
     * });
     * @namespace mw.searchSuggest
     */
    mw.searchSuggest = {
        /**
         * @typedef {Object} mw.searchSuggest~ResponseMetaData
         * @property {string} type the contents of the X-OpenSearch-Type response header.
         * @property {string} searchId the contents of the X-Search-ID response header.
         * @property {string} query
         */
        /**
         * @callback mw.searchSuggest~ResponseFunction
         * @param {string[]} titles titles of pages that match search
         * @param {ResponseMetaData} meta meta data relating to search.
         */
        /**
         * Queries the wiki and calls response with the result.
         *
         * @param {mw.Api} api
         * @param {string} query
         * @param {ResponseFunction} response
         * @param {string|number} [limit]
         * @param {string|number|string[]|number[]} [namespace]
         * @return {jQuery.Deferred}
         */
        request: function ( api, query, response, limit, namespace ) {
            return api.get( {
                formatversion: 2,
                action: 'opensearch',
                search: query,
                namespace: namespace || searchNS,
                limit
            } ).done( function ( data, jqXHR ) {
                response( data[ 1 ], {
                    type: jqXHR.getResponseHeader( 'X-OpenSearch-Type' ),
                    searchId: jqXHR.getResponseHeader( 'X-Search-ID' ),
                    query
                } );
            } );
        }
    };

    $( function () {
        var api, searchboxesSelectors,
            // Region where the suggestions box will appear directly below
            // (using the same width). Can be a container element or the input
            // itself, depending on what suits best in the environment.
            // For Vector the suggestion box should align with the simpleSearch
            // container's borders, in other skins it should align with the input
            // element (not the search form, as that would leave the buttons
            // vertically between the input and the suggestions).
            $searchRegion = $( '#simpleSearch, #searchInput' ).first(),
            $searchInput = $( '#searchInput' ),
            previousSearchText = $searchInput.val();

        function serializeObject( fields ) {
            var i,
                obj = {};

            for ( i = 0; i < fields.length; i++ ) {
                obj[ fields[ i ].name ] = fields[ i ].value;
            }

            return obj;
        }

        // Compute form data for search suggestions functionality.
        function getFormData( context ) {
            var $form, baseHref, linkParams;

            if ( !context.formData ) {
                // Compute common parameters for links' hrefs
                $form = context.config.$region.closest( 'form' );

                baseHref = $form.attr( 'action' ) || '';
                baseHref += baseHref.indexOf( '?' ) > -1 ? '&' : '?';

                linkParams = serializeObject( $form.serializeArray() );

                context.formData = {
                    textParam: context.data.$textbox.attr( 'name' ),
                    linkParams: linkParams,
                    baseHref: baseHref
                };
            }

            return context.formData;
        }

        /**
         * Callback that's run when the user changes the search input text
         * 'this' is the search input box (jQuery object)
         *
         * @ignore
         */
        function onBeforeUpdate() {
            var searchText = this.val();

            if ( searchText && searchText !== previousSearchText ) {
                mw.track( 'mediawiki.searchSuggest', {
                    action: 'session-start'
                } );
            }
            previousSearchText = searchText;

            if ( !shouldTestSearch ) {
                return;
            }

            // Clear past marks that are no longer relevant. This likely means that the
            // search request failed or was cancelled. Whatever the reason, the mark
            // is no longer needed since we are only interested in collecting the time
            // from query to render.
            if ( performance.getEntriesByName( queryMark ).length ) {
                performance.clearMarks( queryMark );
            }

            performance.mark( queryMark );
        }

        /**
         * Defines the location of autocomplete. Typically either
         * header, which is in the top right of vector (for example)
         * and content which identifies the main search bar on
         * Special:Search. Defaults to header for skins that don't set
         * explicitly.
         *
         * @ignore
         * @param {Object} context
         * @return {string}
         */
        function getInputLocation( context ) {
            return context.config.$region
                .closest( 'form' )
                .find( '[data-search-loc]' )
                .data( 'search-loc' ) || 'header';
        }

        /**
         * Callback that's run when suggestions have been updated either from the cache or the API
         * 'this' is the search input box (jQuery object)
         *
         * @ignore
         * @param {Object} metadata
         */
        function onAfterUpdate( metadata ) {
            var context = this.data( 'suggestionsContext' );

            mw.track( 'mediawiki.searchSuggest', {
                action: 'impression-results',
                numberOfResults: context.config.suggestions.length,
                resultSetType: metadata.type || 'unknown',
                searchId: metadata.searchId || null,
                query: metadata.query,
                inputLocation: getInputLocation( context )
            } );

            if ( shouldTestSearch ) {
                // Schedule the mark after the search results have rendered and are
                // visible to the user. Two rAF's are needed for this since rAF will
                // execute before the rendering steps happen (e.g. layout and paint). A
                // nested rAF will execute after these rendering steps have completed
                // and ensure the search results are visible to the user.
                requestAnimationFrame( function () {
                    requestAnimationFrame( function () {
                        if ( !performance.getEntriesByName( queryMark ).length ) {
                            return;
                        }

                        performance.mark( renderMark );
                        performance.measure( queryToRenderMeasure, queryMark, renderMark );

                        // Measure from the start of the lazy load to the first render if we
                        // haven't already captured that info.
                        if ( performance.getEntriesByName( loadStartMark ).length &&
                            !performance.getEntriesByName( loadStartToFirstRenderMeasure ).length ) {
                            performance.measure( loadStartToFirstRenderMeasure, loadStartMark, renderMark );
                        }

                        // The measures are the most meaningful info so we remove the marks
                        // after we have the measure.
                        performance.clearMarks( queryMark );
                        performance.clearMarks( renderMark );
                    } );
                } );
            }
        }

        // The function used to render the suggestions.
        function renderFunction( text, context ) {
            var formData = getFormData( context ),
                textboxConfig = context.data.$textbox.data( 'mw-searchsuggest' ) || {};

            // linkParams object is modified and reused
            formData.linkParams[ formData.textParam ] = text;

            // Allow trackers to attach tracking information, such
            // as wprov, to clicked links.
            mw.track( 'mediawiki.searchSuggest', {
                action: 'render-one',
                formData: formData,
                index: context.config.suggestions.indexOf( text )
            } );

            // this is the container <div>, jQueryfied
            this.text( text );

            // wrap only as link, if the config doesn't disallow it
            if ( textboxConfig.wrapAsLink !== false ) {
                this.wrap(
                    $( '<a>' )
                        .attr( 'href', formData.baseHref + $.param( formData.linkParams ) )
                        .attr( 'title', text )
                        .addClass( 'mw-searchSuggest-link' )
                );
            }
        }

        // The function used when the user makes a selection
        function selectFunction( $input, source ) {
            var context = $input.data( 'suggestionsContext' ),
                text = $input.val(),
                url = $( this ).parent( 'a' ).attr( 'href' );

            // We want to track a click-result XOR a submit-form action.
            // If the source was 'click' (or otherwise non-'keyboard'),
            // track it and then let the rest of the event proceed as normal.
            // If the source was 'keyboard', and we have a URL
            // (from the <a> that the result was wrapped in, see renderFunction()),
            // then also track a click, prevent the regular form submit,
            // and instead directly navigate to the URL as if it had been clicked.
            // If the source was 'keyboard', but we have no URL,
            // then we have to let the regular form submit go through,
            // so skip the click tracking in that case to avoid duplicate tracking.
            if ( source === 'keyboard' && url || source !== 'keyboard' ) {
                mw.track( 'mediawiki.searchSuggest', {
                    action: 'click-result',
                    numberOfResults: context.config.suggestions.length,
                    index: context.config.suggestions.indexOf( text )
                } );

                if ( source === 'keyboard' ) {
                    window.location.assign( url );
                    // prevent default and stop propagation
                    return false;
                }
            }

            // allow the form to be submitted
            return true;
        }

        function specialRenderFunction( query, context ) {
            var $el = this,
                formData = getFormData( context );

            // linkParams object is modified and reused
            formData.linkParams[ formData.textParam ] = query;

            mw.track( 'mediawiki.searchSuggest', {
                action: 'render-one',
                formData: formData,
                index: context.config.suggestions.indexOf( query )
            } );

            if ( mw.user.options.get( 'search-match-redirect' ) && $el.children().length === 0 ) {
                $el
                    .append(
                        $( '<div>' )
                            .addClass( 'special-label' )
                            .text( mw.msg( 'searchsuggest-containing' ) ),
                        $( '<div>' )
                            .addClass( 'special-query' )
                            .text( query )
                    )
                    .show();
            } else {
                $el.find( '.special-query' )
                    .text( query );
            }

            // eslint-disable-next-line no-jquery/no-class-state
            if ( $el.parent().hasClass( 'mw-searchSuggest-link' ) ) {
                $el.parent().attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' );
            } else {
                $el.wrap(
                    $( '<a>' )
                        .attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' )
                        .addClass( 'mw-searchSuggest-link' )
                );
            }
        }

        // Generic suggestions functionality for all search boxes
        searchboxesSelectors = [
            // Primary searchbox on every page in standard skins
            '#searchInput',
            // Generic selector for skins with multiple searchboxes (used by CologneBlue)
            // and for MediaWiki itself (special pages with page title inputs)
            '.mw-searchInput'
        ];
        $( searchboxesSelectors.join( ', ' ) )
            .suggestions( {
                fetch: function ( query, response, maxRows ) {
                    var node = this[ 0 ];

                    api = api || new mw.Api();

                    $.data( node, 'request', mw.searchSuggest.request( api, query, response, maxRows ) );
                },
                cancel: function () {
                    var node = this[ 0 ],
                        request = $.data( node, 'request' );

                    if ( request ) {
                        request.abort();
                        $.removeData( node, 'request' );
                    }
                },
                result: {
                    render: renderFunction,
                    select: function () {
                        // allow the form to be submitted
                        return true;
                    }
                },
                update: {
                    before: onBeforeUpdate,
                    after: onAfterUpdate
                },
                cache: true,
                highlightInput: true
            } )
            .on( 'paste cut drop', function () {
                // make sure paste and cut events from the mouse and drag&drop events
                // trigger the keypress handler and cause the suggestions to update
                $( this ).trigger( 'keypress' );
            } )
            // In most skins (at least Monobook and Vector), the font-size is messed up in <body>.
            // (they use 2 elements to get a sensible font-height). So, instead of making exceptions for
            // each skin or adding more stylesheets, just copy it from the active element so auto-fit.
            .each( function () {
                var $this = $( this );
                $this
                    .data( 'suggestions-context' )
                    .data.$container.css( 'fontSize', $this.css( 'fontSize' ) );
            } );

        // Ensure that the thing is actually present!
        if ( $searchRegion.length === 0 ) {
            // Don't try to set anything up if simpleSearch is disabled sitewide.
            // The loader code loads us if the option is present, even if we're
            // not actually enabled (anymore).
            return;
        }

        // Special suggestions functionality and tracking for skin-provided search box
        $searchInput.suggestions( {
            update: {
                before: onBeforeUpdate,
                after: onAfterUpdate
            },
            result: {
                render: renderFunction,
                select: selectFunction
            },
            special: {
                render: specialRenderFunction,
                select: function ( $input, source ) {
                    var context = $input.data( 'suggestionsContext' ),
                        text = $input.val();
                    if ( source === 'mouse' ) {
                        // mouse click won't trigger form submission, so we need to send a click event
                        mw.track( 'mediawiki.searchSuggest', {
                            action: 'click-result',
                            numberOfResults: context.config.suggestions.length,
                            index: context.config.suggestions.indexOf( text )
                        } );
                    } else {
                        $input.closest( 'form' )
                            .append(
                                $( '<input>' )
                                    .prop( {
                                        type: 'hidden',
                                        value: 1
                                    } )
                                    .attr( 'name', 'fulltext' )
                            );
                    }
                    return true; // allow the form to be submitted
                }
            },
            $region: $searchRegion
        } );

        var $searchForm = $searchInput.closest( 'form' );
        $searchForm
            // Track the form submit event.
            // Note that the form is mainly submitted for manual user input;
            // selecting a suggestion is tracked as a click instead (see selectFunction()).
            .on( 'submit', function () {
                var context = $searchInput.data( 'suggestionsContext' );
                mw.track( 'mediawiki.searchSuggest', {
                    action: 'submit-form',
                    numberOfResults: context.config.suggestions.length,
                    $form: context.config.$region.closest( 'form' ),
                    inputLocation: getInputLocation( context ),
                    index: context.config.suggestions.indexOf(
                        context.data.$textbox.val()
                    )
                } );
            } );

        // Check to see if the fulltext search button is placed before the go search button
        if ( $searchForm.find( '.mw-fallbackSearchButton ~ .searchButton' ).length ) {
            // Submitting the form with enter should always trigger "search within pages"
            // for JavaScript capable browsers.
            // If it is, remove the "full text search" fallback button.
            // In skins, where the "full text search" button
            // precedes the "search by title" button, e.g. Vector this is done for
            // non-JavaScript support. If the "search by title" button is first,
            // and two search buttons are shown e.g. MonoBook no change is needed.
            $searchForm.find( '.mw-fallbackSearchButton' ).remove();
        }
    } );

}() );