import type { EventHandler, Page } from '../types/component.types'; import { evaluateExpression } from '../utils/expressionEvaluator'; import { resolveRequestBody } from '../utils/stateBinding'; import { normalizeWorkflowResultKey, suggestWorkflowResultKeyFromName } from '../utils/workflowResultKey'; import { parseScopedPath } from '../state/scopedPath'; /** * Event types that components can dispatch */ export type EventType = 'click' | 'change' | 'submit' | 'focus' | 'blur'; /** * Action types that event handlers can execute */ export type ActionType = 'setState' | 'navigateToPage' | 'workflow'; /** * Context required for executing actions */ export interface ActionContext { /** Set a state value by path */ setStateValue: (path: string, value: any) => boolean; /** Set a state value by raw key */ setStateValueRaw: (key: string, value: any) => boolean; /** Set a state value directly without type auto-detection (for expressions) */ setStateValueDirect: (path: string, value: any) => boolean; /** Navigate to a page by ID */ navigateToPage: (pageId: string) => void; /** All pages (for validation) */ pages: Page[]; /** Scoped runtime state (for expression evaluation) */ state: any; } /** * Configuration for workflow execution */ export interface WorkflowConfig { /** URL to call for workflow execution */ executeUrl: string; /** Request body builder */ buildRequestBody?: (workflowName: string) => any; } /** * Action executor function type */ export type ActionExecutor = ( handler: EventHandler, context: ActionContext, config: EventExecutorConfig ) => Promise; /** * Configuration for the EventExecutor */ export interface EventExecutorConfig { workflow?: WorkflowConfig; } /** * Validates a single event handler structure */ const isValidHandler = (handler: any): handler is EventHandler => { if (!handler || typeof handler !== 'object') return false; if (!handler.id || typeof handler.id !== 'string') return false; if (!handler.action || !['setState', 'navigateToPage', 'workflow'].includes(handler.action)) return false; if (handler.action === 'setState') { return !!(handler.statePath && handler.value !== undefined); } else if (handler.action === 'navigateToPage') { return !!handler.pageId; } else if (handler.action === 'workflow') { return !!handler.workflowName; } return false; }; /** * Validates and filters event handlers array */ export const validateHandlers = (handlers: any[]): EventHandler[] => { if (!Array.isArray(handlers)) return []; return handlers.filter(isValidHandler); }; // ============= Action Executors ============= /** * Execute setState action */ const executeSetState: ActionExecutor = async (handler, context) => { if (!handler.statePath || typeof handler.statePath !== 'string') { throw new Error(`Invalid state path for handler ${handler.id}`); } let parsedStatePath; try { parsedStatePath = parseScopedPath(handler.statePath); } catch (error) { throw new Error( `Invalid scoped state path for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}` ); } // Evaluate expression if value is a string (JavaScript expression) let evaluatedValue = handler.value; if (typeof handler.value === 'string' && handler.value.trim().length > 0) { try { evaluatedValue = evaluateExpression(handler.value, { state: context.state }); } catch (error: any) { throw new Error(`Expression evaluation failed for handler ${handler.id}: ${error?.message || String(error)}`); } } // Use setStateValueDirect to preserve expression result type // This prevents "1" from being auto-converted to 1, which would break string methods like .slice() const success = context.setStateValueDirect(parsedStatePath.fullPath, evaluatedValue); if (!success) { throw new Error(`Failed to set state value for handler ${handler.id}`); } }; /** * Execute navigateToPage action */ const executeNavigateToPage: ActionExecutor = async (handler, context) => { if (!handler.pageId) { throw new Error(`Missing pageId for handler ${handler.id}`); } const pageExists = context.pages.some((p) => p.id === handler.pageId); if (!pageExists) { throw new Error(`Page ${handler.pageId} does not exist for handler ${handler.id}`); } context.navigateToPage(handler.pageId); }; /** * Execute workflow action */ const executeWorkflow: ActionExecutor = async (handler, context, config) => { if (!handler.workflowName || typeof handler.workflowName !== 'string') { throw new Error(`Missing workflow name for handler ${handler.id}`); } // Use handler's workflowUrl if available, otherwise fall back to config const workflowUrl = handler.workflowUrl || config.workflow?.executeUrl; if (!workflowUrl) { throw new Error( `Workflow handler ${handler.id} requires a workflow URL. Provide handler.workflowUrl or configure workflow.executeUrl.` ); } // Resolve request body with state bindings let requestBody: any = {}; if (handler.requestBody) { // Use handler's custom request body and resolve {{ }} bindings try { requestBody = resolveRequestBody(handler.requestBody, context.state); } catch (error) { throw new Error(`Failed to resolve request body for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`); } } else { const buildRequestBody = config.workflow?.buildRequestBody; if (buildRequestBody) { // Fallback to config's buildRequestBody (for backward compatibility) requestBody = buildRequestBody(handler.workflowName); } } const response = await fetch(workflowUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }); if (!response.ok) { throw new Error(`Workflow execution failed: ${response.status}`); } const responseText = await response.text(); let workflowResult: any = null; if (responseText) { try { workflowResult = JSON.parse(responseText); } catch { workflowResult = responseText; } } let normalizedResultKey; try { const resultKeyInput = handler.workflowResultKey?.trim() ? handler.workflowResultKey : suggestWorkflowResultKeyFromName(handler.workflowName); normalizedResultKey = normalizeWorkflowResultKey(resultKeyInput); } catch (error) { throw new Error( `Invalid workflow result key for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}` ); } const success = context.setStateValueDirect(normalizedResultKey.fullPath, workflowResult); if (!success) { throw new Error(`Failed to save workflow result for handler ${handler.id}`); } }; // ============= Action Registry ============= /** * Registry of action executors */ const actionExecutors: Map = new Map([ ['setState', executeSetState], ['navigateToPage', executeNavigateToPage], ['workflow', executeWorkflow], ]); /** * Register a custom action executor */ export const registerActionExecutor = (action: string, executor: ActionExecutor): void => { actionExecutors.set(action as ActionType, executor); }; /** * Get an action executor by action type */ export const getActionExecutor = (action: ActionType): ActionExecutor | undefined => { return actionExecutors.get(action); }; // ============= EventExecutor Class ============= /** * EventExecutor - Centralized service for executing event handlers */ export class EventExecutor { private config: EventExecutorConfig; private context: ActionContext | null = null; constructor(config?: Partial) { this.config = { workflow: config?.workflow, }; } /** * Set the action context (called when context changes) */ setContext(context: ActionContext): void { this.context = context; } /** * Evaluate a handler's condition (if present) * @param handler - Event handler with optional condition * @returns true if condition passes or no condition exists, false otherwise * @throws Error if condition evaluation fails with error message */ private evaluateCondition(handler: EventHandler): boolean { // If no condition specified, always execute (backward compatibility) if (!handler.condition || handler.condition.trim() === '') { return true; } if (!this.context) { return false; } try { // Evaluate the condition expression const result = evaluateExpression(handler.condition, { state: this.context.state }); // If condition is falsy, check if we should show an error if (!result) { if (handler.conditionErrorMessage) { // Throw error with custom message - will be caught by executeWithErrorHandling throw new Error(handler.conditionErrorMessage); } // No error message - silently skip this handler return false; } return true; } catch (error: any) { // If there's a custom error message from condition failure, use it if (handler.conditionErrorMessage && error.message === handler.conditionErrorMessage) { throw error; // Re-throw to preserve custom message } // Otherwise, it's an expression evaluation error throw new Error(`Condition evaluation failed for handler ${handler.id}: ${error?.message || String(error)}`); } } /** * Execute event handlers sequentially * @param handlers - Array of event handlers to execute * @throws Error if execution fails */ async execute(handlers: EventHandler[]): Promise { if (!this.context) { throw new Error('EventExecutor context not set. Call setContext() first.'); } const validHandlers = validateHandlers(handlers); if (validHandlers.length === 0) return; for (const handler of validHandlers) { // Evaluate condition before executing handler const shouldExecute = this.evaluateCondition(handler); // Skip this handler if condition failed (without error message) if (!shouldExecute) { console.log(`Skipping handler ${handler.id} - condition not met`); continue; } const executor = actionExecutors.get(handler.action); if (!executor) { throw new Error(`Unknown action type: ${handler.action}`); } await executor(handler, this.context, this.config); } } /** * Execute handlers with error handling (shows alert on failure) */ async executeWithErrorHandling(handlers: EventHandler[]): Promise { try { await this.execute(handlers); return true; } catch (error) { console.error('Event execution failed:', error); const message = error instanceof Error && error.message ? error.message : 'Event handler execution failed. Please check your workflow or state configuration.'; alert(message); return false; } } } // ============= Singleton Instance ============= /** * Global EventExecutor instance */ export const eventExecutor = new EventExecutor(); // ============= Legacy API (backward compatibility) ============= /** * Legacy function for backward compatibility * @deprecated Use eventExecutor.execute() instead */ export const executeEventHandlers = async ( handlers: EventHandler[], context: ActionContext & { runWorkflow?: (workflowName: string) => Promise } ): Promise => { const validHandlers = validateHandlers(handlers); if (validHandlers.length === 0) return; for (const handler of validHandlers) { if (handler.action === 'setState') { if (!handler.statePath || typeof handler.statePath !== 'string') { throw new Error(`Invalid state path for handler ${handler.id}`); } let parsedStatePath; try { parsedStatePath = parseScopedPath(handler.statePath); } catch (error) { throw new Error( `Invalid scoped state path for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}` ); } const success = context.setStateValue(parsedStatePath.fullPath, handler.value); if (!success) { throw new Error(`Failed to set state value for handler ${handler.id}`); } } else if (handler.action === 'navigateToPage') { if (!handler.pageId) { throw new Error(`Missing pageId for handler ${handler.id}`); } const pageExists = context.pages.some((p) => p.id === handler.pageId); if (!pageExists) { throw new Error(`Page ${handler.pageId} does not exist for handler ${handler.id}`); } context.navigateToPage(handler.pageId); } else if (handler.action === 'workflow') { if (!handler.workflowName || typeof handler.workflowName !== 'string') { throw new Error(`Missing workflow name for handler ${handler.id}`); } if (context.runWorkflow) { const workflowResult = await context.runWorkflow(handler.workflowName); let normalizedResultKey; try { const resultKeyInput = handler.workflowResultKey?.trim() ? handler.workflowResultKey : suggestWorkflowResultKeyFromName(handler.workflowName); normalizedResultKey = normalizeWorkflowResultKey(resultKeyInput); } catch (error) { throw new Error( `Invalid workflow result key for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}` ); } const success = context.setStateValueDirect(normalizedResultKey.fullPath, workflowResult); if (!success) { throw new Error(`Failed to save workflow result for handler ${handler.id}`); } } } } };