Source: bin/app_root.js

//@ts-check
/**
 * This singleton class is responsible for paging the main views in the site, inserted at the app's root
 * It does this with the help of the base history manager that contains the base history model that provides
 * the new state of the window plus the active main navigation control
 * 
 * It will provide an interface to know when the nav buttons need to be updated correctly. Then, calls the 
 * MainRouter to trigger routing functions
 * 
 * It should NOT update the state. This should only be done by the MainRouter, once routing is done.
 * 
 * Listens to pop state however, to trigger router to do routing again, and auto update nav buttons/links
 */

/**
 * @typedef AppExtraOptions
 * @property {{ interceptNonLocal: boolean, interceptorCb: genericParamReturnFunction<string, boolean> }} linkInterceptor
 * 
 */

//For ui-components
import AppRootComponent from "./ui-components/app_root_component";
import MainFragmentComponent from "../fragments/utils/ui-components/main-fragment/main_fragment";
import ChildFragmentComponent from "../fragments/utils/ui-components/child-fragment/child_fragment";

import AppStateManager from "../base-history/app_state_manager";
import MainRouter from "../router/main_router";
import RoutingInfoUtils from "../router/utils/routing-info/routing_info_utils";

class AppRoot{

    constructor(){

        //@ts-ignore
        if (!AppRoot.instance){

            this.appStateManager = [];
            /**
             * @type {[MainNavigationInfo[]]}
             */
            //@ts-ignore
            this.mainNavInfos = [];
            /**
             * @type {MainRouter[]}
             */
            this.mainRouter = [];
            /**
             * @type {[AppHistoryState]}
             */
            //@ts-ignore
            //Keep track of old state on pop. Weird API doesn't return it. So have to use it this way
            this.oldState = [];
            this.ignorePopEvent = [];
            //@ts-ignore
            AppRoot.instance = this;
        }

        //@ts-ignore
        return AppRoot.instance;
    }

    get _appStateManager(){

        return this.appStateManager[0];
    }

    get _mainNavInfos(){

        return this.mainNavInfos[0];
    }

    get _mainRouter(){

        return this.mainRouter[0];
    }

    get _oldState(){

        return this.oldState[0];
    }

    get _ignorePopEvent(){

        return this.ignorePopEvent[0];
    }

    set _oldState(currentState){

        this.oldState[0] = currentState;
    }

    set _ignorePopEvent(ignore){

        this.ignorePopEvent[0] = ignore;
    }

    /**
     * This function initializes the App, and should only be called once - in the app's index.js or root file.
     * 
     * It receives the appStateManager, who we'll use to know if app state is different, thus tell
     * router to update with correct state values
     * 
     * @param {AppStateManager} appStateManager
     * @param {MainRouter} mainRouter
     * @param {AppRootView} rootView
     * @param {string} defaultRoute
     * @param {AppExtraOptions} [options]
     */
    initApp(appStateManager, mainRouter, rootView, defaultRoute, options){

        this.appStateManager.push(appStateManager);
        this.mainRouter.push(mainRouter);
        if(rootView){

            this.inspectRootView(rootView);
            this.mainNavInfos.push(rootView.mainNavInfos);
            this.loadRootView(rootView);
            this.bindListenersToNavigation(rootView.mainNavInfos);
        }
        //Set to true until default/current route loads. Help prevent older browsers popStateFiring on page load
        this._ignorePopEvent = true;

        this.setStateChangeCallback();
        this.bindPopStateListener();
        this.setupLinkInterceptor(options?.linkInterceptor);
        this.loadApp(defaultRoute);
    }

    /**
     * Check whether the root view object is valid.
     * @param {AppRootView} rootView
     */
    inspectRootView(rootView){

        if(!rootView.template && rootView.mainNavInfos){

            console.warn("Your rootView object has no template but you've provided mainNavInfos. Please ensure your view is already bound before initializing the app");
        } 
    }

    setStateChangeCallback(){

        this._mainRouter._appRootStateCallback = this.onMainRouterStateChange.bind(this);
    }

