DeuxHuitHuit/framework.js

View on GitHub
src/app-pages.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * Pages are controller that are activated on a url basis.
 *
 * @fileoverview Defines and exports pages
 *
 * @author Deux Huit Huit <https://deuxhuithuit.com>
 * @license MIT <https://deuxhuithuit.mit-license.org>
 *
 * @namespace pages
 * @memberof App
 * @requires App
 */
(function (global, undefined) {
    'use strict';
    
    const pageModels = {};
    const pageInstances = {};
    const activeRoutes = {};

    /**
     * Creates and a new factory function based on the
     * given parameters
     * @name createPageModel
     * @memberof pages
     * @method
     * @param {String} key The unique key for this page model
     * @param {pageParam|pageCreator} model A page object that conforms with the pageParam type
     *   or a pageCreator function that returns a page object.
     * @param {Boolean} [override=false] Allows overriding an existing page model
     *
     * @returns {pageModel} The newly built factory function
     * @private
     */
    const createPageModel = function (key, model, override) {

        /**
         * Page Model is a Factory function for page instances.
         * @name factory
         * @memberof pages
         * @method
         * @param {Object} pageData PageObject
         * @returns page
         * @private
         */
        const factory = function (pageData) {
            let modelRef;
            let isInited = false;
            
            if (typeof model === 'object') {
                modelRef = model;
            } else if (typeof model === 'function') {
                modelRef = model.call(this, key, pageData, override);
                if (typeof modelRef !== 'object') {
                    App.log({
                        args: [
                            'The exported page model function must return an object, ' +
                            '`%s` given (%s)', typeof modelRef, modelRef
                        ],
                        fx: 'error'
                    });
                    return null;
                }
            } else {
                App.log({
                    args: [
                        'The exported page model must be an object or a function, ' +
                        '`%s` given (%s)', typeof model, model
                    ],
                    fx: 'error'
                });
                return null;
            }

            const getSelector = () => '[data-page-url="' + pageData.key + '"]';

            /**
             * Page Param
             * @memberof pages
             * @typedef {Object} pageParam
             * @param {Function} actions @returns {object}
             * @param {Function} init
             * @param {Function} enter
             * @param {Function} leave
             * @param {Function} canEnter @returns {boolean}
             * @param {Function} canLeave @returns {boolean}
             * @param {Function} model @returns {string}
             * @param {Function} routes @return {Array}
             */
            const base = {
                actions: () => {},
                init: () => {},
                canEnter: () => true,
                canLeave: () => true,
                model: () => key,
                enter: (next, data) => {
                    const p = document.querySelector(getSelector());
                    p.style.opacity = 1;
                    p.style.display = 'block';
                    if (!!data.firstTime || data.type === 'pushState') {
                        window.scrollTo({
                            top: 0,
                            left: 0,
                            behavior: 'auto'
                        });
                    }
                    App.callback(next);
                },
                leave: (next) => {
                    const p = document.querySelector(getSelector());
                    p.style.opacity = 0;
                    p.style.display = 'none';
                    App.callback(next);
                }
            };

            // insure this can't be overridden
            const overwrites = Object.freeze({
                key: () => pageData.key,
                selector: () => getSelector(),
                data: () => pageData,
                isInited: () => {
                    return isInited;
                },
                setInited: () => {
                    isInited = true;
                }
            });

            // New deep copy frozen object
            return Object.freeze(Object.assign({}, base, modelRef, overwrites));
        };

        // create the empty array for the model in the routes references
        activeRoutes[key] = [];

        return factory;
    };
    
    /**
     * Creates a page with the specified model.
     * @name createPage
     * @memberof pages
     * @method
     * @param {Object} pageData An data bag for your page
     * @param {String} keyModel The page model's unique key
     * @param {Boolean} [override=false] Allows overriding an existing page instance
     * @returns {?page} Null if something goes wrong
     * @private
     */
    const createPage = function (pageData, keyModel, override) {
        //Find the page model associated
        const pageModel = pageModels[keyModel];
        let pageInst;
        
        if (!pageModel) {
            App.log({args: ['Model `%s` not found', keyModel], fx: 'error'});
        } else {
            //Check to not override an existing page
            if (!!pageInstances[pageData.key] && !override) {
                App.log({
                    args: ['Overwriting page key `%s` is not allowed', pageData.key],
                    fx: 'error'
                });
            } else {
                pageInst = pageModel(pageData);
                if (!!pageInst) {
                    pageInstances[pageData.key] = pageInst;
                }
                return pageInst;
            }
        }
        return false;
    };

    /**
     * Registers a pageModel instance.
     * @name registerPageModel
     * @memberof pages
     * @method
     * @param {String} key The model unique key
     * @param {pageModel} pageModel The page model
     * @param {Boolean} [override=false] Allows overriding an existing page instance
     *
     * @returns {pageModel}
     * @private
     */
    const registerPageModel = function (key, pageModel, override) {
        const keyType = typeof key;
        if (keyType !== 'string') {
            App.log({
                args: ['`key` must be a string, `%s` given (%s).', keyType, key],
                fx: 'error'
            });
        // Found an existing page and cannot override it
        } else if (!!pageModels[key] && !override) {
            //error, should not override an existing key
            App.log({
                args: ['Overwriting page model key `%s` is not allowed', key],
                fx: 'error'
            });
        } else {
            // Store page to the list
            pageModels[key] = Object.freeze(pageModel);
            return pageModel;
        }
        return false;
    };
    
    /**
     * Create a new pageModel, i.e. a function to create a new pages.
     * It first calls {@link createPageModel} and then calls {@link registerPageModel}
     * with the output of the first call.
     * @name exportPage
     * @memberof pages
     * @method
     * @param {String} key The model unique key
     * @param {pageParam|pageCreator} model A page object that conforms with the pageParam type
     *   or a pageCreator function that returns a page object.
     * @param {Boolean} [override=false] Allows overriding an existing page instance
     *
     * @return {pageModel}
     * @private
     */
    const exportPage = function (key, model, override) {
        // Pass all args to the factory
        const pageModel = createPageModel(key, model, override);
        // Only work with pageModel afterwards
        return registerPageModel(key, pageModel, override);
    };
    
    const routeMatchStrategies = {
        regexp: function (testRoute, route, cb) {
            if (testRoute.test(route)) {
                return cb();
            }
            return true;
        },
        string: function (testRoute, route, cb) {
            let regex;
            // be sure to escape uri
            route = decodeURIComponent(route);
            
            // be sure we do not have hashed in the route
            route = route.split('#')[0];
            
            // avoid RegExp if possible
            if (testRoute === route) {
                return cb();
            }
            
            // assure we are testing from the beginning
            if (testRoute.indexOf('^') !== 0) {
                testRoute = '^' + testRoute;
            }
            
            // assure we are testing until the end
            if (testRoute.indexOf('^') !== testRoute.length - 1) {
                testRoute = testRoute + '$';
            }
            
            // wildcard replace
            // avoid overloading routes with regex
            if (testRoute.indexOf('*')) {
                // a-zA-Z0-9 ,:;.=%$|—_/\\-=?&\\[\\]\\\\#
                testRoute = testRoute.replace(new RegExp('\\*', 'gi'), '.*');
            }
            
            try {
                regex = new RegExp(testRoute);
            } catch (ex) {
                App.log({
                    args: ['Error while creating RegExp %s.\n%s', testRoute, ex],
                    fx: 'error'
                });
            }
            
            if (!!regex && regex.test(route)) {
                return cb();
            }
            return true;
        }
    };
    
    /**
     * Tries to match the given route against the given
     * array of possible routes.
     * @name matchRoute
     * @memberof pages
     * @method
     * @param {String} route The route to search match for
     * @param {String[]|RegExp[]} routes The allowed routes
     *
     * @returns {Integer} The index of the matched route or -1 if no match
     * @private
     */
    const matchRoute = function (route, routes) {
        let index = -1;
        const found = function (i) {
            index = i;
            return false; // exit every
        };
        
        if (typeof route !== 'string') {
            App.log({args: '`route` must be a string', fx: 'error'});
            return index;
        }
        
        if (!!~route.indexOf('?')) {
            route = route.split('?')[0];
        }
        
        if (!!route && !!routes) {
            if (!Array.isArray(routes)) {
                routes = Object.values(routes);
            }
            routes.every(function matchOneRoute (testRoute, i) {
                const routeType = typeof testRoute;
                const routeStrategy = routeMatchStrategies[routeType];
                const cb = function () {
                    return found(i);
                };
                
                if (typeof routeStrategy === 'function') {
                    return routeStrategy(testRoute, route, cb);
                } else if (testRoute === route) {
                    return cb();
                }
                return true;
            });
        }
        
        return index;
    };

    /**
     * Add routes to a model
     * @name addRoutes
     * @memberof pages
     * @method
     * @param {String} keyModel model to add routes to
     * @param {Array} routes to add to the model
     * @returns {Array} all the active routes
     * @private
     */
    const addRoutes = (keyModel, routes) => {
        if (!pageModels[keyModel]) {
            App.log({fx: 'error', args: 'Model "' + keyModel + '" not found.'});
            return false;
        }

        if (!activeRoutes[keyModel]) {
            activeRoutes[keyModel] = [];
        }

        if (keyModel === 'default') {
            App.log({fx: 'error', args: 'You can\'t add routes to the default model'});
            return false;
        }

        // new set to remove duplicates in array
        activeRoutes[keyModel] = ([...new Set((activeRoutes[keyModel]).concat(routes))]);

        // todo 3.1.0 add verification if route is already used

        return activeRoutes[keyModel];
    };

    /**
     * Remove routes to a model
     * @name removeRoutes
     * @memberof pages
     * @method
     * @param {String} keyModel model to remove routes to
     * @param {Array} routes to remove to the model
     * @returns {Array} all the active routes
     * @private
     */
    const removeRoutes = (keyModel, routes) => {
        if (!pageModels[keyModel]) {
            App.log({fx: 'error', args: 'Model "' + keyModel + '" not found.'});
            return false;
        }
        return false;
    };

    /**
     * Returns the first page object that matches the href param
     * @name getPageForHref
     * @memberof pages
     * @method
     * @param {String} href The href to search match for
     *
     * @returns {page} The page object or a new page with associated model
     * @private
     */
    const getPageForHref = function (href) {

        // check if the instance already exists
        if (!!pageInstances[href]) {
            return pageInstances[href];
        }

        // make sure the href does not include the hash
        href = href.split('#')[0];

        // match with potential model
        let model = null;

        for (const m in activeRoutes) {
            if (activeRoutes.hasOwnProperty(m)) {
                const modelRoutes = activeRoutes[m];
                const match = !!~matchRoute(href, modelRoutes);
                if (!!match) {
                    model = m;
                    break;
                }
            }
        }

        if (!model) {
            model = 'default';
        }

        // create instance with matched model
        return createPage({key: href}, model, true);
    };

    const loaded = (url) => {
        return !!document.querySelector(App.root()).querySelector('[data-page-url="' + url + '"]');
    };

    registerPageModel('default', createPageModel('default', {}, true), {});

    /** Public Interfaces **/
    global.App = Object.assign({}, global.App, {
        pages: {

            /**
             * Getter for all instances of a particular one
             * @param [key] - the optinal key to search for.
             *   If falsy, will return all instances
             * @returns {page|page[]}
             * @private
             */
            instances: function (key) {
                if (!!key) {
                    return pageInstances[key];
                }
                return pageInstances;
            },

            /**
             * Returns all models
             * @method
             * @name models
             * @memberof pages
             * @returns {Object}
             * @public
             */
            models: function () {
                return pageModels;
            },

            /**
             * Returns the first page object that matches the route param
             * @name getPageForHref
             * @memberof pages
             * @method
             * @param {String} route The route to search match for
             *
             * @returns {?page} The page object or null if not found
             * @public
             */
            getPageForHref: getPageForHref,

            /**
             * Returns the page based the key and fallbacks to
             * the [route]{@link getPageForHref} if noting is found.
             * @name page
             * @method
             * @memberof pages
             * @param {string} keyOrRoute - the key or the route of the page
             * @returns {page}
             * @public
             */
            page: function (keyOrRoute) {
                //Try to get the page by the key
                let result = pageInstances[keyOrRoute];

                //if no result found try with the route
                if (!!!result) {
                    result = getPageForHref(keyOrRoute);
                }
                
                return result;
            },

            /**
             * Creates a page with the specified model.
             * @name create
             * @memberof pages
             * @method
             * @param {Object} pageData An data bag for your page
             * @param {String} keyModel The page model's unique key
             * @param {Boolean} [override=false] Allows overriding an existing page instance
             * @returns {?page} Null if something goes wrong
             * @public
             */
            create: createPage,

            /**
             * Create a new pageModel, i.e. a function to create a new pages.
             * It first calls {@link createPageModel} and then calls {@link registerPageModel}
             * with the output of the first call.
             * @name exports
             * @memberof pages
             * @method
             * @param {String} key The model unique key
             * @param {pageParam|pageCreator} model A page object that conforms
             *   with the pageParam type or a pageCreator function that returns a page object.
             * @param {pageParam|pageCreator} model A page object that conforms with the
             *   pageParam type or a pageCreator function that returns a page object.
             * @param {Boolean} [override=false] Allows overriding an existing page instance
             *
             * @return {pageModel}
             * @public
             */
            exports: exportPage,

            /**
             * Check if the page is loaded from a given url
             * @name exports
             * @memberof pages
             * @method
             * @param {String} url the url to check
             * @return {Boolean}
             * @public
             * @since 3.0.0
             */
            loaded: loaded,

            /**
             * App pages routes
             *
             * @namespace routes
             * @memberof pages
             * @since 3.0.0
             */
            routes: {

                /**
                 * Get all the active routes
                 * @name active
                 * @memberof routes
                 * @method
                 * @returns {Object} all the active routes for all models
                 * @public
                 */
                active: () => activeRoutes,

                /**
                 * @name match
                 * @method
                 * @memberof routes
                 * {@link App.pages~matchRoute}
                 * @public
                 */
                match: matchRoute,

                /**
                 * Add routes to a model
                 * @name addRoutes
                 * @memberof routes
                 * @method
                 * @param {String} keyModel model to add routes to
                 * @param {Array} routes to add to the model
                 * @returns {Array} all the active routes
                 * @public
                 */
                add: addRoutes,

                /**
                 * Remove routes to a model
                 * @name removeRoutes
                 * @memberof routes
                 * @method
                 * @param {String} keyModel model to remove routes to
                 * @param {Array} routes to remove to the model
                 * @returns {Array} all the active routes
                 * @public
                 */
                remove: removeRoutes
            }
        }
    });
    
})(window);