425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
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<void>;
|
|
|
|
/**
|
|
* 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<ActionType, ActionExecutor> = 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<EventExecutorConfig>) {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<any> }
|
|
): Promise<void> => {
|
|
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}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|