From 68c2cc3d294f97b27961c70b792755b74075bf7f Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Thu, 9 Jan 2025 15:41:08 -0300 Subject: [PATCH] feat: Add support for ASSIGN actions in Program Rules --- src/data/entities/D2ExpressionParser.ts | 40 ++++++++--- .../Questionnaire/QuestionnaireQuestion.ts | 63 +++++++++++++++- .../Questionnaire/QuestionnaireRules.ts | 72 ++++++++++++++++++- 3 files changed, 159 insertions(+), 16 deletions(-) diff --git a/src/data/entities/D2ExpressionParser.ts b/src/data/entities/D2ExpressionParser.ts index 6c788b4..1c217d6 100644 --- a/src/data/entities/D2ExpressionParser.ts +++ b/src/data/entities/D2ExpressionParser.ts @@ -12,24 +12,42 @@ export class D2ExpressionParser { ruleCondition, xp.ExpressionMode.RULE_ENGINE_CONDITION ); + const expressionData = this.getExpressionDataJs(expressionParser, variableValues); + const parsedResult: boolean = expressionParser.evaluate(() => {}, expressionData); + return Either.success(parsedResult); + } catch (error) { + return Either.error(error as Error); + } + } - const ruleVariables = this.mapProgramRuleVariables(expressionParser, variableValues); - const genericVariables = this.mapProgramVariables(expressionParser); - const variables = new Map([...ruleVariables, ...genericVariables]); - - const expressionData = new xp.ExpressionDataJs(variables); - - const parsedResult: boolean = expressionParser.evaluate( - () => console.debug(""), - expressionData + public evaluateActionExpression( + expression: string, + variableValues: Map + ): Either { + try { + const expressionParser = new xp.ExpressionJs( + expression, + xp.ExpressionMode.RULE_ENGINE_ACTION ); - + const expressionData = this.getExpressionDataJs(expressionParser, variableValues); + const parsedResult: EvaluatedExpressionResult = expressionParser.evaluate(() => {}, + expressionData); return Either.success(parsedResult); } catch (error) { return Either.error(error as Error); } } + private getExpressionDataJs = ( + expressionParser: xp.ExpressionJs, + variableValues: Map + ): xp.ExpressionDataJs => { + const ruleVariables = this.mapProgramRuleVariables(expressionParser, variableValues); + const genericVariables = this.mapProgramVariables(expressionParser); + const variables = new Map([...ruleVariables, ...genericVariables]); + return new xp.ExpressionDataJs(variables); + }; + private getVariableValueByType = ( type: ProgramRuleVariableType, stringValue: xp.Nullable @@ -106,3 +124,5 @@ const VariableValueTypeMap: Record = { date: xp.ValueType.DATE, number: xp.ValueType.NUMBER, }; + +export type EvaluatedExpressionResult = boolean | string | number | Date | null; diff --git a/src/domain/entities/Questionnaire/QuestionnaireQuestion.ts b/src/domain/entities/Questionnaire/QuestionnaireQuestion.ts index 05a8c82..243060a 100644 --- a/src/domain/entities/Questionnaire/QuestionnaireQuestion.ts +++ b/src/domain/entities/Questionnaire/QuestionnaireQuestion.ts @@ -1,6 +1,10 @@ import { Maybe, assertUnreachable } from "../../../utils/ts-utils"; import { Id, NamedRef } from "../Ref"; -import { getApplicableRules, QuestionnaireRule } from "./QuestionnaireRules"; +import { + getApplicableRules, + getQuestionValueFromEvaluatedExpression, + QuestionnaireRule, +} from "./QuestionnaireRules"; import _ from "../generic/Collection"; import { Questionnaire } from "./Questionnaire"; @@ -267,18 +271,71 @@ export class QuestionnaireQuestion { return finalUpdatesWithSideEffects; } - private static updateQuestion(question: Question, rules: QuestionnaireRule[]): Question { + private static updateQuestion(question: T, rules: QuestionnaireRule[]): T { const updatedIsVisible = this.isQuestionVisible(question, rules); const updatedErrors = this.getQuestionWarningsAndErrors(question, rules); - + const updatedIsDisabled = this.isQuestionDisabled(question, rules); + const updatedValue = this.getQuestionAssignValue(question, rules); return { ...question, isVisible: updatedIsVisible, errors: updatedErrors, + disabled: updatedIsDisabled, + value: updatedValue, ...(question.isVisible !== updatedIsVisible ? { value: undefined } : {}), }; } + private static getRulesWithAssignActionForQuestion( + question: Question, + rules: QuestionnaireRule[] + ) { + return rules.filter( + rule => + rule.parsedResult && + rule.actions.filter( + action => + action.programRuleActionType === "ASSIGN" && + ((action.dataElement && action.dataElement.id === question.id) || + (action.trackedEntityAttribute && + action.trackedEntityAttribute.id === question.id)) + ).length > 0 + ); + } + + private static isQuestionDisabled( + question: Question, + rules: QuestionnaireRule[] + ): boolean | undefined { + const applicableRules = this.getRulesWithAssignActionForQuestion(question, rules); + return applicableRules.length > 0 ? true : question.disabled; + } + + private static getQuestionAssignValue( + question: Question, + rules: QuestionnaireRule[] + ): Question["value"] { + const applicableActions = this.getRulesWithAssignActionForQuestion(question, rules).flatMap( + rule => rule.actions + ); + if (applicableActions.length === 0) { + return question.value; + } else { + if (applicableActions.length > 1) { + console.warn( + "Multiple ASSIGN actions found for question: ", + question, + "Applying first rule:", + applicableActions[0] + ); + } + return getQuestionValueFromEvaluatedExpression( + question, + applicableActions[0]?.dataEvaluated + ); + } + } + private static isQuestionVisible(question: Question, rules: QuestionnaireRule[]): boolean { //Check of there are any rules applicable to the current question //with hide field action diff --git a/src/domain/entities/Questionnaire/QuestionnaireRules.ts b/src/domain/entities/Questionnaire/QuestionnaireRules.ts index 7051d5c..1886b58 100644 --- a/src/domain/entities/Questionnaire/QuestionnaireRules.ts +++ b/src/domain/entities/Questionnaire/QuestionnaireRules.ts @@ -1,5 +1,6 @@ import { D2ExpressionParser, + EvaluatedExpressionResult, ProgramRuleVariableName, ProgramRuleVariableValue, } from "../../../data/entities/D2ExpressionParser"; @@ -31,7 +32,8 @@ export interface QuestionnaireRuleAction { trackedEntityAttribute?: { id: Id | undefined; // to hide }; - data?: string; // to assign + data?: string; // to assign (expression raw value) + dataEvaluated?: EvaluatedExpressionResult; // to assign (calculated/evaluated value) programStageSection?: { id: Id | undefined; // to hide/show }; @@ -65,10 +67,18 @@ export const getApplicableRules = ( ); //2. Run the rule conditions and return rules with parsed results + // Also augment rule actions with results of the `data` expression evaluation const parsedApplicableRules = applicableRules.map(rule => { const expressionParserResult = parseConditionWithExpressionParser(rule, questions); - - return { ...rule, parsedResult: expressionParserResult }; + const actionsWithEvaluatedDataExpressions = getActionsWithEvaluatedDataExpression( + rule, + questions + ); + return { + ...rule, + parsedResult: expressionParserResult, + actions: actionsWithEvaluatedDataExpressions, + }; }); return parsedApplicableRules; @@ -101,6 +111,25 @@ export const getQuestionValueByType = (question: Question): string => { } }; +export function getQuestionValueFromEvaluatedExpression( + question: Question, + dataEvaluated?: EvaluatedExpressionResult +): Question["value"] { + // TODO: handle possible mismatches between question value type and dataEvaluated type + // e.g. question.type is "date" but dataEvaluated is a number + if (dataEvaluated === null) { + return undefined; + } else if (question.type === "select") { + const option = question.options.find(option => option.code === dataEvaluated); + if (!option) console.warn("Option not found in question for code:", dataEvaluated); + return option; + } else if (typeof dataEvaluated === "number") { + return dataEvaluated.toString(); + } else { + return dataEvaluated; + } +} + function getProgramRuleVariableValues( programRuleVariables: Maybe, questions: Question[] @@ -154,3 +183,40 @@ const parseConditionWithExpressionParser = ( }, }); }; + +/** + * Get the actions from the rule, augmenting them with the `dataEvaluated` property - Only for ASSIGN actions + * `dataEvaluated` is set with the results of running the D2ExpressionParser evaluation + */ +const getActionsWithEvaluatedDataExpression = ( + rule: QuestionnaireRule, + questions: Question[] +): QuestionnaireRuleAction[] => { + const programRuleVariableValues = getProgramRuleVariableValues( + rule.programRuleVariables, + questions + ); + const parser = new D2ExpressionParser(); + + return rule.actions.map(action => { + if (!action.data || action.programRuleActionType !== "ASSIGN") { + return action; + } + return { + ...action, + dataEvaluated: parser + .evaluateActionExpression(action.data, programRuleVariableValues) + .match({ + success: evaluationResult => evaluationResult, + error: errMsg => { + console.error( + "Error evaluating ASSIGN data expression", + action.data, + errMsg + ); + return null; + }, + }), + }; + }); +};