wikimedia/mediawiki-extensions-MobileFrontend

View on GitHub
src/mobile.startup/search/SearchOverlay.js

Summary

Maintainability
B
6 hrs
Test Coverage
const
    mfExtend = require( '../mfExtend' ),
    Overlay = require( '../Overlay' ),
    util = require( '../util' ),
    searchHeader = require( './searchHeader' ),
    SearchResultsView = require( './SearchResultsView' ),
    WatchstarPageList = require( '../watchstar/WatchstarPageList' ),
    SEARCH_DELAY = 300,
    SEARCH_SPINNER_DELAY = 2000;

/**
 * Overlay displaying search results
 *
 * @class SearchOverlay
 * @memberof module:mobile.startup/search
 * @extends Overlay
 * @uses SearchGateway
 * @uses IconButton
 *
 * @param {Object} params Configuration options
 * @param {string} params.placeholderMsg Search input placeholder text.
 * @param {string} [params.action] of form defaults to the value of wgScript
 * @param {string} [params.defaultSearchPage] The default search page e.g. Special:Search
 * @param {SearchGateway} [params.gateway]
 */
function SearchOverlay( params ) {
    const header = searchHeader(
            params.placeholderMsg,
            params.action || mw.config.get( 'wgScript' ),
            ( query ) => this.performSearch( query ),
            params.defaultSearchPage || '',
            params.autocapitalize
        ),
        options = util.extend( true, {
            headerChrome: true,
            isBorderBox: false,
            className: 'overlay search-overlay',
            headers: [ header ],
            events: {
                'click .search-content': 'onClickSearchContent',
                'click .overlay-content': 'onClickOverlayContent',
                'click .overlay-content > div': function ( ev ) {
                    ev.stopPropagation();
                },
                'touchstart .results': 'hideKeyboardOnScroll',
                'mousedown .results': 'hideKeyboardOnScroll',
                'click .results a': 'onClickResult'
            }
        },
        params );

    this.header = header;
    Overlay.call( this, options );

    this.api = options.api;
    // eslint-disable-next-line new-cap
    this.gateway = options.gateway || new options.gatewayClass( this.api );

    this.router = options.router;

    this.currentSearchId = null;
}

