core_StateManager.js

import { Helper } from "../util/Helper.js";
import { DefaultIndexState } from "../base/DefaultIndexState.js";
import { CustomEvents } from "../IF.js";


/**
 * States - The state manager.
 * You can create multiple States instances in your application logic, e.g. for dealing with sub-states etc.
 */
class StateManager
{
    static DEFAULT_INDEX_STATE_ID = 'INFRONT_DEFAULT_INDEX_STATE';
    static DEFAULT_NOT_FOUND_STATE_ID = 'INFRONT_DEFAULT_NOTFOUND_STATE';

    /**
     * Constructor
     * @param {App} appInstance - Instance of app
     * @param {State=} parentState - Parent state for sub-state managers
     */
    constructor( appInstance, parentState = null )
    {
        this.app = appInstance;
        this.parentState = parentState;
        this._states =  {};
        this._currentState = null;
        this._stateNotFoundClass = this.app ? this.app.config.get( 'stateManager.notFoundState' ) : null;
    }

    set currentState( currentState )
    {
        this._currentState = currentState;
    }

    get currentState()
    {
        return this._currentState;
    }

    set stateNotFoundClass( stateNotFoundClass )
    {
        if ( false === Helper.isClass( stateNotFoundClass ) )
        {
            throw new Error( 'States.setNotFoundClass expects a class/subclass of State.' );
        }

        this._stateNotFoundClass = stateNotFoundClass;
    }

    get stateNotFoundClass()
    {
        return this._stateNotFoundClass;
    }

    /**
     * Add state class
     *
     * @param {...DefaultBaseState} stateClasses - State class to be added.
     * @throws {Error}  - Throws an error when adding state is not possible
     * @returns {boolean} - Returns wheter or not adding was successful
     */
    add( ...stateClasses )
    {
        for( const stateClass of stateClasses )
        {
            if ( false === Helper.isClass( stateClass ) )
            {
                throw new Error( 'States.addState expects a class/subclass of State.' );
            }

            // Autogeneratre id in case it is not valid
            if ( false === Helper.isString( stateClass.ID ) )
            {
                console.warn( 'StateClass doesnt have a valid ID.' );
                stateClass.ID = Helper.createUid();
            }

            if ( true === this._states.hasOwnProperty( stateClass.ID ) )
            {
                console.warn( `StateClass not added. ID ${stateClass.ID} already exists.` );
                return false;
            }

            this._states[ stateClass.ID ] = stateClass;

            if ( Helper.isString( stateClass.ROUTE ) )
            {
                this.app.router.addRoute( stateClass.ROUTE, stateClass );
            }
            else if ( Helper.isArray( stateClass.ROUTE ) )
            {
                for ( let route of stateClass.ROUTE )
                {
                    this.app.router.addRoute( route, stateClass );
                }
            }
        }
    }

    /**
     * Create an instance of given state id
     *
     * @param {string} stateId - The state id to be instantiated
     * @param {RouteParams} routeParams - Current RouteParams
     * @returns {State|null} - State instance or null
     */
    create( stateId, routeParams )
    {
        let stateInstance = null;

        if ( this._states.hasOwnProperty( stateId ) )
        {
            // Pass parent state to sub-state constructor
            stateInstance = new this._states[ stateId ]( this.app, routeParams, this.parentState );
        }
        else if ( null !== this.stateNotFoundClass )
        {
            stateInstance = new this.stateNotFoundClass( this.app, routeParams, this.parentState );
        }
        else
        {
            console.error( `State ${stateId} does not exist.` );
        }

        return stateInstance;
    }

    /**
     * Checks if given stateId already exists.
     *
     * @param {string} stateId
     * @return {boolean}
     */
    exists( stateId )
    {
        return this._states.hasOwnProperty( stateId );
    }

    /**
     * Switch to given state
     * @param {DefaultBaseState} newState - Instance of state to switch to
     * @throws {Error} - Throws an error if given state cannot be entered, current state cannot be exited, or if enter()/exit() functions throw an error.
     * @returns {Promise<boolean>}
     */
    async switchTo( newState )
    {
        let previousStateId = null;
        let currentStateId = this.currentState ? this.currentState.getId() : null;

        // Create hierarchy-aware event details
        const eventDetail = {
            currentStateId: currentStateId,
            nextStateId: newState ? newState.getId() : null,
            isSubState: this.parentState !== null,
            parentStateId: this.parentState ? this.parentState.getId() : null
        };

        this.app.dispatchEvent(
            new CustomEvent(CustomEvents.TYPE.BEFORE_STATE_CHANGE, { detail: eventDetail })
        );

        if ( false === newState.canEnter() )
        {
            const redirectUrl = newState.getRedirectUrl();
            if ( redirectUrl )
            {
                // For sub-states, delegate redirect to root state manager
                if (this.parentState) {
                    const rootState = newState.getRootState();
                    const rootApp = rootState.app;
                    if (rootApp && rootApp.router) {
                        rootApp.router.redirect(redirectUrl);
                    }
                } else {
                    this.app.router.redirect( redirectUrl );
                }
                return false;
            }

            throw Error( 'Forbidden to enter new state:' + newState.getId() );
        }

        if ( this.currentState )
        {
            // Check if current state can be exited
            if ( false === this.currentState.canExit() )
            {
                throw new Error( 'Cannot exit current state: ' + this.currentState.getId() );
            }

            previousStateId = this.currentState.getId();
            await this.currentState.exit();
            
            // Dispose of the current state to cleanup resources (including sub-states)
            if (typeof this.currentState.dispose === 'function') {
                try {
                    await this.currentState.dispose();
                } catch (error) {
                    console.warn('Error disposing state:', error);
                }
            }
            
            delete this.currentState;
        }

        this.currentState = newState;
        
        // Update parent state reference if this is a sub-state manager
        if (this.parentState) {
            this.parentState.currentSubState = newState;
        }
        
        await newState.enter();
        currentStateId = this.currentState.getId();

        // Update event detail for after-change event
        eventDetail.previousStateId = previousStateId;
        eventDetail.currentStateId = currentStateId;

        this.app.dispatchEvent(
            new CustomEvent(CustomEvents.TYPE.AFTER_STATE_CHANGE, { detail: eventDetail })
        );

        return true;
    }

}

export { StateManager };