Source: adapter/adapterViewModel.js

import { observable, observableArray, computed, unwrap } from 'knockout';
import { createViewModel, createViewModels } from 'scalejs.metadataFactory';
import { receive } from 'scalejs.messagebus';
import { extend } from 'lodash';
import { get, merge } from 'scalejs';
import noticeboard from 'scalejs.noticeboard';

/* TODO:
In PJSON, we used readonly, errors, etc. We need a way to do that outside of adapter
i.e. plugin to adapter context with other components
*/

/** Adapter: a viewless component which keeps track of child nodes and the data for the nodes
 * @module adapter
 *
 * @param {object} node
 *  The configuration object for the module
 * @param {string} node.type='adapter'
 *  The type of the node is adapter
 * @param {string} node.id
 *  The id for the module
 * @param {boolean} [node.lazy=false]
 *  If the child nodes need to be lazily loaded
 * (e.g. delay creation of children viewmodels until data returns)
 * @param {boolean} [node.persist=false]
 *  If data object should be persisted from one fetch data call to the next (upon refresh)
 * @param {object|Object[]} [node.dataSourceEndpoint]
 *  An object defining the endpoint(s) that makes the ajax calls
 * @param {string} node.dataSourceEndpoint.uri
 *   The uri for the endpoint
 * @param {string} [node.dataSourceEndpoint.url]
 *  The url for the endpoint
 * @param {array|object} [node.dataSourceEndpoint.keyMap]
 *  A mapper object or array of mapper objects to map keys
 * @param {string} [node.dataSourceEndpoint.keyMap.resultsKey]
 *  Map the results from the ajax call with this key
 * @param {string} [node.dataSourceEndpoint.keyMap.dataKey]
 *  Extend the data object with this key
 * @param {string} [node.dataSourceEndpoint.keyMap.storeKey]
 *  Place the resultsByKey inside of the store with this key
 * @param {object} [node.dataSourceEndpoint.options]
 *  Options for the ajax call
 * @param {array} node.children
 *  The json configuration for children nodes which will be mapped
 * to view models and kept track of from the adapter
 * @param {array} [node.plugins]
 *  The json configuration for plugins which will be accessible
 * from getValue function, based upon type
 *
 * @property {array} mappedChildNodes the mapped children nodes
 * @property {observable} data the data retrieved from dataSourceEndpoint and tracked from children
 * @property {object} contextPlugins an object that contains the plugins which have
 * been added to the adapter context
 * @property {context} the context for the adapter (which can be utilized in a custom template)
 * @property {function} dispose the dispose function for all internal subs
 *
 * @example
 * {
 *      "type": "adapter",
 *      "id": "ADAPTER_ID",
 *      "dataSourceEndpoint": [
 *          {
 *              "uri": "endpoint/uri",
 *              "options": {
 *                  "type": "PUT"
 *              },
 *              "keyMap": {
 *                  "dataKey": "a",
 *                  "resultsKey": "b"
 *              }
 *          }
 *      ],
 *      "children": [
 *          // children json configuration goes here
 *      ]
 * }
 */
