page.js

import _ from 'lodash';
import Parser from './parser';
import Ajax from './ajax';
import Handler from './handler';

/**
 * Created pages cache.
 *
 * @private
 * @type {Object}
 */
let pages = {};

/**
 * Page level defaults that needs to be overridden.
 *
 * @private
 * @type {Object}
 */
const defaults = {
    /**
     * Default styles (override with the required style)
     * @type {String}
     */
    style: '',
    /**
     * Template functin that takes data and returns the TVML template string.
     *
     * @param  {Object} data    The data associated with the template
     * @return {string}         The final TVML template string.
     */
    template(data) {
        console.warn('No template exists!')
        return '';
    },
    /**
     * Data transformation function that will be invoked before passing the data to the template function.
     *
     * @param  {Object} d   The data object
     * @return {Obect}      The transformed data
     */
    data(d) {
        return d;
    },
    /**
     * Default options that will be passed to the ajax options
     *
     * @type {Object}
     */
    options: {
        responseType: 'json'
    }
};

/**
 * Sets the default options for the page.
 *
 * @inner
 * @alias module:page.setOptions
 *
 * @param {Object} cfg The configuration object {defaults}
 */
function setOptions(cfg = {}) {
    console.log('setting default page options...', cfg);
    // override the default options
    _.assign(defaults, cfg);
}

/**
 * Adds style to a document.
 *
 * @todo Check for existing style tag within the head of the provided document and append if exists
 *
 * @private
 *
 * @param  {String} style Style string
 * @param  {Document} doc   The document to add styles on
 */
function appendStyle(style, doc) {
    if (!_.isString(style) || !doc) {
        console.log('invalid document or style string...', style, doc);
        return;
    }
    let docEl = (doc.getElementsByTagName('document')).item(0);
    let styleString = ['<style>', style, '</style>'].join('');
    let headTag = doc.getElementsByTagName('head');

    headTag = headTag && headTag.item(0);
    if (!headTag) {
        headTag = doc.createElement('head');
        docEl.insertBefore(headTag, docEl.firstChild);
    }
    headTag.innerHTML = styleString;
}

/**
 * Prepares a document by adding styles and event handlers.
 *
 * @inner
 * @alias module:page.prepareDom
 *
 * @param  {Document} doc       The document to prepare
 * @return {Document}           The document passed
 */
function prepareDom(doc, cfg = {}) {
    if (!(doc instanceof Document)) {
        console.warn('Cannnot prepare, the provided element is not a document.');
        return;
    }
    // apply defaults
    _.defaults(cfg, defaults);
    // append any default styles
    appendStyle(cfg.style, doc);
    // attach event handlers
    Handler.addAll(doc, cfg);

    return doc;
}

/**
 * A helper method that calls the data method to transform the data.
 * It then creates a dom from the provided template and the final data.
 *
 * @inner
 * @alias module:page.makeDom
 *
 * @param  {Object} cfg             Page configuration options
 * @param  {Object} response        The data object
 * @return {Document}               The newly created document
 */
function makeDom(cfg, response) {
    // apply defaults
    _.defaults(cfg, defaults);
    // create Document
    let doc = Parser.dom(cfg.template, (_.isPlainObject(cfg.data) ? cfg.data: cfg.data(response)));
    // prepare the Document
    prepareDom(doc, cfg);
    // call the after ready method if defined in the configuration
    if (_.isFunction(cfg.afterReady)) {
        console.log('calling afterReady method...');
        cfg.afterReady(doc);
    }
    // cache cfg at the document level
    doc.page = cfg;

    return doc;
}

/**
 * Generated a page function which returns a promise after invocation.
 *
 * @private
 *
 * @param  {Object} cfg     The page configuration object
 * @return {Function}       A function that returns promise upon execution
 */
