Skip to content

Commit e826197

Browse files
Copilothotlong
andcommitted
Implement Expression Evaluator and Action Runner in core package with React hooks
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent aa769ed commit e826197

10 files changed

Lines changed: 801 additions & 2 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* @object-ui/core - Action Runner
11+
*
12+
* Executes actions defined in ActionSchema and EventHandler.
13+
*/
14+
15+
import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
16+
17+
export interface ActionResult {
18+
success: boolean;
19+
data?: any;
20+
error?: string;
21+
reload?: boolean;
22+
close?: boolean;
23+
redirect?: string;
24+
}
25+
26+
export interface ActionContext {
27+
data?: Record<string, any>;
28+
record?: any;
29+
user?: any;
30+
[key: string]: any;
31+
}
32+
33+
export type ActionHandler = (
34+
action: any,
35+
context: ActionContext
36+
) => Promise<ActionResult> | ActionResult;
37+
38+
export class ActionRunner {
39+
private handlers = new Map<string, ActionHandler>();
40+
private evaluator: ExpressionEvaluator;
41+
private context: ActionContext;
42+
43+
constructor(context: ActionContext = {}) {
44+
this.context = context;
45+
this.evaluator = new ExpressionEvaluator(context);
46+
}
47+
48+
registerHandler(actionName: string, handler: ActionHandler): void {
49+
this.handlers.set(actionName, handler);
50+
}
51+
52+
async execute(action: any): Promise<ActionResult> {
53+
try {
54+
if (action.condition) {
55+
const shouldExecute = this.evaluator.evaluateCondition(action.condition);
56+
if (!shouldExecute) {
57+
return { success: false, error: 'Action condition not met' };
58+
}
59+
}
60+
61+
if (action.disabled) {
62+
const isDisabled = typeof action.disabled === 'string'
63+
? this.evaluator.evaluateCondition(action.disabled)
64+
: action.disabled;
65+
66+
if (isDisabled) {
67+
return { success: false, error: 'Action is disabled' };
68+
}
69+
}
70+
71+
if (action.type === 'action' || action.actionType) {
72+
return await this.executeActionSchema(action);
73+
} else if (action.type === 'navigation' || action.navigate) {
74+
return await this.executeNavigation(action);
75+
} else if (action.type === 'api' || action.api) {
76+
return await this.executeAPI(action);
77+
} else if (action.onClick) {
78+
await action.onClick();
79+
return { success: true };
80+
}
81+
82+
return { success: false, error: 'Unknown action type' };
83+
} catch (error) {
84+
return { success: false, error: (error as Error).message };
85+
}
86+
}
87+
88+
private async executeActionSchema(action: any): Promise<ActionResult> {
89+
const result: ActionResult = { success: true };
90+
91+
if (action.confirmText) {
92+
const confirmed = await this.showConfirmation(action.confirmText);
93+
if (!confirmed) {
94+
return { success: false, error: 'Action cancelled by user' };
95+
}
96+
}
97+
98+
if (action.api) {
99+
const apiResult = await this.executeAPI(action);
100+
if (!apiResult.success) return apiResult;
101+
result.data = apiResult.data;
102+
}
103+
104+
if (action.onClick) {
105+
await action.onClick();
106+
}
107+
108+
result.reload = action.reload !== false;
109+
result.close = action.close !== false;
110+
111+
if (action.redirect) {
112+
result.redirect = this.evaluator.evaluate(action.redirect) as string;
113+
}
114+
115+
return result;
116+
}
117+
118+
private async executeNavigation(action: any): Promise<ActionResult> {
119+
const nav = action.navigate || action;
120+
const to = this.evaluator.evaluate(nav.to) as string;
121+
122+
if (nav.external) {
123+
window.open(to, '_blank');
124+
} else {
125+
return { success: true, redirect: to };
126+
}
127+
128+
return { success: true };
129+
}
130+
131+
private async executeAPI(action: any): Promise<ActionResult> {
132+
const apiConfig = action.api;
133+
134+
if (typeof apiConfig === 'string') {
135+
try {
136+
const response = await fetch(apiConfig, {
137+
method: action.method || 'POST',
138+
headers: { 'Content-Type': 'application/json' },
139+
body: JSON.stringify(this.context.data || {})
140+
});
141+
142+
if (!response.ok) {
143+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
144+
}
145+
146+
const data = await response.json();
147+
return { success: true, data };
148+
} catch (error) {
149+
return { success: false, error: (error as Error).message };
150+
}
151+
}
152+
153+
return { success: false, error: 'Complex API configuration not yet implemented' };
154+
}
155+
156+
private async showConfirmation(message: string): Promise<boolean> {
157+
const evaluatedMessage = this.evaluator.evaluate(message) as string;
158+
return window.confirm(evaluatedMessage);
159+
}
160+
161+
updateContext(newContext: Partial<ActionContext>): void {
162+
this.context = { ...this.context, ...newContext };
163+
this.evaluator.updateContext(newContext);
164+
}
165+
166+
getContext(): ActionContext {
167+
return this.context;
168+
}
169+
}
170+
171+
export async function executeAction(
172+
action: any,
173+
context: ActionContext = {}
174+
): Promise<ActionResult> {
175+
const runner = new ActionRunner(context);
176+
return await runner.execute(action);
177+
}

