UNPKG

@player-ui/player

Version:

221 lines (173 loc) 6.08 kB
import { SyncBailHook, SyncHook, SyncWaterfallHook } from "tapable-ts"; import type { DeferredPromise } from "p-defer"; import defer from "p-defer"; import type { NavigationFlow, NavigationFlowState, NavigationFlowEndState, } from "@player-ui/types"; import type { Logger } from "../../logger"; export interface NamedState { /** The name of the navigation node */ name: string; /** The nav node */ value: NavigationFlowState; } export interface TransitionOptions { /** Ignore any validations or other signals preventing the transition from taking place */ force?: boolean; } export type TransitionFunction = ( name: string, options?: TransitionOptions, ) => void; /** The Content navigation state machine */ export class FlowInstance { private flow: NavigationFlow; private log?: Logger; private history: string[]; private isTransitioning = false; private flowPromise?: DeferredPromise<NavigationFlowEndState>; public readonly id: string; public currentState?: NamedState; public readonly hooks = { beforeStart: new SyncBailHook<[NavigationFlow], NavigationFlow>(), /** A callback when the onStart node was present */ onStart: new SyncHook<[any]>(), /** A callback when the onEnd node was present */ onEnd: new SyncHook<[any]>(), /** A hook to intercept and block a transition */ skipTransition: new SyncBailHook< [NamedState | undefined], boolean | undefined >(), /** A chance to manipulate the flow-node used to calculate the given transition used */ beforeTransition: new SyncWaterfallHook< [Exclude<NavigationFlowState, NavigationFlowEndState>, string] >(), /** A chance to manipulate the flow-node calculated after a transition */ resolveTransitionNode: new SyncWaterfallHook<[NavigationFlowState]>(), /** A callback when a transition from 1 state to another was made */ transition: new SyncHook<[NamedState | undefined, NamedState]>(), /** A callback to run actions after a transition occurs */ afterTransition: new SyncHook<[FlowInstance]>(), }; constructor( id: string, flow: NavigationFlow, options?: { /** Logger instance to use */ logger?: Logger; }, ) { this.id = id; this.flow = flow; this.log = options?.logger; this.history = []; this.hooks.transition.tap( "startPromise", async (_oldState, nextState: NamedState) => { const newState = nextState.value; if (this.flowPromise && newState.state_type === "END") { this.flowPromise.resolve(newState); } }, ); } /** Start the state machine */ public async start(): Promise<NavigationFlowEndState> { if (this.flowPromise) { this.log?.warn("Already called start for flow"); return this.flowPromise.promise; } this.flow = this.hooks.beforeStart.call(this.flow) || this.flow; if (this.flow.onStart) { this.hooks.onStart.call(this.flow.onStart); } const initialState = this.flow.startState; if (!initialState) { return Promise.reject(new Error("No 'startState' defined for flow")); } this.flowPromise = defer(); this.pushHistory(initialState); return this.flowPromise.promise; } public transition(transitionValue: string, options?: TransitionOptions) { if (this.isTransitioning) { throw new Error( `Transitioning while ongoing transition from ${this.currentState?.name} is in progress is not supported`, ); } if (this.currentState?.value.state_type === "END") { this.log?.warn( `Skipping transition using ${transitionValue}. Already at and END state`, ); return; } if (this.currentState === undefined) { throw new Error("Cannot transition when there's no current state"); } if (options?.force) { this.log?.debug(`Forced transition. Skipping validation checks`); } else { const skipTransition = this.hooks.skipTransition.call(this.currentState); if (skipTransition) { this.log?.debug( `Skipping transition from ${this.currentState.name} b/c hook told us to`, ); return; } } const state = this.hooks.beforeTransition.call( this.currentState.value, transitionValue, ); if (!("transitions" in state)) { throw new Error(`No transitions defined for ${this.currentState.value}`); } const { transitions } = state; const nextState = transitions[transitionValue] || transitions["*"]; if (nextState === undefined) { this.log?.warn( `No transition from ${this.currentState.name} using ${transitionValue} or *`, ); return; } this.log?.debug( `Transitioning from ${this.currentState.name} to ${nextState} using ${transitionValue} `, ); return this.pushHistory(nextState, options); } private pushHistory(stateName: string, options?: TransitionOptions) { if (!Object.prototype.hasOwnProperty.call(this.flow, stateName)) { throw new Error(`No flow definition for: ${stateName} was found.`); } let nextState = this.flow[stateName]; if ( !this.flow[stateName] || typeof nextState !== "object" || !("state_type" in nextState) ) { this.log?.error(`Flow doesn't contain any states named: ${stateName}`); return; } const prevState = this.currentState; this.isTransitioning = true; nextState = this.hooks.resolveTransitionNode.call(nextState); const newCurrentState = { name: stateName, value: nextState, } as NamedState; this.currentState = newCurrentState; this.history.push(stateName); // If the new state is an END state call the `onEnd` if it exists if (newCurrentState.value.state_type === "END" && this.flow.onEnd) { this.hooks.onEnd.call(this.flow.onEnd); } this.hooks.transition.call(prevState, { ...newCurrentState, }); this.isTransitioning = false; this.hooks.afterTransition.call(this); } }