mfExtend( SearchOverlay, Overlay, {

    /**
     * Initialize 'search within pages' functionality
     *
     * @memberof SearchOverlay
     * @instance
     */
    onClickSearchContent() {
        const
            $form = this.$el.find( 'form' ),
            $el = $form[0].parentNode;

        // Add fulltext input to force fulltext search
        this.parseHTML( '<input>' )
            .attr( {
                type: 'hidden',
                name: 'fulltext',
                value: 'search'
            } )
            .appendTo( $form );
        // history.back queues a task so might run after this call. Thus we use setTimeout
        // http://www.w3.org/TR/2011/WD-html5-20110113/webappapis.html#queue-a-task
        setTimeout( () => {
            // Firefox doesn't allow submission of a form not in the DOM
            // so temporarily re-add it if it's gone.
            if ( !$form[0].parentNode ) {
                $form.appendTo( $el );
            }
            $form.trigger( 'submit' );
        }, 0 );
    },

    /**
     * Tapping on background only should hide the overlay
     *
     * @memberof SearchOverlay
     * @instance
     */
    onClickOverlayContent() {
        this.$el.find( '.cancel' ).trigger( 'click' );
    },

    /**
     * Hide the keyboard when scrolling starts (avoid weird situation when
     * user taps on an item, the keyboard hides and wrong item is clicked).
     *
     * @memberof SearchOverlay
     * @instance
     */
    hideKeyboardOnScroll() {
        this.$input.trigger( 'blur' );
    },

    /**
     * Handle the user clicking a result.
     *
     * @memberof SearchOverlay
     * @instance
     * @param {jQuery.Event} ev
     */
    onClickResult( ev ) {
        const
            self = this,
            $link = this.$el.find( ev.currentTarget );
        /**
         * Fired when the user clicks a search result
         *
         * @type {Object}
         * @property {jQuery.Object} result The jQuery-wrapped DOM element that
         *  the user clicked
         * @property {number} resultIndex The zero-based index of the
         *  result in the set of results
         * @property {jQuery.Event} originalEvent The original event
         */
        // FIXME: ugly hack that removes search from browser history
        // when navigating to search results
        ev.preventDefault();
        this.router.back().then( function () {
            // T308288: Appends the current search id as a url param on clickthroughs
            if ( this.currentSearchId ) {
                const clickUrl = new URL( location.href );
                clickUrl.searchParams.set( 'searchToken', this.currentSearchId );
                self.router.navigateTo( document.title, {
                    path: clickUrl.toString(),
                    useReplaceState: true
                } );
                this.currentSearchId = null;
            }
            // Router.navigate does not support changing href.
            // FIXME: Needs upstream change T189173
            // eslint-disable-next-line no-restricted-properties
            window.location.href = $link.attr( 'href' );
        } );
    },

    /**
     * @inheritdoc
     * @memberof SearchOverlay
     * @instance
     */
    postRender() {
        const self = this,
            searchResults = new SearchResultsView( {
                searchContentLabel: mw.msg( 'mobile-frontend-search-content' ),
                noResultsMsg: mw.msg( 'mobile-frontend-search-no-results' ),
                searchContentNoResultsMsg: mw.message( 'mobile-frontend-search-content-no-results' ).parse()
            } );
        let timer;

        this.$el.find( '.overlay-content' ).append( searchResults.$el );
        Overlay.prototype.postRender.call( this );

        // FIXME: `this.$input` should not be set. Isolate to searchHeader function
        this.$input = this.$el.find( this.header ).find( 'input' );
        // FIXME: `this.$searchContent` should not be set. Isolate to SearchResultsView class.
        this.$searchContent = searchResults.$el.hide();
        // FIXME: `this.$resultContainer` should not be set. Isolate to SearchResultsView class.
        this.$resultContainer = searchResults.$el.find( '.results-list-container' );

        // On iOS a touchstart event while the keyboard is open will result in a scroll
        // leading to an accidental click (T299846)
        // Stopping propagation when the input is focused will prevent scrolling while
        // the keyboard is collapsed.
        this.$resultContainer[0].addEventListener( 'touchstart', ( ev ) => {
            if ( document.activeElement === this.$input[0] ) {
                ev.stopPropagation();
            }
        } );

        /**
         * Hide the spinner and abort timed spinner shows.
         * FIXME: Given this manipulates SearchResultsView this should be moved into that class
         */
        function clearSearch() {
            self.$spinner.hide();
            clearTimeout( timer );
        }

        // Show a spinner on top of search results
        // FIXME: Given this manipulates SearchResultsView this should be moved into that class
        this.$spinner = searchResults.$el.find( '.spinner-container' );
        this.on( 'search-start', ( searchData ) => {
            if ( timer ) {
                clearSearch();
            }
            timer = setTimeout( () => self.$spinner.show(),
                SEARCH_SPINNER_DELAY - searchData.delay );
        } );
        this.on( 'search-results', clearSearch );
    },

    /**
     * Trigger a focus() event on search input in order to
     * bring up the virtual keyboard.
     *
     * @memberof SearchOverlay
     * @instance
     */
    showKeyboard() {
        const len = this.$input.val().length;
        this.$input.trigger( 'focus' );
        // Cursor to the end of the input
        if ( this.$input[0].setSelectionRange ) {
            this.$input[0].setSelectionRange( len, len );
        }
    },

    /**
     * @inheritdoc
     * @memberof SearchOverlay
     * @instance
     */
    show() {
        // Overlay#show defines the actual overlay visibility.
        Overlay.prototype.show.apply( this, arguments );

        this.showKeyboard();
    },

    /**
     * Perform search and render results inside current view.
     * FIXME: Much of the logic for caching and pending queries inside this function should
     * actually live in SearchGateway, please move out.
     *
     * @memberof SearchOverlay
     * @instance
     * @param {string} query
     */
    performSearch( query ) {
        const
            self = this,
            api = this.api,
            delay = this.gateway.isCached( query ) ? 0 : SEARCH_DELAY;

        // it seems the input event can be fired when virtual keyboard is closed
        // (Chrome for Android)
        if ( query !== this.lastQuery ) {
            if ( self._pendingQuery ) {
                self._pendingQuery.abort();
            }
            clearTimeout( this.timer );

            if ( query.length ) {
                this.timer = setTimeout( () => {
                    const xhr = self.gateway.search( query );
                    self._pendingQuery = xhr.then( function ( data ) {
                        this.currentSearchId = data.searchId;
                        // FIXME: Given this manipulates SearchResultsView
                        // this should be moved into that class
                        // check if we're getting the rights response in case of out of
                        // order responses (need to get the current value of the input)
                        if ( data && data.query === self.$input.val() ) {
                            self.$el.toggleClass( 'no-results', data.results.length === 0 );
                            self.$searchContent
                                .show()
                                .find( 'p' )
                                .hide()
                                .filter( data.results.length ? '.with-results' : '.without-results' )
                                .show();

                            // eslint-disable-next-line no-new
                            new WatchstarPageList( {
                                api,
                                funnel: 'search',
                                pages: data.results,
                                el: self.$resultContainer
                            } );

                            self.$results = self.$resultContainer.find( 'li' );
                        }
                    } ).promise( {
                        abort() {
                            xhr.abort();
                        }
                    } );
                }, delay );
            } else {
                self.resetSearch();
            }

            this.lastQuery = query;
        }
    },
    /**
     * Clear results
     *
     * @private
     */
    resetSearch() {
        this.$el.find( '.overlay-content' ).children().hide();
    }
} );

module.exports = SearchOverlay;