Source: input/inputViewModel.js

import { observable, observableArray, computed } from 'knockout';
import { createViewModel, globalMetadata } from 'scalejs.metadataFactory';
import { evaluate } from 'scalejs.expression-jsep';
import { receive } from 'scalejs.messagebus';
import { has, get, is, merge } from 'scalejs';
import { extend } from 'lodash';
import moment from 'moment';
import ko from 'knockout';
import _ from 'lodash';
import noticeboard from 'scalejs.noticeboard';

import autocompleteViewModel from './autocomplete/autocompleteViewModel';
import selectViewModel from './select/selectViewModel';

const inputTypes = {
    autocomplete: autocompleteViewModel,
    select: selectViewModel,
    multiselect: function (node, inputVM) {
        node.options = merge(node.options || {}, {
            addBlank: false
        }); // do not add blanks in multiselect

        return selectViewModel.call(this, node, inputVM);
    }
};

export default function inputViewModel(n) {
    const // metadata node + context
        node = _.merge({}, globalMetadata().input_defaults || {}, n),
        options = node.options || {},
        context = this || {},

        // values which can be chosen from
        values = observableArray(Array.isArray(options.values) ? options.values : []),

        // Depricated? //TODO: Yes isShown is depricated in favor of rendered
        isShown = observable(!node.hidden),

        // 2-way binding with state of focus
        hasFocus = observable(),

        // 1-way binding with state of hover
        hover = observable(),

        // validations
        required = options.validations ? options.validations.required : false,
        customError = observable(),

        // attributes
        disabled = observable(!!options.disabled),
        readonly = deriveReadonly(options.readonly),
        maxlength = options.validations && options.validations.maxLength,

        // patterns
        pattern = options.pattern === true ? getPattern() : options.pattern,
        tooltipShown = observable(false), // for patterns
        shake = observable(false),

        // specific datepicker
        datePlaceholder = node.inputType === 'datepicker' && ko.pureComputed(() => {
            const placeholder = !hover() || hasFocus() ? '' : 'mm/dd/yyyy';
            return placeholder;
        }),

        // custom setValue functions for input types
        setValueFuncs = {
            checkboxList: setCheckboxListValue,
            multiselect: setCheckboxListValue,
            checkbox: setCheckboxValue
        },

        // subs disposable array
        subs = [],

        // move out to utility?
        formatters = {
            dateFormatter
        },
        format = options.values && options.values.textFormatter ?
            formatters[options.values.textFormatter] :
            _.identity;

    let viewmodel = { },
        validations = options.validations || null,
        computedValueExpression,
        // registered action vars
        registeredAction,
        initialRegisteredAction,
        // inputValue: accepts user input via KO Binding
        inputValue = createInputValue(),
        initial;

    viewmodel = {
        mapItem,
        inputValue,
        hasFocus,
        format,
        subs,
        readonly,
        values
    };

    /*
     * PJSON API (refine)
     */
    function getValue() {
        if (node.inputType === 'checkbox') {
            return inputValue() ?
                get(options, 'checkedValue', true) :
                get(options, 'uncheckedValue', false);
        }
        if (inputValue() === '') {
            return {}.hasOwnProperty.call(options, 'emptyValue') ? options.emptyValue : '';
        }
        if (options.number) {
            return Number(inputValue());
        }
        return inputValue();
    }

    function setValue(data, opts = {}) {
        const value = is(data, 'object') ? data.value : data,  // TODO: Refactor - should only accept "value", not "data".
            wasModified = inputValue.isModified();

        initial = opts.initial;

        if (data === getValue()) {
            return;
        }
         // uses setValueFunc if defined, else updates inputValue
        if (setValueFuncs[node.inputType]) {
            setValueFuncs[node.inputType](data);
        } else if (viewmodel.setValue) {
            viewmodel.setValue(data);
        } else {
            inputValue(value);
        }

        // programtically setting the inputValue will not cause isModified to become true
        if (!wasModified) { inputValue.isModified(false); }

        initial = false;
    }

    function update(data) {
        if ({}.hasOwnProperty.call(data, 'value')) {
            setValue(data.value);
        }
        if ({}.hasOwnProperty.call(data, 'error')) {
            customError(data.error);
        }
        if ({}.hasOwnProperty.call(data, 'values')) {
            values(data.values);
        }
    }

    function validate() {
        // can rely on "this" when properties are garuenteed
        // from MD factory and used with compliance
        inputValue.isModified(true);
        return !inputValue.isValid() && isShown() && this.rendered() && inputValue.severity() === 1;
    }

    // TODO: How to allow for custom visible message specific to project?
    function visibleMessage() {
        // returns the message to be displayed (based on validations)
        const severity = inputValue.severity();
        let inputMessage,
            message;

        if (!inputValue.isModified() || inputValue.isValid() || !this.rendered() || !isShown()) {
            // the user has yet to modify the input
            // or there is no message. return nothing
            return;
        }

        inputMessage = inputValue.error();
        inputMessage = inputMessage[inputMessage.length - 1] === '.' ? inputMessage : `${inputMessage}.`;

        if (inputMessage === 'Required.') {
            message = `${node.errorLabel || node.label} is required.`;
        } else {
            message = `${node.errorLabel || node.label} is invalid. ${inputMessage}`;
        }

        return {
            message,
            severity,
            onClick() {
                hasFocus(true);
            }
        };
    }

    /*
     * Rule Engine (todo - Refactor out)
     */

    function assignDate(value, params) {
        if (!is(params, 'object')) {
            console.error('Assign date only supports object params', params);
            return;
        }
        const newDate = moment(value).add(params).format(options.rawFormat || 'YYYY-MM-DD');
        setValue(newDate);
    }

    function setReadonly(bool) {
        readonly(bool);
    }

    function setCheckboxListValue(data) {
        if (data && data.value) {
            console.warn('Using depricated setValue { value: <> } interface. Please update code.');
        }
        if (Array.isArray(data)) {
            inputValue(data);
        } else if (data !== null && data !== undefined) {
            console.warn('Setting a checkbox list with a non-array value. Converting to array...');
            inputValue([data]);
        } else {
            inputValue([]);
        }
    }

    function setCheckboxValue(data) {
        inputValue(data === get(options, 'checkedValue', true));
    }

    /*
     * Internal
     */
    function createInputValue() {
        // checkboxList can have multiple answers so make it an array
        if (['checkboxList', 'multiselect'].indexOf(node.inputType) !== -1) {
            return observableArray(options.value || []);
        }
        // if there is no initial value, set it to empty string,
        // so that isModified does not get triggered for empty dropdowns
        let value = options.value;
        if (node.inputType === 'checkbox') {
            value = (options.value === get(options, 'checkedValue', true));
        }
        return observable(has(options.value) ? value : '');
    }


    function getPattern() {
        // implicitly determine pattern (inputmask) if there is a Regex validation
        if (options.validations && options.validations.pattern) {
            if (!options.validations.pattern.params) {
                console.error('Pattern validation must have params and message', node);
                return;
            }

            return {
                alias: 'Regex',
                regex: options.validations.pattern.params
            };
        }
    }

    function deriveReadonly(readonlyParam) {
        if (is(readonlyParam, 'string')) {
            const override = observable();
            return computed({
                read: function () {
                    return has(override()) ?
                        override()
                        : evaluate(readonlyParam, context.getValue);
                },
                write: function (value) {
                    override(value);
                }
            });
        }
        return observable(!!readonlyParam);
    }
    /*
     * Utils (can be Refactored to common)
     */

    function dateFormatter(date) {
        return moment(date).format('MM/DD/YYYY');
    }

    function mapItem(mapper) {
        const textFormatter = formatters[mapper.textFormatter] || _.identity,
            delimiter = mapper.delimeter || ' / ';

        function formatText(val, key) {
            if (Array.isArray(key)) {
                return key.map(k => val[k]).join(delimiter);
            }
            return val[key];
        }

        return function (val) {
            return {
                text: textFormatter(formatText(val, mapper.textKey)),
                value: formatText(val, mapper.valueKey),
                original: val
            };
        };
    }


    function fetchData() {
        const newValue = inputValue(),
            action = initial ? initialRegisteredAction : registeredAction;
        // our own sub gets called before context is updated
        action.options.data[node.id] = newValue;

        if (newValue !== '') {
            action.action({
                callback: (error, data) => {
                    if (error) {
                        return;
                    }
                    Object.keys(data).forEach((key) => {
                        if (key === 'store') {
                            Object.keys(data[key]).forEach((storeKey) => {
                                const valueToStore = data[key][storeKey];
                                noticeboard.setValue(storeKey, valueToStore);
                            });
                            return;
                        }

                        if (!context.dictionary && !context.data) {
                            console.warn('Using a registered input when no data/dictionary available in context', node);
                            return;
                        }
                        const updateNode = context.dictionary && context.dictionary()[key];
                        if (updateNode && updateNode.update) {
                            updateNode.update(data[key]);
                        }
                    });
                }
            });
        }
    }
    /*
     * Init
     */

    // Mixin the viewModel specific to the inputType
    if (inputTypes[node.inputType]) {
        extend(viewmodel, inputTypes[node.inputType].call(context, node, viewmodel));
    }

    // TODO: Specific to data, move into custom viewModel?
    // make min/max date into observables
    if (options.minDate) {
        viewmodel.minDate = ko.observable(options.minDate);
    }
    if (options.maxDate) {
        viewmodel.maxDate = ko.observable(options.maxDate);
    }

    if (options.registered) {
        registeredAction = createViewModel.call(this, {
            type: 'action',
            actionType: 'ajax',
            options: merge(options.registered.update || options.registered, { data: {} })
        });

        initialRegisteredAction = createViewModel.call(this, {
            type: 'action',
            actionType: 'ajax',
            options: merge(options.registered.initial || options.registered, { data: {} })
        });

        inputValue.subscribe(() => {
            fetchData();
        });

        // listen for 'refresh' event
        subs.push(receive(`${node.id}.refreshRegistered`, (eventOptions) => {
            // console.log('--> refreshing registered', node);
            fetchData(eventOptions);
        }));

        // make initial call if default value is set--fetchData checks if inputValue() is ''
        fetchData();
    }

    // TODO: Clean up validation Code
    // add validations to the inputvalue
    validations = merge(_.cloneDeep(options.validations), { customError });
    if (validations.expression) {
        if (options.validations.expression.message && !options.validations.expression.term) {
            console.error('[input] if providing a message for expression validation, must also provide term');
            options.validations.expression.term = 'true'; // don't cause exceptions.
        }
        validations.expression.params = [
            options.validations.expression.message ?
                options.validations.expression.term
                : options.validations.expression,
            context.getValue
        ];
    }

    // Updates input component
    subs.push(receive(`${node.id}.update`, update));

    if (options.unique && node.inputType !== 'autocomplete') {
        inputValue.subscribe((oldValue) => {
            context.unique[node.id].remove(oldValue);
        }, null, 'beforeChange');

        inputValue.subscribe((newValue) => {
            if (context.deleteFlag && context.deleteFlag()) { return; }
            context.unique[node.id].push(newValue);
        });

        if (context.deleteFlag) {
            context.deleteFlag.subscribe((deleted) => {
                if (deleted) {
                    context.unique[node.id].remove(inputValue());
                }
            });
        }

        context.unique[node.id].subscribe((newValues) => {
            const occurances = newValues.filter(value =>
                 value === inputValue()
            ).length;

            customError(occurances > 1 ? 'Identifier must be unique' : undefined);
        });
    }

    if (viewmodel.validations) {
        validations = merge(validations, viewmodel.validations);
    }
    inputValue = inputValue.extend(validations);

    // Allows us to set values on an input from expression
    if (options.valueExpression) {
        computedValueExpression = computed(() => {
            if (options.allowSet === false) {
                inputValue(); // re-eval when inputValue is set
            }
            return evaluate(options.valueExpression, context.getValue);
        });
        setValue(computedValueExpression());
        computedValueExpression.subscribe((value) => {
            setValue(value);
        });

        subs.push(computedValueExpression);
    }

    // TODO: make into insert zeros option?
    if (get(options, 'pattern.alias') === 'percent') {
        inputValue.subscribe((value) => {
            if (value && isFinite(Number(value))) {
                inputValue(Number(value).toFixed(3));
            }
        });
    }

    shake.subscribe((shook) => {
        shook && setTimeout(() => {
            shake(false);
        }, 1000);
    });

    return merge(node, viewmodel, {
        inputValue,
        visibleMessage,
        customError,
        hasFocus,
        hover,
        datePlaceholder,
        assignDate,
        isShown,
        required,
        readonly,
        disabled,
        maxlength,
        pattern,
        tooltipShown,
        shake,
        options,
        setValue,
        update,
        context: this,
        error: inputValue.error,

        // Mixin-Overrides
        getValue: viewmodel.getValue || getValue,
        values: viewmodel.values || values,
        setReadonly: viewmodel.setReadonly || setReadonly,
        validate: viewmodel.validate || validate,

        dispose() {
            if (viewmodel.dispose) {
                viewmodel.dispose();
            }
            (subs || []).forEach((sub) => {
                sub.dispose && sub.dispose();
            });

            if (options.unique) {
                context.unique[node.id].remove(inputValue());
            }
        }
    });
}


    // implements an input of type
    // text, select, date, radio, checkbox, checkboxList

    // TODO: Refactor Session
    // - createJSDocs
    // - revisit and de-tangle bindings
    // - refactor validations so that the tooltip works without
    // inputText wrapper in the inputType template
    // - move tooltip/helpText in options

    /**
     *  input is the component to use when accepting user-input.
     *  This is the best way to create an interactive UI and
     *  autogenerate your underlying data model by using an adapter in the parent chain.
     *
     * @module input
     *
     * @param {object} node
     *  The configuration specs for the component.
     * @param {string} [node.id]
     *  By specifying an "id" on your input, you are automatically
     * adding your input's data to the data context model.
     * @param {object} node.options
     *  The options pertaining to your specific inputType
     * @param {boolean|string} [node.rendered=true]
     *  Boolean or expression to render the input (or not)
     * @param {array} [node.options.values]
     *  The values that can be chosen from for inputTypes that have selections
     * (e.g. radio, checkboxList)
     * @param {object} [node.options.validations]
     *  KO validations object to validate the inputValue
     * @param {boolean} [node.options.validations.required]
     *  Required validation for ko - also will show * next to label indicating it is required
     * @param {boolean|string} [node.options.readonly=false]
     *  Boolean or expression to set the input as readonly
     * @param {boolean} [node.options.disabled]np
     *  Disables the input (different from readonly)
     * @param {object|string|boolean} [node.options.pattern]
     *  Sets an inputmask for the input. If a string, this is the mask.
     * If an object, gets passed as is.
     *  If boolean = true, uses pattern validation.
     * @param {boolean} [node.options.vertical=false]
     *  For multi-option types (e.g. checkboxList, radio),
     * sets the display to block if true for the options
     */