Source: src/widgets/thread_title_selector.js

/**
 * @file Thread Title Selector
 * @author Andrew Sayers
 * @summary Select a thread to merge into
 */

/**
 * @summary Select a thread title
 * @constructor
 * @extends Widget
 * @param {Object} args widget arguments
 * @example
 *
 * var selector = new ThreadTitleSelector({
 *     target_thread_id  : 1234, // initial target thread ID
 *     target_forum_id   : 12, // initial target forum ID
 *     target_thread_desc: 'initial target name',
 *     mode              : 'edit', // or 'merge' to pick a merge target by title
 *
 *     thread_id  : 2345, // current thread ID
 *     forum_id   : 23, // current forum ID
 *     bb         : bb, // BulletinBoard object
 *     ss         : ss, // SharedStore object
 *     callback   : function(keys) { ... }, // called with the current data
 *     container  : notification_appended_to_this,
 * });
 *
 */
function ThreadTitleSelector( args ) {

    var widget = this;
    var callback = args.callback;
    delete args.callback; // disable default handling

    this.datalist = $('#thread_title_selector_datalist');

    this.title_to_data = {};
    this.   id_to_data = {};
    this.thread_id = args.thread_id;

    if ( !this.datalist.length ) {
        this.datalist = $('<datalist id="thread_title_selector_datalist"></data>').appendTo(document.body);
        $.when( args.bb.threads_recent() ).then(function(recent) {
            recent.forEach(function(target) { widget._add(target.thread_id, target.name, target.forum_id ) });
        });
    }

    Widget.call( this, args, 'thread_title_selector' );

    var input = this.element.children('input');
    var  edit_attrs = { title: input.attr(       'title' ), placeholder: input.attr(       'placeholder' ) };
    var merge_attrs = { title: input.data( 'merge-title' ), placeholder: input.data( 'merge-placeholder' ) };

    var next_callback_id = 0, prev_callback_thread_id = NaN;

    function call_callback(value, callback_id) {
        if ( callback_id != next_callback_id ) return;
        if ( value ) {
            widget.check(value.thread_id).then(function(error) {
                if ( callback_id != next_callback_id ) return;
                if ( error && error != 'closed' ) {
                    widget.value.target_thread_id   = null;
                    widget.value.target_thread_desc = null;
                    widget.value.target_forum_id    = null;
                    if ( callback ) callback(null);
                } else if ( prev_callback_thread_id != value.thread_id ) {
                    prev_callback_thread_id = value.thread_id;
                    widget.value.target_thread_id   = value.thread_id;
                    widget.value.target_thread_desc = value.thread_desc;
                    widget.value.target_forum_id    = value.forum_id;
                    if ( callback ) callback(value);
                }
            });
        } else {
            widget.value.target_thread_id   = null;
            widget.value.target_thread_desc = null;
            widget.value.target_forum_id    = null;
            if ( callback ) callback(value);
        }
    }

    var searched_thread_descs = {}, search_timeout, value_timeout;
    input.on( 'input change', function(event) {
        widget.element.removeClass('closed old self missing');
        var value = $.trim(this.value.toLowerCase());
        if ( widget.value.mode == 'merge' ) {
            input.attr( merge_attrs );
            var target_thread = null;
            if ( widget.title_to_data.hasOwnProperty(value) ) {
                call_callback( widget.title_to_data[value], ++next_callback_id );
            } else if ( value.toLowerCase() == '(this thread does not exist)' ) {
                widget.element.addClass('missing');
                call_callback(null, ++next_callback_id);
            } else {
                value.replace( /^\s*(?:.*\/showthread.php\?t=|.*\[thread=)?([0-9]{4,})/, function(value, thread_id) {
                    if ( value_timeout ) clearTimeout(value_timeout);
                    ++next_callback_id;
                    value_timeout = setTimeout(function() {
                        widget._set( parseInt(thread_id, 10) );
                        value_timeout = null;
                    }, 500 );
                    value = null;
                });
                if ( !value ) return;
                var element = this;
                if ( search_timeout ) clearTimeout(search_timeout);
                search_timeout = setTimeout(function() {
                    var title = $.trim(element.value.toLowerCase());
                    if (
                        title.length >= 3 &&
                        !searched_thread_descs.hasOwnProperty(title) &&
                        !widget.title_to_data.hasOwnProperty(title)
                    ) {
                        searched_thread_descs[title] = null;
                        args.bb.threads_complete( title ).then(function(vals) {
                            search_timeout = null;
                            vals.forEach(function(val) { widget._add( val.thread_id, val.title, val.forum_id ) });
                            $(element).change();
                        });
                    } else {
                        search_timeout = 0;
                    }
                }, 500 );
            }
        } else {
            input.attr( edit_attrs );
            widget.value.target_forum_id    = args.forum_id;
            widget.value.target_thread_id   = args.thread_id;
            widget.value.target_thread_desc = this.value;
            if ( callback ) callback({
                forum_id   : args.forum_id,
                thread_id  : args.thread_id,
                thread_desc: this.value
            });
        }
    });

    this.bb = args.bb;

    this.value = {
        mode              : 'edit',
        target_thread_id  : args.thread_id,
        target_thread_desc: args.thread_desc,
        target_forum_id   : args.forum_id
    };

    if ( args.thread_id ) {
        this._set( args.thread_id, args.thread_desc, args.forum_id );
    }

}

ThreadTitleSelector.prototype = Object.create(Widget.prototype, {
    bb           : { writable: true, configurable: false },
    datalist     : { writable: true, configurable: false },
       id_to_data: { writable: true, configurable: false },
    title_to_data: { writable: true, configurable: false },
    thread_id    : { writable: true, configurable: false },
});
ThreadTitleSelector.prototype.constructor = ThreadTitleSelector;

/**
 * @summary add a value to the (shared) datalist
 * @private
 * @param {Number} thread_id    thread ID
 * @param {string=} thread_desc thread title (or other description)
 */
ThreadTitleSelector.prototype._add = function( thread_id, thread_desc, forum_id ) {
    if ( typeof(thread_desc) == 'string' ) {
        this. title_to_data[thread_desc.toLowerCase()] =
            this.id_to_data[thread_id                ] =
            { thread_id: thread_id, thread_desc: thread_desc, forum_id: forum_id };
        if ( !this.datalist.find('option').filter(function() { return this.textContent == thread_desc }).length )
            this.datalist.append( $('<option>').val(thread_desc).text(thread_desc) );
        return $.Deferred().resolve().promise();
    } else if ( this.id_to_data.hasOwnProperty(thread_id) ) {
        return $.Deferred().resolve().promise();
    } else {
        var widget = this;
        return this.bb.thread_metadata(thread_id).then(function(data) {
            if ( data.valid ) {
                widget. title_to_data[data.title.toLowerCase()] =
                    widget.id_to_data[thread_id               ] =
                    { thread_id: thread_id, thread_desc: data.title, forum_id: data.forum_id };
                widget.datalist.append( $('<option>').val(data.title).text(data.title) );
            } else {
                widget.id_to_data[thread_id] = { thread_id: thread_id, thread_desc: '(this thread does not exist)', forum_id: null };
            }
        });
    }
}

/**
 * @summary set a new value
 * @private
 * @param {Number} thread_id    thread ID
 * @param {string=} thread_desc thread title (or other description)
 */
ThreadTitleSelector.prototype._set = function( thread_id, thread_desc, forum_id ) {
    var widget = this;
    if ( thread_desc === '' ) {
        widget.element.children('input').val( '' ).change();
    } else {
        return this._add( thread_id, thread_desc, forum_id ).then(function() {
            widget.element.children('input').val( widget.id_to_data[thread_id].thread_desc );
            if ( widget.value.mode == 'merge' ) {
                widget.check(thread_id).then(function(error) {
                    if ( error ) {
                        widget.value.target_thread_id   = null;
                        widget.value.target_thread_desc = null;
                        widget.value.target_forum_id    = null;
                    } else {
                        widget.value.target_thread_id   = thread_id;
                        widget.value.target_thread_desc = widget.id_to_data[thread_id].thread_desc;
                        widget.value.target_forum_id    = widget.id_to_data[thread_id].forum_id;
                    }
                    widget.element.children('input').change();
                });
            } else {
                widget.element.children('input').change();
            }
        });
    }
}

/**
 * @summary check whether the specified thread is a valid merge target
 * @param {Number} thread_id thread to check
 * @return {jQuery.Promise}
 */
ThreadTitleSelector.prototype.check = function( thread_id ) {
    if ( this.thread_id == thread_id ) {
        this.element.addClass('self');
        return $.Deferred().resolve('self').promise();
    } else if ( this.thread_id < thread_id ) {
        this.element.addClass('old');
        return $.Deferred().resolve('old').promise();
    } else {
        var widget = this;
        return this.bb.thread_metadata(thread_id).then(function(data) {
            var ret = '';
            if ( data.deleted || data.merged || !data.valid ) ret = 'missing';
            else if ( !data.open ) ret = 'closed';
            widget.element.addClass(ret);
            return ret;
        });
    }
}

/**
 * @summary Give focus to this widget
 */
ThreadTitleSelector.prototype.focus = function() {
    this.element.children('input').focus();
}

/**
 * @summary Get/set widget's value
 * @param {Object} value new value
 * @return {Object} (new) value
 * @example
 *
 * widget.val({
 *     mode: 'merge', // or 'edit'
 *     target_thread_id  : 1234,
 *     target_thread_desc: 1234,
 * });
 *
 */
ThreadTitleSelector.prototype.val = function( value ) {

    if ( value ) {
        Widget.prototype.val.call( this, value );
        if ( value.hasOwnProperty('mode') ) {
            if ( value.mode == 'merge' )
                this.element.children('input').attr( 'list', 'thread_title_selector_datalist' );
            else
                this.element.children('input').removeAttr( 'list' );
        }
        if ( value.hasOwnProperty('target_thread_id') ) {
            this._set( value.target_thread_id, value.target_thread_desc, value.target_forum_id );
        } else {
            this.element.children('input').change();
        }
    }

    return $.extend( Widget.prototype.val.call(this), {
        thread_desc: this.element.children('input').val(),
    });

}