    /**
     * Adds the template as the innerHTML of the view parent with the given ID. Concatenates with existing HTML in the parent view
     * Does this if not server-side rendered
     * @param {AppRootView} rootView 
     */
    loadRootView(rootView){

        if(rootView.template){

            if(document.getElementsByTagName("app-root").length === 0){

                let parentView = rootView.viewParentID ? document.getElementById(rootView.viewParentID) : document.body;
                parentView.innerHTML = rootView.template + parentView.innerHTML;
            } else {

                console.warn("Root view not initialized because it's been server side rendered (<app-root> component exists)");
            }
        }

        if(!this.isRootViewStructureValid()){

            console.warn("App Root view structure invalid");
            throw new Error(`The app root view structure is invalid. <app-root> components ${document.getElementsByTagName("app-root").length} instead of 1`);
        }
    }

    isRootViewStructureValid(){

        return document.getElementsByTagName("app-root").length === 1;
    }

    /**
     * Use mainNavInfos
     * Are <app-root> children
     * 
     * Also, avoid adding listener if tagName is A. That is handled by interceptor. See how that works
     * And, for buttons, listener added to ALL matching selector (dot selector used, not to interfere with how ids work)
     * So, this info still allows for you to style active and inactive states using attributes
     * 
     * UPDATE THIS ALGO IN Local Nav Infos for fragments and view panels* as well
     * 
     * @param {MainNavigationInfo[]} mainNavInfos
     */
    bindListenersToNavigation(mainNavInfos){

        if(mainNavInfos){

            mainNavInfos.forEach((info) => {

                //Get the elements per selector
                const elements = this.getAppRootComponent().querySelectorAll(`.${info.selector}`);
                elements.forEach((el) => {

                    if(el.tagName !== "A"){

                        try {
    
                            el.addEventListener("click", this.navigateApp.bind(this, info));
                        } catch (err){
        
                            throw new Error(`Can't set up navigation controls for navigation with dot (class) selector ${info.selector}. Ensure it's inside the <app-root> component\n\n ${err}`);
                        }
                    }
                });
            });
        }
    }

    /**
     * 
     * @returns 
     */
    getAppRootComponent(){

        return document.getElementsByTagName("app-root")[0]
    }

    /**
     * The link click interceptor to naturally route A tags
     * 
     * @param {AppExtraOptions['linkInterceptor']} opts 
     */
    setupLinkInterceptor(opts){

        document.addEventListener("click", (e) => {

            /**
             * @type {string}
             */
            let href = null;
            /**
             * @type {HTMLLinkElement}
             */
            //@ts-expect-error
            const target = e.target;

            /**
             * IMPORTANT
             * 
             * export the styling a *:not([click-override=true]){ pointer-events: none } to ensure this also works
             * with children. Children can only intercept pointer events if set click-override property to true
             * 
             * Allows nested A tags to work well.
             * 
             */
            if(target.tagName === "A"){
                
                href = target.getAttribute("href");
                if(href){

                    //check if href local
                    if(href.startsWith("/")){ //|| href.startsWith("./") Removed this, to support absolute routes ONLY, as per routing info spec
    
                        //local. Route to
                        //Let default action happen if set to open local link in new tab. Else, navigate internally
                        if(target.getAttribute("target") !== "_blank"){
    
                            e.preventDefault();
                            //Can provide an attribute to prevent click interception for A tags
                            if(!target.getAttribute(MainRouter.APP_NAVIGATIONAL_LINK_NO_FOLLOW_ATTR)){

                                /**
                                 * TODO: Know when it fails, and then pop backwards. 
                                 * 
                                 * Implemented. TEST
                                 */
                                this._mainRouter.routeTO(this._mainRouter.getPathURLWithQueryFromUrl(href), null, () => {

                                    this._ignorePopEvent = true
                                    //move back - above value will be reset by listener to pop events
                                    window.history.back();
                                });
                            }
                        }
                    } else {

                        //non-local. Process
                        if(opts?.interceptNonLocal){
    
                            if(!opts.interceptorCb(href)){
    
                                //prevent opening link cause client denied
                                e.preventDefault();
                            }
                        }
                    }
                }
            }
        });
    }

    /**
     * 
     * @param {MainNavigationInfo} info 
     * @param {*} e 
     */
    navigateApp(info, e){

        this._mainRouter.routeToView({ state: this._appStateManager.getBaseAppStateFromMainNavID(info.selector, info.defaultRoute, info.defaultRoute)});
    }

    bindPopStateListener(){

        this._mainRouter._popIgnoreCallback = () => {

            this._ignorePopEvent = true;
        };
        window.onpopstate = this.handleStatePop.bind(this);
    }
    
