diff --git a/package.json b/package.json index 8cb5adf..b2af29c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@dhis2/d2-i18n": "1.1.0", "@dhis2/d2-i18n-extract": "1.0.8", "@dhis2/d2-i18n-generate": "1.2.0", + "@dhis2/expression-parser": "^1.1.0", "@dhis2/ui": "6.12.0", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", diff --git a/src/data/utils/ruleHelper.ts b/src/data/utils/ruleHelper.ts index dcebdca..5dba41e 100644 --- a/src/data/utils/ruleHelper.ts +++ b/src/data/utils/ruleHelper.ts @@ -85,9 +85,12 @@ export const getProgramRules = ( return { id: id, condition: attributeParsedCondition.replace(/d2:/g, "fn:"), //replace d2: with fn: to decouple entity from DHIS + d2Condition: attributeParsedCondition, + originalCondition: condition, dataElementIds: _(dataElementIds).uniq().compact().value(), teAttributeIds: _(teaIds).uniq().compact().value(), actions: programRuleActions || [], + programRuleVariables: programRuleVariables || [], }; }) || [] ); diff --git a/src/domain/entities/Questionnaire/Questionnaire.ts b/src/domain/entities/Questionnaire/Questionnaire.ts index 53ecd5e..ca31beb 100644 --- a/src/domain/entities/Questionnaire/Questionnaire.ts +++ b/src/domain/entities/Questionnaire/Questionnaire.ts @@ -8,7 +8,7 @@ import { QuestionnaireQuestion, isAntibioticQuestion, } from "./QuestionnaireQuestion"; -import { QuestionnaireRule, getApplicableRules } from "./QuestionnaireRules"; +import { QuestionnaireRule } from "./QuestionnaireRules"; import { QuestionnaireSection, QuestionnaireSectionM } from "./QuestionnaireSection"; export interface QuestionnaireBase { @@ -259,23 +259,33 @@ export class Questionnaire { initialLoad = false ): Questionnaire { //For the updated question, get all rules that are applicable - const allQsInQuestionnaireStages = questionnaire.stages.flatMap( - (stage: QuestionnaireStage) => { - return stage.sections.flatMap(section => { - return section.questions.map(question => question); - }); - } - ); - - const allQsInQuestionnaire = [ - ...(questionnaire.entity?.questions || []), - ...allQsInQuestionnaireStages, - ]; - - const applicableRules = getApplicableRules( - updatedQuestion, - questionnaire.rules, - allQsInQuestionnaire + // const allQsInQuestionnaireStages = questionnaire.stages.flatMap( + // (stage: QuestionnaireStage) => { + // return stage.sections.flatMap(section => { + // return section.questions.map(question => question); + // }); + // } + // ); + + // const allQsInQuestionnaire = [ + // ...(questionnaire.entity?.questions || []), + // ...allQsInQuestionnaireStages, + // ]; + + // const applicableRules = getApplicableRules( + // updatedQuestion, + // questionnaire.rules, + // allQsInQuestionnaire + // ); + + const applicableRules = questionnaire.rules.filter( + rule => + rule.dataElementIds.includes(updatedQuestion.id) || + rule.teAttributeIds.includes(updatedQuestion.id) || + rule.actions.some(action => action.dataElement?.id === updatedQuestion.id) || + rule.actions.some( + action => action.trackedEntityAttribute?.id === updatedQuestion.id + ) ); if (initialLoad && applicableRules.length === 0) return questionnaire; diff --git a/src/domain/entities/Questionnaire/QuestionnaireQuestion.ts b/src/domain/entities/Questionnaire/QuestionnaireQuestion.ts index fbf3997..1517da0 100644 --- a/src/domain/entities/Questionnaire/QuestionnaireQuestion.ts +++ b/src/domain/entities/Questionnaire/QuestionnaireQuestion.ts @@ -154,7 +154,7 @@ export class QuestionnaireQuestion { static updateQuestions( questions: Question[], updatedQuestion: Question, - rules: QuestionnaireRule[], + _rules: QuestionnaireRule[], questionnaire: Questionnaire, parentSectionHidden?: boolean ): Question[] { @@ -180,7 +180,7 @@ export class QuestionnaireQuestion { //Get list question ids that require update const allQuestionsRequiringUpdate = _( - rules.flatMap(rule => { + questionnaire.rules.flatMap(rule => { const actionUpdates = rule.actions.flatMap( action => action?.dataElement?.id || action.trackedEntityAttribute?.id ); @@ -196,10 +196,15 @@ export class QuestionnaireQuestion { const parsedAndUpdatedQuestions = updatedQuestions.map(question => { //Get the rules that are applicable for the current question //this is done to take care of any "side-effects" of an updated question. - const rulesApplicableForCurrentQuestion = - question.id !== updatedQuestion.id - ? getApplicableRules(question, questionnaire.rules, updatedQuestions) - : rules; + // const rulesApplicableForCurrentQuestion = + // question.id !== updatedQuestion.id + // ? getApplicableRules(question, questionnaire.rules, updatedQuestions) + // : rules; + const rulesApplicableForCurrentQuestion = getApplicableRules( + question, + questionnaire.rules, + updatedQuestions + ); //If the question is part of any of the rule actions, update the section const parsedAndUpdatedQuestion = @@ -227,7 +232,7 @@ export class QuestionnaireQuestion { return this.updateQuestions( acc, { ...hiddenQuestion, value: undefined }, - rules, + questionnaire.rules, questionnaire ); }, parsedAndUpdatedQuestions); diff --git a/src/domain/entities/Questionnaire/QuestionnaireRules.ts b/src/domain/entities/Questionnaire/QuestionnaireRules.ts index 9e2ca29..8701a9e 100644 --- a/src/domain/entities/Questionnaire/QuestionnaireRules.ts +++ b/src/domain/entities/Questionnaire/QuestionnaireRules.ts @@ -1,7 +1,10 @@ +import { D2ProgramRuleVariable } from "../../../data/entities/D2Program"; import { Id } from "../Ref"; import _ from "../generic/Collection"; import { Question } from "./QuestionnaireQuestion"; +import * as xp from "@dhis2/expression-parser"; + const RULE_FUNCTIONS = ["fn:hasValue", "fn:daysBetween", "fn:yearsBetween"]; const RULE_OPERATORS = [ ">" as const, @@ -46,10 +49,13 @@ export interface QuestionnaireRuleAction { export interface QuestionnaireRule { id: Id; condition: string; //condition is parsed with dataelementId e.g: #{dataElementId} == 'Yes' + d2Condition: string; //SNEHA TO DO : remove above condition and use this condition after testing + originalCondition: string; dataElementIds: Id[]; // all dataElements in condition (there could be mutiple conditions) teAttributeIds: Id[]; // all trackedEntityAttributes in condition (there could be mutiple conditions) actions: QuestionnaireRuleAction[]; parsedResult?: boolean; //calculate the condition and store the result + programRuleVariables: D2ProgramRuleVariable[] | undefined; } export const getApplicableRules = ( @@ -68,8 +74,24 @@ export const getApplicableRules = ( //2. Run the rule conditions and return rules with parsed results const parsedApplicableRules = applicableRules.map(rule => { - const parsedResult = parseCondition(rule.condition, updatedQuestion, questions); - return { ...rule, parsedResult }; + const customParserResult = parseCondition(rule.condition, updatedQuestion, questions); + + const expressionParserResult = parseConditionWithExpressionParser(rule, questions); + + //SNEHA DEBUG + if (customParserResult !== expressionParserResult) { + console.debug( + `custom parser and expression parser give diffrent results for rule : ${ + rule.id + }, condition : ${ + rule.originalCondition + }, custom parser : ${expressionParserResult}, expression parser : ${customParserResult}, value: ${ + questions.find(q => q.id === rule.dataElementIds[0])?.value + }` + ); + } + + return { ...rule, parsedResult: expressionParserResult }; }); return parsedApplicableRules; @@ -226,7 +248,7 @@ const parseAndEvaluateSubCondition = ( } }; -export const parseCondition = ( +const parseCondition = ( condition: string, updatedQuestion: Question, questions: Question[] @@ -270,3 +292,101 @@ export const parseCondition = ( return andConditions.every(condition => condition); }; + +const parseConditionWithExpressionParser = (rule: QuestionnaireRule, questions: Question[]) => { + try { + const dataItemsExpressionParser = new xp.ExpressionJs( + rule.d2Condition, + xp.ExpressionMode.RULE_ENGINE_CONDITION + ); + + const dataItems = dataItemsExpressionParser.collectDataItems(); + const dataItemValueMap = dataItems.map((dataItem: xp.DataItemJs) => { + const currentQuestion = questions.find(question => question.id === dataItem.uid0.value); + if (!currentQuestion) return { dataItem: dataItem, value: "" }; + + const value = getQuestionValueByType(currentQuestion); + return { + dataItem: dataItem, + value: value, + }; + }); + const dataItemsMap = new Map(dataItemValueMap.map(a => [a.dataItem, a.value])); + + const programRuleVariableExpressionParser = new xp.ExpressionJs( + rule.originalCondition, + xp.ExpressionMode.RULE_ENGINE_CONDITION + ); + + const programRuleVariables = + programRuleVariableExpressionParser.collectProgramRuleVariableNames(); + const programRuleVariablesValueMap = programRuleVariables.map(programRuleVariable => { + const currentProgramRuleVariable = rule.programRuleVariables?.find( + prv => prv.name === programRuleVariable + ); + const currentQuestion = questions.find( + question => + question.id === currentProgramRuleVariable?.dataElement?.id || + question.id === currentProgramRuleVariable?.trackedEntityAttribute?.id + ); + + if (!currentQuestion) + return { + programRuleVariable: programRuleVariable, + value: new xp.VariableValueJs(xp.ValueType.STRING, null, [], null), + }; + const value = getQuestionValueByType(currentQuestion); + //SNEHA DEBUG : This is my guess. + // const candidates = currentQuestion.type === "select" ? currentQuestion.options : []; + // const eventDate = new Date(); + + const variableValue = getVariableValueByType( + currentQuestion, + value === "" ? null : value + ); + + return { + programRuleVariable: programRuleVariable, + value: variableValue, + }; + }); + const programRuleVariablesMap = new Map( + programRuleVariablesValueMap.map(a => [a.programRuleVariable, a.value]) + ); + + const expressionData = new xp.ExpressionDataJs( + programRuleVariablesMap, + undefined, + undefined, + dataItemsMap, + undefined + ); + const parsedResult: boolean = programRuleVariableExpressionParser.evaluate( + a => console.debug("ABC" + a), //SNEGHA DEBUG : what is this? + expressionData + ); + return parsedResult; + } catch (error) { + console.error(`Error parsing rule condition: ${rule.condition} with error : ${error}`); + return false; + } +}; + +const getVariableValueByType = ( + question: Question, + stringValue: xp.Nullable +): xp.VariableValueJs => { + switch (question.type) { + case "select": + case "text": + return new xp.VariableValueJs(xp.ValueType.STRING, stringValue, [], null); + case "boolean": + return new xp.VariableValueJs(xp.ValueType.BOOLEAN, stringValue, [], null); + case "date": + case "datetime": + return new xp.VariableValueJs(xp.ValueType.DATE, stringValue, [], null); + + case "number": + return new xp.VariableValueJs(xp.ValueType.NUMBER, stringValue, [], null); + } +}; diff --git a/src/domain/entities/Questionnaire/QuestionnaireSection.ts b/src/domain/entities/Questionnaire/QuestionnaireSection.ts index 27a8e9e..3da73c8 100644 --- a/src/domain/entities/Questionnaire/QuestionnaireSection.ts +++ b/src/domain/entities/Questionnaire/QuestionnaireSection.ts @@ -41,11 +41,13 @@ export class QuestionnaireSectionM { sections: QuestionnaireSection[], updatedQuestion: Question, questionnaire: Questionnaire, - rules: QuestionnaireRule[] + _rules: QuestionnaireRule[] ): QuestionnaireSection[] { //Get all the sections that require update const allSectionsRequiringUpdate = _( - rules.flatMap(rule => rule.actions.flatMap(action => action.programStageSection?.id)) + questionnaire.rules.flatMap(rule => + rule.actions.flatMap(action => action.programStageSection?.id) + ) ) .compact() .value(); @@ -53,7 +55,7 @@ export class QuestionnaireSectionM { const updatedSections = sections.map(section => { //If the section is part of any of the rule actions, update the section const updatedSection = allSectionsRequiringUpdate.includes(section.code) - ? this.updateSection(section, rules) + ? this.updateSection(section, questionnaire.rules) : section; return { @@ -61,7 +63,7 @@ export class QuestionnaireSectionM { questions: QuestionnaireQuestion.updateQuestions( updatedSection.questions, updatedQuestion, - rules, + questionnaire.rules, questionnaire, updatedSection.isVisible === false && section.isVisible === true ), diff --git a/yarn.lock b/yarn.lock index 3be77d8..fdf98b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2778,6 +2778,14 @@ material-ui "^0.20.0" rxjs "^5.5.7" +"@dhis2/expression-parser@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@dhis2/expression-parser/-/expression-parser-1.1.0.tgz#fcb4405c3621b7f754fc5c639f1a9a5c987e200a" + integrity sha512-QR663COhDunSq0oOZNfx7e7NJWtPjByyh/ncU6Nr5q7s45mMy0vGSFV4reIRIwFqjYGEAXIh/YK/4DKHgL9lqA== + dependencies: + "@js-joda/core" "3.2.0" + format-util "^1.0.5" + "@dhis2/prop-types@^1.6.4": version "1.6.4" resolved "https://registry.yarnpkg.com/@dhis2/prop-types/-/prop-types-1.6.4.tgz#ec4d256c9440d4d00071524422a727c61ddaa6f6" @@ -3613,6 +3621,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@js-joda/core@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" + integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== + "@juggle/resize-observer@^3.3.1": version "3.4.0" resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" @@ -6987,6 +7000,11 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +format-util@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" + integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg== + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"