From d309583d1a8855122f17699c9ac119efeb9f07c2 Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Thu, 19 Dec 2024 10:50:48 -0300 Subject: [PATCH 1/7] fix: stop loader on save error. Stay in the form on save error. --- src/webapp/components/survey/SurveyForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webapp/components/survey/SurveyForm.tsx b/src/webapp/components/survey/SurveyForm.tsx index 914246c6..e29cc9fa 100644 --- a/src/webapp/components/survey/SurveyForm.tsx +++ b/src/webapp/components/survey/SurveyForm.tsx @@ -67,14 +67,14 @@ export const SurveyForm: React.FC = props => { if (saveCompleteState && saveCompleteState.status === "error") { snackbar.error(saveCompleteState.message); - if (props.hideForm) props.hideForm(); + setLoading(false); } //If error fetching survey, redirect to homepage. if (error) { history.push(`/`); } - }, [error, saveCompleteState, snackbar, history, props]); + }, [error, saveCompleteState, snackbar, history, props, setLoading]); const saveSurveyForm = () => { setLoading(true); From 5cf1affad98a0b2afb3589d4d6939507aa244a7e Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Thu, 19 Dec 2024 20:39:06 +0100 Subject: [PATCH 2/7] fix: remove repeatable program stage --- .../usecases/RemoveRepeatableProgramStageUseCase.ts | 10 ++++------ src/webapp/components/survey/hook/useSurveyForm.ts | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/domain/usecases/RemoveRepeatableProgramStageUseCase.ts b/src/domain/usecases/RemoveRepeatableProgramStageUseCase.ts index db9b9ece..5aa00802 100644 --- a/src/domain/usecases/RemoveRepeatableProgramStageUseCase.ts +++ b/src/domain/usecases/RemoveRepeatableProgramStageUseCase.ts @@ -11,17 +11,15 @@ export class RemoveRepeatableProgramStageUseCase { //Repeatable Program Stages are only applicable to Prevalence Facility forms const eventId = questionnaire.stages.find(stage => stage.id === stageId)?.instanceId; + const updatedQuestionnaire = Questionnaire.removeProgramStage(questionnaire, stageId); - if (!eventId) - return Future.error(new Error("Cannot find event Id correspoding to the stage")); + if (!eventId) { + return Future.success(updatedQuestionnaire); + } return this.surveyRepository .deleteEventSurvey(eventId, questionnaire.orgUnit.id, PREVALENCE_FACILITY_LEVEL_FORM_ID) .flatMap(() => { - const updatedQuestionnaire = Questionnaire.removeProgramStage( - questionnaire, - stageId - ); return Future.success(updatedQuestionnaire); }); } diff --git a/src/webapp/components/survey/hook/useSurveyForm.ts b/src/webapp/components/survey/hook/useSurveyForm.ts index a7b6ce6a..44538f45 100644 --- a/src/webapp/components/survey/hook/useSurveyForm.ts +++ b/src/webapp/components/survey/hook/useSurveyForm.ts @@ -195,7 +195,7 @@ export function useSurveyForm(formType: SURVEY_FORM_TYPES, eventId: string | und }, err => { setLoading(false); - setError(err.message); + setError(`Cannot find event Id correspoding to the stage: ${err.message}`); } ); } From eb219f98c1aad75f307d29734a0f779faee1739f Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Thu, 19 Dec 2024 20:50:44 +0100 Subject: [PATCH 3/7] chore: fix typo --- src/webapp/components/survey/hook/useSurveyForm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/components/survey/hook/useSurveyForm.ts b/src/webapp/components/survey/hook/useSurveyForm.ts index 44538f45..bf8678d8 100644 --- a/src/webapp/components/survey/hook/useSurveyForm.ts +++ b/src/webapp/components/survey/hook/useSurveyForm.ts @@ -195,7 +195,7 @@ export function useSurveyForm(formType: SURVEY_FORM_TYPES, eventId: string | und }, err => { setLoading(false); - setError(`Cannot find event Id correspoding to the stage: ${err.message}`); + setError(`Cannot find event Id corresponding to the stage: ${err.message}`); } ); } From 04e451029b90704d482072b32d87a3096464efe5 Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Fri, 20 Dec 2024 13:07:45 -0300 Subject: [PATCH 4/7] fix: avoid invalid dates in getQuestion. Catch errors from mapping. --- i18n/en.pot | 7 ++++-- i18n/es.po | 5 ++++- src/data/utils/questionHelper.ts | 17 +++++++++++++-- src/data/utils/surveyFormMappers.ts | 33 +++++++++++++++++++++++------ src/utils/dates.ts | 1 + 5 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 src/utils/dates.ts diff --git a/i18n/en.pot b/i18n/en.pot index efd5e1a0..78a088b9 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,11 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-05-23T16:02:03.207Z\n" -"PO-Revision-Date: 2024-05-23T16:02:03.207Z\n" +"POT-Creation-Date: 2024-12-20T16:08:06.715Z\n" +"PO-Revision-Date: 2024-12-20T16:08:06.715Z\n" + +msgid "There was an error processing the form" +msgstr "" msgid "Facilities" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index c04bd60d..ad3d6163 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,13 +1,16 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-05-22T13:24:01.336Z\n" +"POT-Creation-Date: 2024-12-20T16:08:06.715Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "There was an error processing the form" +msgstr "" + msgid "Facilities" msgstr "" diff --git a/src/data/utils/questionHelper.ts b/src/data/utils/questionHelper.ts index 6c79834e..2d783199 100644 --- a/src/data/utils/questionHelper.ts +++ b/src/data/utils/questionHelper.ts @@ -38,6 +38,7 @@ import { import _ from "../../domain/entities/generic/Collection"; import { D2TrackerEvent } from "@eyeseetea/d2-api/api/trackerEvents"; import { D2TrackerTrackedEntity as TrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; +import { isValidDate } from "../../utils/dates"; const SPECIES_QUESTION_FORNAME = "Specify the specie"; const ANTIBIOTIC_QUESTION_FORNAME = "Specify the antibiotic"; @@ -173,7 +174,7 @@ export const getQuestion = ( const dateQ: DateQuestion = { ...base, type: "date", - value: dataValue ? new Date(dataValue) : undefined, + value: parseQuestionDate(dataValue, base), }; return dateQ; } @@ -182,13 +183,25 @@ export const getQuestion = ( const dateQ: DateTimeQuestion = { ...base, type: "datetime", - value: dataValue ? new Date(dataValue) : undefined, + value: parseQuestionDate(dataValue, base), }; return dateQ; } } }; +const parseQuestionDate = ( + dateStr: string | undefined, + question: QuestionBase +): Date | undefined => { + if (!dateStr) return undefined; + const result = isValidDate(new Date(dateStr)) ? new Date(dateStr) : undefined; + if (dateStr && !result) { + console.debug(`Invalid date value: ${dateStr}`, question); + } + return result; +}; + export const mapQuestionsToDataValues = (questions: Question[]): DataValue[] => { const dataValues = _( questions.map(question => { diff --git a/src/data/utils/surveyFormMappers.ts b/src/data/utils/surveyFormMappers.ts index 4c7c743f..fc1d07a5 100644 --- a/src/data/utils/surveyFormMappers.ts +++ b/src/data/utils/surveyFormMappers.ts @@ -42,6 +42,7 @@ import { } from "@eyeseetea/d2-api/api/trackerEnrollments"; import { DataValue } from "@eyeseetea/d2-api"; import { generateUid } from "../../utils/uid"; +import i18n from "../../utils/i18n"; const AntibioticTreatmentHospitalEpisodeSectionName = `Antibiotic treatments during hospital episode`.toLowerCase(); @@ -294,7 +295,12 @@ export const mapQuestionnaireToEvent = ( stages.sections.flatMap(section => section.questions) ); - const dataValues = mapQuestionsToDataValues(questions); + let dataValues: DataValue[] = []; + try { + dataValues = mapQuestionsToDataValues(questions); + } catch (error) { + return Future.error(new Error(i18n.t("There was an error processing the form"))); + } if (eventId) { return getEventProgramById(eventId, api).flatMap(event => { @@ -323,13 +329,12 @@ export const mapQuestionnaireToEvent = ( } }; -export const mapQuestionnaireToTrackedEntities = ( +const mapQuestionnaireToEventsByStage = ( questionnaire: Questionnaire, orgUnitId: string, - programId: Id, - teiId: string | undefined = undefined -): FutureData<{ trackedEntities: TrackedEntity[] }> => { - const eventsByStage: D2TrackerEvent[] = questionnaire.stages.map(stage => { + programId: Id +): D2TrackerEvent[] => { + return questionnaire.stages.map(stage => { const dataValuesByStage = stage.sections.flatMap(section => { return mapQuestionsToDataValues(section.questions); }); @@ -339,11 +344,25 @@ export const mapQuestionnaireToTrackedEntities = ( event: stage.instanceId ?? "", programStage: stage.code, orgUnit: orgUnitId, - dataValues: dataValuesByStage as DataValue[], + dataValues: dataValuesByStage, occurredAt: new Date().getTime().toString(), status: "ACTIVE", }; }); +}; + +export const mapQuestionnaireToTrackedEntities = ( + questionnaire: Questionnaire, + orgUnitId: string, + programId: Id, + teiId: string | undefined = undefined +): FutureData<{ trackedEntities: TrackedEntity[] }> => { + let eventsByStage: D2TrackerEvent[] = []; + try { + eventsByStage = mapQuestionnaireToEventsByStage(questionnaire, orgUnitId, programId); + } catch (error) { + return Future.error(new Error(i18n.t("There was an error processing the form"))); + } const attributes: D2TrackerEnrollmentAttribute[] = questionnaire.entity ? questionnaire.entity.questions.map(question => { diff --git a/src/utils/dates.ts b/src/utils/dates.ts new file mode 100644 index 00000000..8aedbf74 --- /dev/null +++ b/src/utils/dates.ts @@ -0,0 +1 @@ +export const isValidDate = (date: Date): boolean => !Number.isNaN(date.getTime()); From 68c2cc3d294f97b27961c70b792755b74075bf7f Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Thu, 9 Jan 2025 15:41:08 -0300 Subject: [PATCH 5/7] 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 6c788b4b..1c217d65 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 05a8c823..243060a6 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 7051d5cb..1886b58f 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; + }, + }), + }; + }); +}; From 956d97d023a67be2186ea7381a40135574c2e17b Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:54:56 +0100 Subject: [PATCH 6/7] fix: update questionnaire stages correctly by replacing the updated stage in the array --- src/domain/usecases/GetSurveyUseCase.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/domain/usecases/GetSurveyUseCase.ts b/src/domain/usecases/GetSurveyUseCase.ts index b97b80cc..41b87b49 100644 --- a/src/domain/usecases/GetSurveyUseCase.ts +++ b/src/domain/usecases/GetSurveyUseCase.ts @@ -140,11 +140,12 @@ export class GetSurveyUseCase { sections: updatedSections, }; + const updatedStages = questionnaire.stages.map(stage => + stage.id === updatedStage.id ? updatedStage : stage + ); + return Future.success( - Questionnaire.updateQuestionnaireStages(questionnaire, [ - ...questionnaire.stages, - updatedStage, - ]) + Questionnaire.updateQuestionnaireStages(questionnaire, updatedStages) ); } else { return Future.success(questionnaire); From cf693e2eed6040ceebcaa28bd57f9cc95e46556f Mon Sep 17 00:00:00 2001 From: Miquel Adell Date: Fri, 17 Jan 2025 13:31:35 +0100 Subject: [PATCH 7/7] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ca142597..70a7f016 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "amr-surveys", "description": "AMR Surveys App", - "version": "0.8.0", + "version": "0.9.0", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".",