Home Reference Source

js/template/Search/SearchTemplate.js

import SearchCategoryTemplate from '~/template/Search/SearchCategoryTemplate';
import Template from '~/template/Template';
import TextInputTemplate, { TextInputType } from '~/template/Form/TextInputTemplate';
import SwappingTemplate from '~/template/SwappingTemplate';
import Search from '~/models/Search';
import KeyManager from '~/models/KeyManager';
import Theme from '~/models/Theme';

import { HandleUnhandledPromise } from '~/helpers/ErrorManager';

import { merge } from 'rxjs';
import { share, scan, filter, switchMap, distinctUntilChanged, debounceTime, map } from 'rxjs/operators';

export default class SearchTemplate extends Template {

    /**
     * Creates base search template
     * @async
     */
    constructor() {
        const root = <div class="search-overlay__positioner"/>;
        super(root);

        /** @type {SwappingTemplate} */
        this.searchIconTemplate = new SwappingTemplate(
            <img src={Theme.current.imageForTheme('search')} alt="Search"/>
        );

        /** @type {HTMLImageElement} */
        this.loadingIcon = <img src={Theme.current.imageForTheme('loading')} alt="Loading..."/>;

        /** @type {TextInputTemplate} */
        this.searchText = new TextInputTemplate(TextInputType.Search, 'Find a challenge, user, answer, ...', {
            autofocus: true,
            isOwned: true
        });

        /** @type {SwappingTemplate} */
        this.resultContainer = new SwappingTemplate();

        /** @type {Search} */
        this.searchClient = Search.createClient();

        /** @type {?KeyManager} */
        this.keyManager = null;

        // Keeps track of focus using keyboard
        /** @private */
        this.resultFocus = null;

        return (async () => {
            root.appendChild(
                <DocumentFragment>
                    <div class="search-overlay-base">
                        <div class="search-overlay">
                            <div class="search-overlay__component search-overlay__component--type-image">
                                { this.searchIconTemplate.unique() }
                            </div>
                            <div class="search-overlay__component search-overlay__component--size-stretch search-overlay__component--type-input">
                                { this.searchText.unique() }
                            </div>
                        </div>
                        <div class="search-overlay__credit">
                            <a target="_blank" href="https://www.algolia.com" title="Search by Algolia">
                                <img src={Theme.current.imageForTheme('algolia/by-algolia')} alt="Search by Algolia"/>
                            </a>
                        </div>
                    </div>
                    { this.resultContainer.unique() }
                </DocumentFragment>
            );

            // This observer is called when new result is loaded
            this.loadedResults = this.searchText
                .observeValue()
                .pipe(
                    debounceTime(200),
                    distinctUntilChanged(),
                    switchMap(async value => await this.search(value)),
                    share());


            this.loadedResults.subscribe(results =>
                this.displayResults(results)
                    .catch(HandleUnhandledPromise));

            return this;
        })();
    }

    /** @override */
    didLoad() {
        super.didLoad();
        this.searchText.focus();

        this.keyManager = new KeyManager(document);
        this.keyManager.addTarget(this.searchText.underlyingNode);

        this.resultFocus = merge(
            this.loadedResults
                .pipe(map(() => ({ reset: true }))),
            this.keyManager.registerObservable('ArrowDown')
                .pipe(map(() => ({ change: 1 }))),
            this.keyManager.registerObservable('ArrowUp')
                .pipe(map(() => ({ change: -1 }))))
            .pipe(
                scan((oldIndex, { reset, change }) => reset ? -1 : Math.max(oldIndex + change, -1), -1))
            .subscribe(::this.setResultFocus);
    }

    /**
     * Sets focus to the nth item
     * @param {number} index - Noop if out of range
     */
    setResultFocus(index) {
        if (index === -1) {
            this.searchText.focus();
        } else {
            const result = this.results[index % this.results.length];

            if (!result)
                return;

            result.focus();
        }
    }

    /** @override */
    didUnload() {
        super.didUnload();
        this.keyManager.clear();
        this.resultFocus.unsubscribe();
    }

    /**
     * Searches for a query
     * @param {string} query
     */
    async search(query) {
        if (query === "") return null;

        this.searchIconTemplate.displayAlternate(this.loadingIcon);
        const searchResults = await this.searchClient.globalSearch(query, { perPage: 3 }).next();
        this.searchIconTemplate.restoreOriginal();

        return searchResults;
    }

    /**
     * Displays search results
     * @param {SearchResults} results
     */
    async displayResults(results) {
        if (results === null) {
            this.resultContainer.restoreOriginal();
            return;
        }

        const parent = <div class="search-list"/>,
            templates = [];

        let isAtLeastOneResult = false;

        for (const category of results.categories()) {
            if (!results.categoryHasResultsForCategory(category)) {
                continue;
            }

            isAtLeastOneResult = true;

            const template = new SearchCategoryTemplate(category, results);
            templates.push(...template.results);
            template.loadInContext(parent);
        }

        this.results = templates;
        this.resultIndex = -1;

        if (!isAtLeastOneResult) {
            parent.appendChild(
                <div class="search-overlay-base search-result__empty">
                    No results found
                </div>
            );
        }

        this.resultContainer.displayAlternate(parent);
    }
}