Source: src/widgets/notification_selector.js

/**
 * @file Notification Selector
 * @author Andrew Sayers
 * @summary Select details for various levels of notification
 */

/**
 * @summary Select details for various levels of notification
 * @constructor
 * @extends ActionWidget
 * @param {Object} args notification arguments
 * @example
 *
 * var notification = new NotificationSelector({
 *
 *     // default values (can be changed with val()):
 *     level               : 'none', // or 'post' or 'PM' or 'warning' or 'infraction'
 *     namespace           : 'variables namespace', // namespace to retrieve variables from
 *     violation           : 'default violation', // string, numeric ID or object
 *     keys                : {...}, // keys to use when resolving variables
 *           title_variable: 'Title variable', // variable to use for post/PM title or infraction reason
 *             ban_variable: 'Ban reason variable', // variable to use for reason to ban an account
 *          bbcode_variable: 'BBCode variable', // variable to use for BBCode
 *      note_title_variable: 'User note title variable', // variable to use for user note title
 *     note_bbcode_variable: 'User note BBCode variable', // variable to use for user note BBCode
 *     // these two usually aren't used:
 *     // title : 'initial text for post/PM title',
 *     // bbcode: 'initial text for BBCode',
 *     show_violations     : false, // show the "violation" dropdown (default: true)
 *
 *     // other data needed for the selector to function:
 *     user            : { username: ..., user_id: ... }, // user to apply action to
 *     v               : variables, // Variables object
 *     bb              : bb, // BulletinBoard object
 *     violation_groups: [{ name: '...', violations: [v1,v2,...]},...], // list of violations to display
 *     thread_id       : 12345, // post goes here, variables and previews will be calculated as if rendered in this thread
 *     thread_desc     : 'name of thread (passed to Action)',
 *     loading_html    : 'Loading, please wait...', // shown while the preview is updated
 *     callback        : function(keys) { ... }, // called with the current data
 *     container       : notification_appended_to_this,
 *     key_prefix      : 'string appended to all keys returned from the action', // default: 'notification '
 *     known_keys      : [ 'key name', ... ], // keys that should not trigger the 'curly brackets detected' warning
 *
 * });
 *
 * @description User notes should always be updated with the contents of a
 * notification action, but sometimes we want to do that in a separate action.
 *
 * When 'note_title_variable' and 'note_bbcode_variable' are not passed,
 * the action will always succeed.  You are expected to write a later action
 * that checks 'notification result' and returns appropriately.
 */
