Home Reference Source

js/template/Form/SearchInputTemplate.js

import Template from '~/template/Template';
import Language from '~/models/Language';
import SwappingTemplate from '~/template/SwappingTemplate';
import TextInputTemplate, { TextInputType } from '~/template/Form/TextInputTemplate';
import LanguageTemplate, { LanguageFixedTemplate } from '~/template/LanguageTemplate';

import { combineLatest, fromEvent, BehaviorSubject } from 'rxjs';
import { distinctUntilChanged, filter, map, mapTo, share, startWith } from 'rxjs/operators';

/**
 * @typedef {Object} SearchInputResults
 * @property {T[]} results - The results
 * @property {boolean} areMore - if more results
 */

/**
 * Abstract base for inputs which search
 * @implements {InputInterface}
 * @extends {Template}
 */
export default class SearchInputTemplate extends Template {
    /**
     * Creates an empty language input template with no language selected.
     * @param {Object} o - options
     * @param {Query} o.query - Object used for searching
     * @param {string} [o.placeholder=""] - placeholder for the input
     * @param {TextInputType} [o.inputType=Title] - Default input type
     */
    constructor({
        query,
        inputType = TextInputType.Title,
        placeholder = ''
    } = {}) {
        const root = <div class="search-picker__container" />;
        const rootSwapper = new SwappingTemplate(root);
        super(rootSwapper);

        /**
         * The input for the typical text input
         * @type {TextInputTemplate}
         */
        this.textInput = new TextInputTemplate(inputType, placeholder, {
            isOwned: false,
            isWide: true,
            autocomplete: false
        });

        /**
         * List of all results
         * @type {Element}
         */
        this.results = new SwappingTemplate(<div/>);

        /**
         * Emits the language when applicable
         * @type {BehaviorSubject}
         */
        this.value = new BehaviorSubject(null)
            .pipe(
                distinctUntilChanged(
                    (itemA, itemB) =>
                        itemA && itemB && this.compare(itemA, itemB)));

        root.appendChild(
            <DocumentFragment>
                { this.textInput.unique() }
                { this.results.unique() }
            </DocumentFragment>
        );

        combineLatest(
            this.textInput
                .observeValue(),
            this.textInput
                .observeFocus())
            .pipe(
                map(([queryString, isFocused]) => isFocused ? queryString : ""),
                distinctUntilChanged(),
                map((queryString) => query.findPage(queryString, {
                    maxResults: 3,
                    searchEmpty: false
                })))
            .subscribe(
                results => this.displayResults(results))

        this.value.subscribe(
            language => {
                if (language === null) {
                    rootSwapper.restoreOriginal();
                } else {
                    this.textInput.focus(false);

                    const template = this.fixedTemplateFor(language);
                    rootSwapper.displayAlternate(template);

                    this.observeCancelFor(template)
                        .pipe(
                            mapTo(null))
                        .subscribe(this.value);
                }
            })
    }

    /**
     * Display array of results
     * @param {?(T[])} resultObject - Iterable of results
     */
    displayResults(resultObject) {
        if (resultObject === null) {
            // This means we should hide results
            this.results.restoreOriginal();
            return;
        }

        const { areMore, results } = resultObject;

        // Otherwise show results
        if (results.length === 0) {
            this.results.displayAlternate(
                <div class="search-picker search-picker--empty">
                    No results
                </div>
            );
            return;
        } else {
            const list = <ul class="search-picker__list"/>;

            for (const result of results) {
                const listItem = <li class="search-picker__item" />;
                const template = this.resultTemplateFor(result);
                const node = template.loadInContext(listItem);
                list.appendChild(listItem);

                fromEvent(node, 'mousedown')
                    .pipe(
                        filter(event => event.which === 1),
                        mapTo(result))
                    .subscribe(this.value);
            }

            if (areMore) {
                list.appendChild(
                    <li class="search-picker__hidden">more results not shown</li>
                );
            }

            this.results.displayAlternate(
                <div class="search-picker">
                    { list }
                </div>
            );
        }
    }


    /**
     * Template for a given result
     * @param {T} result
     * @return {Template} template w/ lifecyle events
     * @abstract
     */
    resultTemplateFor(result) { return null; }

    /**
     * Fixed template to embed in
     * @param {T} result
     * @return {Template} template w/ cancelable observable
     * @abstract
     */
    fixedTemplateFor(result) { return null; }

    /**
     * Observable for fixed template
     * @param {Template} template - The template from which to return 'cancel'
     * @return {Observable} can emit any value doesn't matter
     * @abstract
     */
    observeCancelFor(template) { return null; }

    /**
     * Override to specify comparator
     * @param {T} a - First object of type of search type
     * @param {T} b - Second object
     * @return {boolean} if equal
     * @abstract
     */
    compare(a, b) { return a === b; }

    // MARK: - InputInterface
    /** @override */
    observeValue() {
        return this.value;
    }

    /** @override */
    get userInput() { return this.textInput || null; }
}