diff --git a/packages/core/src/data_sources/model/DataVariableListenerManager.ts b/packages/core/src/data_sources/model/DataVariableListenerManager.ts index e2941b523b..45a5b1e8fa 100644 --- a/packages/core/src/data_sources/model/DataVariableListenerManager.ts +++ b/packages/core/src/data_sources/model/DataVariableListenerManager.ts @@ -71,4 +71,8 @@ export default class DynamicVariableListenerManager { this.dataListeners.forEach((ls) => model.stopListening(ls.obj, ls.event, this.onChange)); this.dataListeners = []; } + + destroy() { + this.removeListeners(); + } } diff --git a/packages/core/src/data_sources/model/conditional_variables/Condition.ts b/packages/core/src/data_sources/model/conditional_variables/Condition.ts new file mode 100644 index 0000000000..4586f804b2 --- /dev/null +++ b/packages/core/src/data_sources/model/conditional_variables/Condition.ts @@ -0,0 +1,106 @@ +import EditorModel from '../../../editor/model/Editor'; +import DataVariable from '../DataVariable'; +import { evaluateVariable, isDataVariable } from '../utils'; +import { Expression, LogicGroup } from './DataCondition'; +import { LogicalGroupStatement } from './LogicalGroupStatement'; +import { Operator } from './operators'; +import { GenericOperation, GenericOperator } from './operators/GenericOperator'; +import { LogicalOperator } from './operators/LogicalOperator'; +import { NumberOperator, NumberOperation } from './operators/NumberOperator'; +import { StringOperator, StringOperation } from './operators/StringOperations'; + +export class Condition { + private condition: Expression | LogicGroup | boolean; + private em: EditorModel; + + constructor(condition: Expression | LogicGroup | boolean, opts: { em: EditorModel }) { + this.condition = condition; + this.em = opts.em; + } + + evaluate(): boolean { + return this.evaluateCondition(this.condition); + } + + /** + * Recursively evaluates conditions and logic groups. + */ + private evaluateCondition(condition: any): boolean { + if (typeof condition === 'boolean') return condition; + + if (this.isLogicGroup(condition)) { + const { logicalOperator, statements } = condition; + const operator = new LogicalOperator(logicalOperator); + const logicalGroup = new LogicalGroupStatement(operator, statements, { em: this.em }); + return logicalGroup.evaluate(); + } + + if (this.isExpression(condition)) { + const { left, operator, right } = condition; + const evaluateLeft = evaluateVariable(left, this.em); + const evaluateRight = evaluateVariable(right, this.em); + const op = this.getOperator(evaluateLeft, operator); + + const evaluated = op.evaluate(evaluateLeft, evaluateRight); + return evaluated; + } + + throw new Error('Invalid condition type.'); + } + + /** + * Factory method for creating operators based on the data type. + */ + private getOperator(left: any, operator: string): Operator { + if (this.isOperatorInEnum(operator, GenericOperation)) { + return new GenericOperator(operator as GenericOperation); + } else if (typeof left === 'number') { + return new NumberOperator(operator as NumberOperation); + } else if (typeof left === 'string') { + return new StringOperator(operator as StringOperation); + } + throw new Error(`Unsupported data type: ${typeof left}`); + } + + /** + * Extracts all data variables from the condition, including nested ones. + */ + getDataVariables(): DataVariable[] { + const variables: DataVariable[] = []; + this.extractVariables(this.condition, variables); + return variables; + } + + /** + * Recursively extracts variables from expressions or logic groups. + */ + private extractVariables(condition: boolean | LogicGroup | Expression, variables: DataVariable[]): void { + if (this.isExpression(condition)) { + if (isDataVariable(condition.left)) variables.push(condition.left); + if (isDataVariable(condition.right)) variables.push(condition.right); + } else if (this.isLogicGroup(condition)) { + condition.statements.forEach((stmt) => this.extractVariables(stmt, variables)); + } + } + + /** + * Checks if a condition is a LogicGroup. + */ + private isLogicGroup(condition: any): condition is LogicGroup { + return condition && typeof condition.logicalOperator !== 'undefined' && Array.isArray(condition.statements); + } + + /** + * Checks if a condition is an Expression. + */ + private isExpression(condition: any): condition is Expression { + return condition && typeof condition.left !== 'undefined' && typeof condition.operator === 'string'; + } + + /** + * Checks if an operator exists in a specific enum. + */ + private isOperatorInEnum(operator: string, enumObject: any): boolean { + return Object.values(enumObject).includes(operator); + } +} diff --git a/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts b/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts index fbcf891969..745caae342 100644 --- a/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts +++ b/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts @@ -3,9 +3,13 @@ import { StringOperation } from './operators/StringOperations'; import { GenericOperation } from './operators/GenericOperator'; import { Model } from '../../../common'; import { LogicalOperation } from './operators/LogicalOperator'; -import { evaluateCondition } from './evaluateCondition'; +import DynamicVariableListenerManager from '../DataVariableListenerManager'; +import EditorModel from '../../../editor/model/Editor'; +import { Condition } from './Condition'; +import DataVariable from '../DataVariable'; +import { evaluateVariable, isDataVariable } from '../utils'; -export const ConditionalVariableType = 'conditional-variable'; +export const DataConditionType = 'conditional-variable'; export type Expression = { left: any; operator: GenericOperation | StringOperation | NumberOperation; @@ -19,32 +23,75 @@ export type LogicGroup = { export class DataCondition extends Model { private conditionResult: boolean; + private condition: Condition; + private em: EditorModel; + private variableListeners: DynamicVariableListenerManager[] = []; defaults() { return { - type: ConditionalVariableType, + type: DataConditionType, condition: false, }; } constructor( - private condition: Expression | LogicGroup | boolean, + condition: Expression | LogicGroup | boolean, private ifTrue: any, private ifFalse: any, + opts: { em: EditorModel }, ) { super(); + this.condition = new Condition(condition, { em: opts.em }); + this.em = opts.em; this.conditionResult = this.evaluate(); + this.listenToDataVariables(); } evaluate() { - return evaluateCondition(this.condition); + return this.condition.evaluate(); } getDataValue(): any { - return this.conditionResult ? this.ifTrue : this.ifFalse; + return this.conditionResult ? evaluateVariable(this.ifTrue, this.em) : evaluateVariable(this.ifFalse, this.em); } reevaluate(): void { this.conditionResult = this.evaluate(); } + + toJSON() { + return { + condition: this.condition, + ifTrue: this.ifTrue, + ifFalse: this.ifFalse, + }; + } + + private listenToDataVariables() { + if (!this.em) return; + + // Clear previous listeners to avoid memory leaks + this.cleanupListeners(); + + const dataVariables = this.condition.getDataVariables(); + if (isDataVariable(this.ifTrue)) dataVariables.push(this.ifTrue); + if (isDataVariable(this.ifFalse)) dataVariables.push(this.ifFalse); + + dataVariables.forEach((variable) => { + const variableInstance = new DataVariable(variable, { em: this.em }); + const listener = new DynamicVariableListenerManager({ + model: this as any, + em: this.em!, + dataVariable: variableInstance, + updateValueFromDataVariable: this.reevaluate.bind(this), + }); + + this.variableListeners.push(listener); + }); + } + + private cleanupListeners() { + this.variableListeners.forEach((listener) => listener.destroy()); + this.variableListeners = []; + } } diff --git a/packages/core/src/data_sources/model/conditional_variables/LogicalGroupStatement.ts b/packages/core/src/data_sources/model/conditional_variables/LogicalGroupStatement.ts index 4d4796c0df..cba3764955 100644 --- a/packages/core/src/data_sources/model/conditional_variables/LogicalGroupStatement.ts +++ b/packages/core/src/data_sources/model/conditional_variables/LogicalGroupStatement.ts @@ -1,15 +1,24 @@ import { LogicalOperator } from './operators/LogicalOperator'; import { Expression, LogicGroup } from './DataCondition'; -import { evaluateCondition } from './evaluateCondition'; +import { Condition } from './Condition'; +import EditorModel from '../../../editor/model/Editor'; export class LogicalGroupStatement { + private em: EditorModel; + constructor( private operator: LogicalOperator, private statements: (Expression | LogicGroup | boolean)[], - ) {} + opts: { em: EditorModel }, + ) { + this.em = opts.em; + } evaluate(): boolean { - const results = this.statements.map((statement) => evaluateCondition(statement)); + const results = this.statements.map((statement) => { + const condition = new Condition(statement, { em: this.em }); + return condition.evaluate(); + }); return this.operator.evaluate(results); } } diff --git a/packages/core/src/data_sources/model/utils.ts b/packages/core/src/data_sources/model/utils.ts new file mode 100644 index 0000000000..44aaf738b5 --- /dev/null +++ b/packages/core/src/data_sources/model/utils.ts @@ -0,0 +1,15 @@ +import EditorModel from '../../editor/model/Editor'; +import { DataConditionType } from './conditional_variables/DataCondition'; +import DataVariable, { DataVariableType } from './DataVariable'; + +export function isDataVariable(variable: any) { + return variable?.type === DataVariableType; +} + +export function isDataCondition(variable: any) { + return variable?.type === DataConditionType; +} + +export function evaluateVariable(variable: any, em: EditorModel) { + return isDataVariable(variable) ? new DataVariable(variable, { em }).getDataValue() : variable; +} diff --git a/packages/core/test/specs/data_sources/model/conditional_variables/DataCondition.ts b/packages/core/test/specs/data_sources/model/conditional_variables/DataCondition.ts index c3f7da595b..59ac056040 100644 --- a/packages/core/test/specs/data_sources/model/conditional_variables/DataCondition.ts +++ b/packages/core/test/specs/data_sources/model/conditional_variables/DataCondition.ts @@ -1,3 +1,4 @@ +import { DataSourceManager } from '../../../../../src'; import { DataCondition, Expression, @@ -7,19 +8,43 @@ import { GenericOperation } from '../../../../../src/data_sources/model/conditio import { LogicalOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/LogicalOperator'; import { NumberOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/NumberOperator'; import { StringOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/StringOperations'; +import { DataVariableType } from '../../../../../src/data_sources/model/DataVariable'; +import { DataSourceProps } from '../../../../../src/data_sources/types'; +import Editor from '../../../../../src/editor/model/Editor'; +import EditorModel from '../../../../../src/editor/model/Editor'; describe('DataCondition', () => { + let em: EditorModel; + let dsm: DataSourceManager; + const dataSource: DataSourceProps = { + id: 'USER_STATUS_SOURCE', + records: [ + { id: 'USER_1', age: 25, status: 'active' }, + { id: 'USER_2', age: 12, status: 'inactive' }, + ], + }; + + beforeEach(() => { + em = new Editor(); + dsm = em.DataSources; + dsm.add(dataSource); + }); + + afterEach(() => { + em.destroy(); + }); + describe('Basic Functionality Tests', () => { test('should evaluate a simple boolean condition', () => { const condition = true; - const dataCondition = new DataCondition(condition, 'Yes', 'No'); + const dataCondition = new DataCondition(condition, 'Yes', 'No', { em }); expect(dataCondition.getDataValue()).toBe('Yes'); }); test('should return ifFalse when condition evaluates to false', () => { const condition = false; - const dataCondition = new DataCondition(condition, 'Yes', 'No'); + const dataCondition = new DataCondition(condition, 'Yes', 'No', { em }); expect(dataCondition.getDataValue()).toBe('No'); }); @@ -28,7 +53,7 @@ describe('DataCondition', () => { describe('Operator Tests', () => { test('should evaluate using GenericOperation operators', () => { const condition: Expression = { left: 5, operator: GenericOperation.equals, right: 5 }; - const dataCondition = new DataCondition(condition, 'Equal', 'Not Equal'); + const dataCondition = new DataCondition(condition, 'Equal', 'Not Equal', { em }); expect(dataCondition.getDataValue()).toBe('Equal'); }); @@ -39,20 +64,20 @@ describe('DataCondition', () => { operator: GenericOperation.equals, right: 'world', }; - const dataCondition = new DataCondition(condition, 'true', 'false'); + const dataCondition = new DataCondition(condition, 'true', 'false', { em }); expect(dataCondition.evaluate()).toBe(false); }); test('should evaluate using StringOperation operators', () => { const condition: Expression = { left: 'apple', operator: StringOperation.contains, right: 'app' }; - const dataCondition = new DataCondition(condition, 'Contains', "Doesn't contain"); + const dataCondition = new DataCondition(condition, 'Contains', "Doesn't contain", { em }); expect(dataCondition.getDataValue()).toBe('Contains'); }); test('should evaluate using NumberOperation operators', () => { const condition: Expression = { left: 10, operator: NumberOperation.lessThan, right: 15 }; - const dataCondition = new DataCondition(condition, 'Valid', 'Invalid'); + const dataCondition = new DataCondition(condition, 'Valid', 'Invalid', { em }); expect(dataCondition.getDataValue()).toBe('Valid'); }); @@ -66,7 +91,7 @@ describe('DataCondition', () => { ], }; - const dataCondition = new DataCondition(logicGroup, 'Pass', 'Fail'); + const dataCondition = new DataCondition(logicGroup, 'Pass', 'Fail', { em }); expect(dataCondition.getDataValue()).toBe('Pass'); }); }); @@ -74,7 +99,7 @@ describe('DataCondition', () => { describe('Edge Case Tests', () => { test('should throw error for invalid condition type', () => { const invalidCondition: any = { randomField: 'randomValue' }; - expect(() => new DataCondition(invalidCondition, 'Yes', 'No')).toThrow('Invalid condition type.'); + expect(() => new DataCondition(invalidCondition, 'Yes', 'No', { em })).toThrow('Invalid condition type.'); }); test('should evaluate complex nested conditions', () => { @@ -92,7 +117,7 @@ describe('DataCondition', () => { ], }; - const dataCondition = new DataCondition(nestedLogicGroup, 'Nested Pass', 'Nested Fail'); + const dataCondition = new DataCondition(nestedLogicGroup, 'Nested Pass', 'Nested Fail', { em }); expect(dataCondition.getDataValue()).toBe('Nested Pass'); }); }); @@ -107,7 +132,7 @@ describe('DataCondition', () => { ], }; - const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false'); + const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false', { em }); expect(dataCondition.getDataValue()).toBe('All true'); }); @@ -120,7 +145,7 @@ describe('DataCondition', () => { ], }; - const dataCondition = new DataCondition(logicGroup, 'At least one true', 'All false'); + const dataCondition = new DataCondition(logicGroup, 'At least one true', 'All false', { em }); expect(dataCondition.getDataValue()).toBe('At least one true'); }); @@ -134,7 +159,7 @@ describe('DataCondition', () => { ], }; - const dataCondition = new DataCondition(logicGroup, 'Exactly one true', 'Multiple true or all false'); + const dataCondition = new DataCondition(logicGroup, 'Exactly one true', 'Multiple true or all false', { em }); expect(dataCondition.getDataValue()).toBe('Exactly one true'); }); @@ -153,7 +178,7 @@ describe('DataCondition', () => { ], }; - const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false'); + const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false', { em }); expect(dataCondition.getDataValue()).toBe('All true'); }); @@ -167,8 +192,132 @@ describe('DataCondition', () => { ], }; - const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false'); + const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false', { em }); expect(dataCondition.getDataValue()).toBe('One or more false'); }); }); + + describe('Conditions with dataVariables', () => { + test('should return "Yes" when dataVariable matches expected value', () => { + const condition: Expression = { + left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' }, + operator: GenericOperation.equals, + right: 'active', + }; + + const dataCondition = new DataCondition(condition, 'Yes', 'No', { em }); + expect(dataCondition.getDataValue()).toBe('Yes'); + }); + + test('should return "No" when dataVariable does not match expected value', () => { + const condition: Expression = { + left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' }, + operator: GenericOperation.equals, + right: 'inactive', + }; + + const dataCondition = new DataCondition(condition, 'Yes', 'No', { em }); + expect(dataCondition.getDataValue()).toBe('No'); + }); + + // TODO: unskip after adding UndefinedOperator + test.skip('should handle missing data variable gracefully', () => { + const condition: Expression = { + left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.not_a_user.status' }, + operator: GenericOperation.isDefined, + right: undefined, + }; + + const dataCondition = new DataCondition(condition, 'Found', 'Not Found', { em }); + expect(dataCondition.getDataValue()).toBe('Not Found'); + }); + + test('should correctly compare numeric values from dataVariables', () => { + const condition: Expression = { + left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.age' }, + operator: NumberOperation.greaterThan, + right: 24, + }; + const dataCondition = new DataCondition(condition, 'Valid', 'Invalid', { em }); + expect(dataCondition.getDataValue()).toBe('Valid'); + }); + + test('should evaluate logical operators with multiple data sources', () => { + const dataSource2: DataSourceProps = { + id: 'SECOND_DATASOURCE_ID', + records: [{ id: 'RECORD_2', status: 'active', age: 22 }], + }; + dsm.add(dataSource2); + + const logicGroup: LogicGroup = { + logicalOperator: LogicalOperation.and, + statements: [ + { + left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' }, + operator: GenericOperation.equals, + right: 'active', + }, + { + left: { type: DataVariableType, path: 'SECOND_DATASOURCE_ID.RECORD_2.age' }, + operator: NumberOperation.greaterThan, + right: 18, + }, + ], + }; + + const dataCondition = new DataCondition(logicGroup, 'All conditions met', 'Some conditions failed', { em }); + expect(dataCondition.getDataValue()).toBe('All conditions met'); + }); + + test('should handle nested logical conditions with data variables', () => { + const logicGroup: LogicGroup = { + logicalOperator: LogicalOperation.or, + statements: [ + { + logicalOperator: LogicalOperation.and, + statements: [ + { + left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_2.status' }, + operator: GenericOperation.equals, + right: 'inactive', + }, + { + left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_2.age' }, + operator: NumberOperation.lessThan, + right: 14, + }, + ], + }, + { + left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' }, + operator: GenericOperation.equals, + right: 'inactive', + }, + ], + }; + + const dataCondition = new DataCondition(logicGroup, 'Condition met', 'Condition failed', { em }); + expect(dataCondition.getDataValue()).toBe('Condition met'); + }); + + test('should handle data variables as an ifTrue return value', () => { + const dataCondition = new DataCondition( + true, + { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' }, + 'No', + { em }, + ); + expect(dataCondition.getDataValue()).toBe('active'); + }); + + test('should handle data variables as an ifFalse return value', () => { + const dataCondition = new DataCondition( + false, + 'Yes', + { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' }, + { em }, + ); + expect(dataCondition.getDataValue()).toBe('active'); + }); + }); });