function NotificationSelector( args ) {

    var notification = this;

    var user = { username: args.user.username, user_id: args.user.user_id };

    var key_prefix = args.hasOwnProperty('key_prefix') ? args.key_prefix : 'notification ';

    var post_id;

    ActionWidget.call(
        this, args, 'notification_selector', [ 'title', 'bbcode', 'ban', 'note_title', 'note_bbcode' ],
        function(keys) { // fire

            var value = $.extend( {}, this.val() );

            if ( args.known_keys ) {
                var keys = {};
                args.known_keys.forEach(function(key) {
                    if ( notification.known_keys.hasOwnProperty(key) ) {
                        keys[key] = notification.known_keys[key]
                    } else {
                        console.log( 'Error: known key "' + key + '" was not defined' );
                    }
                });
                value.title  = args.v.parse( value.title , keys );
                value.bbcode = args.v.parse( value.bbcode, keys );
            };

            var return_keys = $.extend( {}, value.keys );
            keys.violation = return_keys.violation = value.violation.name;
            function success() {
                return_keys[key_prefix + 'result'] = 'success';
                var title  = notification.resolve_value( 'note_title' , [], $.extend( keys, return_keys ) );
                var bbcode = notification.resolve_value( 'note_bbcode', [], $.extend( keys, return_keys ) );
                if ( bbcode === null ) {
                    return { keys: return_keys };
                } else {
                    return notification.bb.usernote_add( user.user_id, title, bbcode ).then(function() {
                        return { keys: return_keys };
                    });
                }
            };

            function fail(error) {
                return_keys[key_prefix + 'result'] = 'fail';
                return_keys[key_prefix + 'error' ] = error;
                var title  = notification.resolve_value( 'note_title' , [], $.extend( keys, return_keys ) );
                var bbcode = notification.resolve_value( 'note_bbcode', [], $.extend( keys, return_keys ) );
                if ( bbcode === null ) {
                    return { keys: return_keys };
                } else {
                    return notification.bb.usernote_add( user.user_id, title, bbcode ).then(function() {
                        var dfd = jQuery.Deferred();
                        dfd.reject(error);
                        return dfd.promise();
                    });
                }
            };

            switch ( value.level ) {

            case 'PM':
                return_keys[key_prefix + 'title' ] = value.title;
                return_keys[key_prefix + 'bbcode'] = value.bbcode;
                return notification.bb.pm_send( user.username, value.title, value.bbcode ).then(success,fail);

            case 'post':
                return_keys[key_prefix + 'title' ] = value.title;
                return_keys[key_prefix + 'bbcode'] = value.bbcode;
                return notification.bb.thread_reply({
                    thread_id           : notification.thread_id,
                    title               : value.title,
                    bbcode              : value.bbcode
                }).then(function(_post_id) {
                    return_keys[key_prefix + 'post id'] = post_id = _post_id;
                    return success();
                }, fail );

            case 'warning'   : var is_warning = true; // then FALL THROUGH
            case 'infraction':
                return_keys[key_prefix + 'title' ] = notification.resolve_value( 'ban', [], keys )
                return_keys[key_prefix + 'bbcode'] = value.bbcode;
                return notification.bb.infraction_give({
                    administrative_note: notification.resolve_value( 'title', [], keys ),
                    ban_reason         : return_keys[key_prefix + 'title'],
                    bbcode             : return_keys[key_prefix + 'bbcode'],
                    user_id            : user.user_id,
                    is_warning         : is_warning,
                    infraction_id      : value.violation.id
                }).then(success,fail);

            case 'none':
                return null;

            }

        },
        function() { // description
            if ( notification.value.level == 'none' ) return;
            var actions = [];
            if ( notification.value.level == 'post' )
                actions.push({ type: notification.value.level, target: { thread_id: notification.thread_id, thread_desc: args.thread_desc, post_id: post_id } });
            else
                actions.push({ type: notification.value.level, target: user });
            if ( notification.resolve_value( 'note_bbcode', [], {} ) !== null )
                actions.push({ type: 'usernote', target: user });
            return actions;
        },
        function() { // blockers
            if ( notification.value.level != 'none' && !select.val() )
                return [ 'Please select a violation for ' + user.username ];
        }
    );

    this.bb           = args.bb;
    this.loading_html = args.loading_html;
    this.thread_id    = args.thread_id;
    this.key_prefix   = key_prefix;
    this.known_keys   = {};
    $.extend( this.value, {
        level    : null,
        namespace: null,
        violation: null,
        keys     : null,
    });

    args.bb.user_moderation_info(args.user.user_id).then(function(info) {
        if ( info.pm_notification.receive ) {
            if ( !info.pm_notification.notified )
                notification.element.find('.pm-warning').addClass('warning');
        } else
            notification.element.find('.pm-warning').addClass('error');
    });

    this.button = $('<input class="notification-text-button" type="button">')
        .val( notification.element.hasClass('preview') ? 'switch to edit mode' : 'switch to preview mode' )
        .on( 'click', function() {
            this.value = 'switch to ' + ( notification.element.hasClass('preview') ? 'preview' : 'edit' ) + ' mode';
            notification.mode           ( notification.element.hasClass('preview') ? 'edit' : 'preview' );
        })
        .appendTo(this.element.find('.mode-switch-container').first());

    var select = this.element.find('select');

    if ( args.violation_groups.length == 1 ) {
        select.append(
            args.violation_groups[0].violations.map(function(violation) {
                return $('<option>')
                    .data( 'violation', violation )
                    .val ( violation.id )
                    .text( violation.name );
            })
        );
    } else {
        select.append(
            args.violation_groups.map(function(group) {
                return $('<optgroup>').attr( 'label', group.name )
                    .append(
                        group.violations.map(function(violation) {
                            return $('<option>')
                                .data( 'violation', violation )
                                .val ( violation.id )
                                .text( violation.name );
                        })
                    );
            })
        );
    }

    select.change(function() {

        if ( $(this).val() == '' ) {

            notification.element.addClass('missing-violation');

        } else {

            var value = notification.value;
            var violation = $(':selected',this).data('violation');

            var keys = $.extend( { violation: violation.name, points: violation.points }, value.keys );

            notification.element.removeClass('missing-violation');

            if ( value.level == 'PM' || value.level == 'post' ) {
                var title_element = notification.element.find('.'+value.level + ' .title');
                var text = notification.set_value( title_element.val(), 'title', [keys.violation], keys );
                if ( typeof(text) == 'string' ) title_element.val( text ).change();
            } else if ( value.level != 'none' ) {
                [ 'top', 'bottom' ].forEach(function(location) {
                    notification.element.find('.'+value.level + ' .message-' + location).html(
                        args.v.resolve( 'report process', [value.level + ' message ' + location], keys ) );
                });
            }

            var bbcode_element = notification.element.find('.'+value.level + ' textarea');
            var text = notification.set_value( bbcode_element.val(), 'bbcode', [keys.violation], keys );
            if ( typeof(text) == 'string' ) bbcode_element.val( text ).change();

            if ( notification.element.hasClass('preview') ) {
                var level_element = notification.element.children('.'+value.level);
                level_element.find('.postrow').html( args.loading_html );
                notification.bb.bbcode_html( notification.thread_id, level_element.find('textarea').val() )
                    .done(function(html) { level_element.find('.postrow').html(html) })
                ;
            }

        }

    });

    var had_literal_curly_brackets = false, had_known_variables = false, known_variable_re;
    if ( args.known_keys ) {
        known_variable_re = new RegExp(
            '{{(?:' + args.known_keys.map(function(key) { return key.replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&") }).join('|') + ')}}',
            'g'
        );
    } else {
        known_variable_re = /(?!)/;
    }
    this.element.find('.title,textarea').on( 'input change', function() {
        var has_known_variables = false;
        var text = this.value.replace( known_variable_re, function() {
            has_known_variables = true;
            return '';
        });
        if ( ( text.search('{{') != -1 ) != had_literal_curly_brackets ) {
            notification.element.toggleClass('has-curly-brackets');
            had_literal_curly_brackets ^= true
            if ( had_literal_curly_brackets ) has_known_variables = false;
        }
        if ( has_known_variables != had_known_variables ) {
            notification.element.toggleClass('has-known-variables');
            had_known_variables ^= true;
        }
    });

    this.element.find('.reset').click(function() {
        $(this).siblings('textarea').val( notification.reset_value( $(this).siblings('textarea').val(), 'bbcode' ) );
    });

    this.val(args);

}

NotificationSelector.prototype = Object.create(ActionWidget.prototype, {
    bb          : { writable: true, configurable: false },
    loading_html: { writable: true, configurable: false },
    thread_id   : { writable: true, configurable: false },
    thread_desc : { writable: true, configurable: false },
    user        : { writable: true, configurable: false },
    key_prefix  : { writable: true, configurable: false },
    button      : { writable: true, configurable: false },
    value       : { writable: true, configurable: false },
    known_keys  : { writable: true, configurable: false }
});
NotificationSelector.prototype.constructor = NotificationSelector;


/**
 * @summary Get/set widget's values
 * @param {Object} value new value
 * @return {Object} (new) value
 * @example
 *
 * ns.val({
 *     level          : 'none', // or 'post' || 'PM' or 'warning' or 'infraction'
 *     namespace      : 'variables namespace', // namespace to retrieve variables from
 *     bbcode_variable: 'BBCode variable', // variable to use for BBCode
 *     ban_variable   : 'Ban reason variable', // variable to use for reason to ban an account
 *     title_variable : 'Title variable', // variable to use for post/PM title
 *     keys           : {...}, // keys to use when resolving variables
 *     violation      : 'default violation', // string, numeric ID or object
 *     // these two usually aren't used:
 *     // title  : 'initial text for post/PM title',
 *     // bbcode : 'initial text for BBCode',
 *     data           : ..., arbitrary data used by the calling code
 * });
 *
 * @description
 * Note: In the return object, 'violation' will be set to the current violation
 * object (not the default string), and 'title' and 'bbcode' will be set to the
 * current strings.
 *
 * Values remain unchanged if they aren't passed (no need to pass 'namespace' each time)
 *
 */
NotificationSelector.prototype.val = function( value ) {

    if ( value ) {

        var need_update = ['level', 'namespace', 'bbcode_variable', 'title_variable', 'keys', 'bbcode', 'title' ]
            .reduce(function(prev, property) { return prev || value.hasOwnProperty(property) }, false);

        if ( value.hasOwnProperty('level') && value.level != this.value.level ) {
            this.update_previous_values();
            this.element.removeClass('none post PM warning infraction').addClass(value.level)
                .find('input,select,textarea').prop( 'required', false );
            this.element                          .find('select'  ).prop( 'required', true );
            this.element.children('.'+value.level).find('textarea').prop( 'required', true );
        }

        if ( this.button.closest('.notification-selector').is(this.element) )
            this.element.find('div.' + value.level + ' .mode-switch-container').append(this.button);

        if      ( value.show_violations === false ) this.element.find('.violation-details').hide();
        else if ( value.show_violations === true  ) this.element.find('.violation-details').show();

        if ( value.hasOwnProperty('violation') ) {
            this.update_previous_values();
            var old_violation = this.element.find('select').val();
            switch ( typeof(value.violation) ) {
            case 'string': this.element.find('option').filter(function() { return $(this).text() == value.violation || this.value == value.violation }).prop( 'selected', true ); break;
            case 'number': this.element.find('select').val( value.violation ); break;
            case 'Object': this.element.find('select').val( value.violation.id ); break;
            }
            need_update |= old_violation != this.element.find('select').val();
        }

        ActionWidget.prototype.val.call( this, value );

        if ( need_update )
            this.element.find('select').change();

    }

    return $.extend( ActionWidget.prototype.val.call(this), {
        violation    : this.element.find('option:selected' ).data('violation'),
        title        : this.element.find('.' + this.value.level + ' .title'  ).val(),
        bbcode       : this.element.find('.' + this.value.level + ' textarea').val(),
    });

}

/**
 * @summary Get/set the box's current notification mode
 * @param {string=} mode 'edit' or 'preview'
 * @return {string} (new) mode
 */
NotificationSelector.prototype.mode = function( mode ) {
    if ( mode && !this.element.hasClass(mode) ) {
        this.element.removeClass('edit preview').addClass(mode);
        if ( mode == 'preview' ) {
            var level_element = this.element.children('.'+this.value.level);
            level_element.find('.postrow').html( this.loading_html );
            this.bb.bbcode_html( this.thread_id, level_element.find('textarea').val() )
                .done(function(html) {
                    level_element.find('.postrow').html(html);
                })
            ;
        }
    }
    return this.element.attr('class').replace( /.*\b(edit|preview)\b.*/, "$1" );
}

/**
 * @summary Mode-switching button
 * @return {jQuery}
 * @description this bit of sugar provides a "mode switch" button for the widget
 */
NotificationSelector.prototype.mode_switcher = function() { return this.button }

/**
 * @summary <div> element where you can put "extra" actions
 * @return {jQuery}
 * @description Sometimes it's useful to place another widget inside this one
 * (e.g. an extra action to fire at the same time).  This element is
 * a place to put such widgets
 */
NotificationSelector.prototype.extra_block = function() { return this.element.find('.extra') }

/**
 * @summary add known keys that will be inserted immediately before the notification is sent
 * @param {Object} keys keys to insert
 */
NotificationSelector.prototype.add_known_keys = function(keys) {
    $.extend( this.known_keys, keys );
}