export default function adapterViewModel(node) {
    const dictionary = observable({}), // dictionary of nodes with an id
        data = observable({}), // data of dictionary contents
        context = {
            metadata: node.children,
            parentContext: this,
            getValue: getValue,
            dictionary: dictionary,
            data: data,
            id: node.id
        },
        mappedChildNodes = observableArray(),
        subs = [],
        plugins = node.plugins ? createViewModels.call(context, node.plugins) : [],
        contextPlugins = {};

    let dataSyncSubscription,
        updated = false;

    plugins.forEach((plugin) => {
        contextPlugins[plugin.type] = plugin;
    });

    // recursive function which parses through nodes and adds nodes with an id to dictionary
    function createDictionary(nodes) {
        const dict = dictionary.peek();
        nodes.forEach((n) => {
            // add node to dictionary if it isnt there yet
            if (n.id && !dict[n.id]) {
                dict[n.id] = n;
                updated = true;
            }
            // add children to dictionary if getValue function is not exposed
            if (!n.getValue) {
                createDictionary(unwrap(n.mappedChildNodes) || []);
            }
        });
    }

    // keep the data current if the node value changed with dataSyncDescription
    function syncDataDictionary() {
        dataSyncSubscription = computed(() => {
            const dict = dictionary();
            Object.keys(dict).forEach((id) => {
                if (dict[id].rendered) {
                    if (dict[id].rendered() && dict[id].getValue) {
                        data()[id] = dict[id].getValue();
                    } else if (!dict[id].rendered()) {
                        if (dict[id].trackIfHidden) {
                            data()[id] = dict[id].getValue();
                        } else {
                            delete data()[id];
                        }
                    }
                }
            });
        });
    }

    // pause dataSyncDescription and update the data
    function updateData(newData) {
        dataSyncSubscription && dataSyncSubscription.dispose();
        data(newData);
        syncDataDictionary();
    }

    // fetches the data from dataSourceEndpoint(s)
    function fetchData() {
        const dataSourceEndpointArray = Array.isArray(node.dataSourceEndpoint)
            ? node.dataSourceEndpoint : [node.dataSourceEndpoint],
            dataObject = node.persist ? data() : {};
        let count = 0;

        dataSourceEndpointArray.forEach((e) => {
            let endpoint = e;
            if (endpoint.uri) {
                console.warn('dataSourceEndpoint expects URI in "target". Please update your JSON to reflect the new syntax');
                endpoint = merge(endpoint, {
                    target: endpoint
                });
                delete endpoint.uri;
            }

            createViewModel.call(context, {
                type: 'action',
                actionType: 'ajax',
                options: endpoint
            }).action({
                callback: function (error, results) {
                    let resultsByKey,
                        keyMapArray = endpoint.keyMap || [{}],
                        newDataObject = {};

                    count += 1;

                    if (!Array.isArray(keyMapArray)) {
                        keyMapArray = [keyMapArray];
                    }

                    if (!error) {
                        keyMapArray.forEach((keyMap) => {
                            resultsByKey = keyMap.resultsKey ? get(results, keyMap.resultsKey) : results;
                            // optional: keyMap.dataKey path to extend dataObject on
                            if (keyMap.dataKey) {
                                newDataObject[keyMap.dataKey] = resultsByKey;
                            } else if (keyMap.storeKey) {
                                noticeboard.setValue(keyMap.storeKey, resultsByKey);
                            } else {
                                newDataObject = resultsByKey;
                            }
                            extend(dataObject, newDataObject);
                        });
                    }

                    if (count === dataSourceEndpointArray.length) {
                        updateData(dataObject);
                        if (!mappedChildNodes().length) {
                            mappedChildNodes(createViewModels.call(context, node.children || []));
                        }
                    }
                }
            });
        });
    }

    function getValue(id) {
        const dictNode = dictionary()[id],
            dataValue = (data() || {})[id];

        // the node has been defined so get the value from the node
        if (dictNode && dictNode.getValue) { return dictNode.getValue(); }

        // data has been defined for the node but the node doesnt exist yet
        if (dataValue) { return dataValue; }

        if (contextPlugins && contextPlugins[id]) {
            return contextPlugins[id]();
        }
        return context.parentContext.getValue(id);
    }

    if (node.keepContextData) {
        data(unwrap(this.data) || {});
    }

    if (!node.lazy) {
        mappedChildNodes(createViewModels.call(context, node.children || []));
    }

    // update dictionary if mappedChildNodes of a node updates
    computed(() => {
        updated = false;
        createDictionary(mappedChildNodes());
        if (updated) {
            dictionary.valueHasMutated();
        }
    });

    // initialize the data subscription
    syncDataDictionary();

    // get initial data
    if (node.dataSourceEndpoint) {
        fetchData();
    }

    // listen for 'refresh' event
    subs.push(receive(`${node.id}.refresh`, (options) => {
        // console.log('-->', node);
        if (node.dataSourceEndpoint) {
            fetchData(options);
        } else {
            Object.keys(dictionary()).forEach((key) => {
                dictionary()[key].setValue && dictionary()[key].setValue('');
            });
        }
    }));

    return merge(node, {
        mappedChildNodes: mappedChildNodes,
        data: data,
        contextPlugins: contextPlugins,
        context: context,
        dispose: function () {
            subs.forEach((sub) => {
                sub.dispose();
            });
        }
    });
}