    /**
     * Handles history pop events
     * @param {PopStateEvent} e 
     */
    handleStatePop(e){
    
        if(!this._ignorePopEvent){
            
            let isBack = true; //Assume we are going back by default
            if(e.state){

                /**
                 * @type {AppHistoryState}
                 */
                const newState = e.state;
                isBack = newState.stackEntry ? newState.stackEntry < this._oldState.stackEntry : true; //Support API before this
            }
            this._mainRouter.routeToPoppedView(isBack);
        } else {

            this._ignorePopEvent = false;
        }
    }

    /**
     * Loads the Oats~i app. 
     * @param {string} defaultRoute 
     */
    loadApp(defaultRoute){

        const currentBaseUrl = this._mainRouter.getPurePathURL();
        let currentUrl = this._mainRouter.getPathURLWithQuery();
        let targetID;
        try {

            targetID = RoutingInfoUtils.findActiveNavIDForUrl(this._mainRouter._routingInfos, currentBaseUrl);
        } catch(err){

            console.warn(err);
            targetID = null;
        }
        if(targetID !== null) {

            //Use the state stored in the browser, if any. Else, default to base one (it may be a new direct load, eg)
            const baseStateAvailable = window.history.state !== null && window.history.state !== undefined;
            this._oldState = baseStateAvailable ? window.history.state : this._appStateManager.getBaseAppStateFromMainNavID(targetID, currentBaseUrl, currentUrl);
            this._mainRouter.routeToAppLaunchRoute(this._oldState, !baseStateAvailable);
        } else {

            //Route undefined. Load default route. Happens if loading root first
            //CORRECT THIS. IT IS LIMITING
            const targetRouteInfo = RoutingInfoUtils.findMatchingRoutingInfoForUrl(this._mainRouter.routingInfos, defaultRoute);
            if(!targetRouteInfo){

                throw new Error("The default route passed could not be found in main nav (routing) infos");
            }
            this._oldState = this._appStateManager.getBaseAppStateFromMainNavID(targetRouteInfo.baseNavBtn, targetRouteInfo.route, targetRouteInfo.route);
            this._mainRouter.routeToAppLaunchRoute(this._oldState, true);
        }
        this._ignorePopEvent = false; //Now can listen to pop event
    }

    /**
     * 
     * @param {AppHistoryState} newState 
     */
    onMainRouterStateChange(newState){

        if(this._mainNavInfos){

            this.updateNavButtons(newState);
        }
        this._oldState = newState;
    }

    /**
     * Updates links as well. Just maintaining original nomenclature
     * 
     * @param {AppHistoryState} newState 
     */
    updateNavButtons(newState){

        const oldActiveNavSelector = this.findMatchingMainNavInfoForState(this._oldState).selector;
        const newActiveNavSelector = this.findMatchingMainNavInfoForState(newState).selector;
        const oldNavEls = this.getAppRootComponent().querySelectorAll(`.${oldActiveNavSelector}`);
        oldNavEls.forEach((el) => {

            el.setAttribute("navigation-state", "inactive");
        });
        const newNavEls = this.getAppRootComponent().querySelectorAll(`.${newActiveNavSelector}`);
        newNavEls.forEach((el) => {

            el.setAttribute("navigation-state", "active");
        });
    }

    /**
     * 
     * @param {AppHistoryState} state 
     */
    findMatchingMainNavInfoForState(state){

        //try a direct match
        let targetNavInfo = this._mainNavInfos.find((info) => state.pageUrl === info.baseActiveRoute)
        //if no direct match found, search for all. Longest string of qualified qualifies
        if(!targetNavInfo){

            /**
             * @type {MainNavigationInfo[]}
             */
            let matchingInfos = [];
            this._mainNavInfos.forEach((info) => {

                if(state.pageUrl.startsWith(info.baseActiveRoute)){

                    matchingInfos.push(info)
                }
            });
            /**
             * @type {MainNavigationInfo}
             */
            let bestQualified = null;
            matchingInfos.forEach((info) => {

                if(!bestQualified){

                    bestQualified = info;
                } else {

                    if(bestQualified.baseActiveRoute.length < info.baseActiveRoute.length){

                        bestQualified = info;
                    }
                }
            });
            if(bestQualified){

                targetNavInfo = bestQualified;
            }
        }

        return targetNavInfo;
    }
}

const appRoot = new AppRoot();
Object.freeze(appRoot);

export default appRoot;