function makePage(cfg) {
    return (options) => {
        _.defaultsDeep(cfg, defaults);

        console.log('making page... options:', cfg);

        // return a promise that resolves after completion of the ajax request
        // if no ready method or url configuration exist, the promise is resolved immediately and the resultant dom is returned
        return new Promise((resolve, reject) => {
            if (_.isFunction(cfg.ready)) { // if present, call the ready function
                console.log('calling page ready... options:', options);
                // resolves promise with a doc if there is a response param passed
                // if the response param is null/falsy value, resolve with null (usefull for catching and supressing any navigation later)
                cfg.ready(options, (response) => resolve((response || _.isUndefined(response)) ? makeDom(cfg, response) : null), reject);
            } else if (cfg.url) { // make ajax request if a url is provided
                Ajax
                    .get(cfg.url, cfg.options)
                    .then((xhr) => {
                        resolve(makeDom(cfg, xhr.response));
                    }, (xhr) => {
                        // if present, call the error handler
                        if (_.isFunction(cfg.onError)) {
                            cfg.onError(xhr.response, xhr);
                        } else {
                            reject(xhr);
                        }
                    });
            } else { // no url/ready method provided, resolve the promise immediately
                resolve(makeDom(cfg));
            }
        });
    }
}

/**
 * A minimalistic page creation library for Apple TV applications
 *
 * @module page
 *
 * @author eMAD <emad.alam@yahoo.com>
 *
 */
export default {
    setOptions: setOptions,
    /**
     * Create a page that can be later used for navigation.
     *
     * @example
     * const homepage = create({
     *     name: 'homepage',
     *     url: 'path/to/server/api/',
     *     template(data) {
     *         // return a string here (preferably TVML)
     *     },
     *     data(d) {
     *         // do your data transformations here and return the final data
     *         // the transformed data will be passed on to your template function
     *     },
     *     options: {
     *         // ajax options
     *     },
     *     events: {
     *         // event maps and handlers on the configuration object
     *         'scroll': function(e) { // do the magic here },
     *         'select': 'onTitleSelect'
     *     },
     *     onError(response, xhr) {
     *         // perform the error handing
     *     },
     *     ready(options, resolve, reject) {
     *         // call resolve with the data to render the provided template
     *
     *         // you may also call resolve with null/falsy value to suppress rendering,
     *         // this is useful when you want full control of the page rendering
     *
     *         // reject is not preferred, but you may still call it
     *
     *         // any configuration options passed while calling the page method,
     *         // will be carried over to ready method at runtime
     *     },
     *     afterReady(doc) {
     *         // all your code that relies on a document object should go here
     *     },
     *     onTitleSelect(e) {
     *         // do the magic here
     *     }
     * });
     * homepage(options) -> promise that resolves to a document
     *
     * //(or if using the navigation class)
     *
     * Navigation.navigate('homepage') -> promise that resolves on navigation
     *
     * @param  {String|Object} name     Name of the page or the configuration options
     * @param  {Object} cfg             Page configuration options
     * @return {Function}               A function that returns promise upon execution
     */
    create(name, cfg) {
        console.log('creating page... name:', name);

        if (_.isObject(name)) {
            cfg = name;
            name = cfg.name;
        }

        _.assign(cfg, {
            name: name
        });

        if (!name || !_.isString(name)) {
            console.warn('Creating page without a name, name based navigation will not be possible.');
        }

        // warn in case the page already exists
        if (pages[name]) {
            console.warn(`The given page name ${name} already exists! Overriding...`);
        }
        let p = makePage(cfg);
        // cache for later user
        pages[name] = p;
        // merge configurations on the page
        _.assign(p, cfg);
        // return the created page to allow chaining
        return p;
    },
    /**
     * Returns the previously created page from the cache.
     *
     * @example
     * // create page
     * ATV.Page.create('homepage', { page configurations });
     * // later in the app
     * const homepage = ATV.Page.get('homepage');
     *
     * @param  {string} name    Name of the previously created page
     * @return {Page}           Page function
     */
    get(name) {
        return pages[name];
    },
    prepareDom: prepareDom,
    makeDom: makeDom
};