my-test-app-builder-2024/src/common/events/EventExecutor.ts

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}`);
}
}
}
}
};