packages/core/src/actions/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
export * from './ActionRunner';
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* @object-ui/core - Expression Context
11+
*
12+
* Manages variable scope and data context for expression evaluation.
13+
*
14+
* @module evaluator
15+
* @packageDocumentation
16+
*/
17+
18+
/**
19+
* Expression context for variable resolution
20+
*/
21+
export class ExpressionContext {
22+
private scopes: Map<string, any>[] = [];
23+
24+
constructor(initialData: Record<string, any> = {}) {
25+
this.scopes.push(new Map(Object.entries(initialData)));
26+
}
27+
28+
/**
29+
* Push a new scope onto the context stack
30+
*/
31+
pushScope(data: Record<string, any>): void {
32+
this.scopes.push(new Map(Object.entries(data)));
33+
}
34+
35+
/**
36+
* Pop the current scope from the context stack
37+
*/
38+
popScope(): void {
39+
if (this.scopes.length > 1) {
40+
this.scopes.pop();
41+
}
42+
}
43+
44+
/**
45+
* Get a variable value from the context
46+
* Searches from innermost to outermost scope
47+
*/
48+
get(path: string): any {
49+
// Split path by dots for nested access
50+
const parts = path.split('.');
51+
const varName = parts[0];
52+
53+
// Search scopes from innermost to outermost
54+
for (let i = this.scopes.length - 1; i >= 0; i--) {
55+
if (this.scopes[i].has(varName)) {
56+
let value = this.scopes[i].get(varName);
57+
58+
// Navigate nested path
59+
for (let j = 1; j < parts.length; j++) {
60+
if (value && typeof value === 'object') {
61+
value = value[parts[j]];
62+
} else {
63+
return undefined;
64+
}
65+
}
66+
67+
return value;
68+
}
69+
}
70+
71+
return undefined;
72+
}
73+
74+
/**
75+
* Set a variable value in the current scope
76+
*/
77+
set(name: string, value: any): void {
78+
if (this.scopes.length > 0) {
79+
this.scopes[this.scopes.length - 1].set(name, value);
80+
}
81+
}
82+
83+
/**
84+
* Check if a variable exists in any scope
85+
*/
86+
has(name: string): boolean {
87+
const varName = name.split('.')[0];
88+
for (let i = this.scopes.length - 1; i >= 0; i--) {
89+
if (this.scopes[i].has(varName)) {
90+
return true;
91+
}
92+
}
93+
return false;
94+
}
95+
96+
/**
97+
* Get all variables from all scopes as a flat object
98+
*/
99+
toObject(): Record<string, any> {
100+
const result: Record<string, any> = {};
101+
// Merge from outermost to innermost (later scopes override earlier ones)
102+
for (const scope of this.scopes) {
103+
for (const [key, value] of scope.entries()) {
104+
result[key] = value;
105+
}
106+
}
107+
return result;
108+
}
109+
110+
/**
111+
* Create a child context with additional data
112+
*/
113+
createChild(data: Record<string, any> = {}): ExpressionContext {
114+
const child = new ExpressionContext();
115+
child.scopes = [...this.scopes, new Map(Object.entries(data))];
116+
return child;
117+
}
118+
}

0 commit comments

Comments
 (0)