diff --git a/jest.config.js b/jest.config.js index 7ae7c170ab..fc1102fb4d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,12 @@ const config = { moduleDirectories: ['core_modules', 'node_modules'], + transform: { + '^.+\\.m?[t|j]sx?$': require.resolve('./node_modules/@dhis2/cli-app-scripts/config/jest.transform.js'), + }, + transformIgnorePatterns: ['/node_modules/(?!@dhis2/rule-engine/)'], + moduleNameMapper: { + '@dhis2/rule-engine(.*)': '/node_modules/@dhis2/rule-engine/rule-engine.mjs', + }, }; module.exports = config; diff --git a/package.json b/package.json index 0f9c8ec1be..ce9b2f5483 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@dhis2/d2-ui-org-unit-tree": "^7.3.3", "@dhis2/d2-ui-rich-text": "^7.4.0", "@dhis2/d2-ui-sharing-dialog": "^7.3.3", + "@dhis2/rule-engine": "^3.3.9", "@dhis2/ui": "^9.10.1", "@dhis2-ui/calendar": "^10.4.0", "@joakim_sm/react-infinite-calendar": "^2.4.2", @@ -31,6 +32,7 @@ "d2-manifest": "^1.0.0", "d2-utilizr": "^0.2.15", "date-fns": "^1.29.0", + "eslint-import-resolver-exports": "^1.0.0-beta.5", "history": "^5.3.0", "leaflet": "^1.7.1", "leaflet-draw": "^1.0.4", diff --git a/packages/rules-engine/src/rulesEngine.types.js b/packages/rules-engine/src/rulesEngine.types.js index fd25b9e251..06db310bc1 100644 --- a/packages/rules-engine/src/rulesEngine.types.js +++ b/packages/rules-engine/src/rulesEngine.types.js @@ -109,7 +109,7 @@ export type DataElement = { name: string, }; -export type DataElements = { [elementId: string]: DataElement }; +export type DataElements = { [elementId: ?string]: DataElement }; export type RuleVariable = { variableValue: any, @@ -132,7 +132,7 @@ export type TrackedEntityAttribute = { }; export type TrackedEntityAttributes = { - [id: string]: TrackedEntityAttribute + [id: ?string]: TrackedEntityAttribute }; export type OrgUnitGroup = $ReadOnly<{| diff --git a/packages/rules-engine/src/services/VariableService/variableService.types.js b/packages/rules-engine/src/services/VariableService/variableService.types.js index 01e1810ff0..5afbd1fcaa 100644 --- a/packages/rules-engine/src/services/VariableService/variableService.types.js +++ b/packages/rules-engine/src/services/VariableService/variableService.types.js @@ -51,6 +51,8 @@ export type Enrollment = { +enrolledAt?: string, +occurredAt?: string, +enrollmentId?: string, + +programName?: string, + +enrollmentStatus?: string, }; export type Option = { diff --git a/src/components/AppLoader/init.js b/src/components/AppLoader/init.js index a2e5c4603a..0e7320bfc7 100644 --- a/src/components/AppLoader/init.js +++ b/src/components/AppLoader/init.js @@ -11,7 +11,7 @@ import { loadMetaData, cacheSystemSettings } from 'capture-core/metaDataStoreLoa import { buildMetaDataAsync, buildSystemSettingsAsync } from 'capture-core/metaDataMemoryStoreBuilders'; import { initControllersAsync } from 'capture-core/storageControllers'; import { DisplayException } from 'capture-core/utils/exceptions'; -import { rulesEngine } from '../../core_modules/capture-core/rules/rulesEngine'; +import { initRulesEngine } from '../../core_modules/capture-core/rules/rulesEngine'; function setLogLevel() { const levels = { @@ -157,8 +157,6 @@ export async function initializeAsync( }, }); - rulesEngine.setSelectedUserRoles(userRoles.map(({ id }) => id)); - const systemSettings = await onQueryApi({ resource: 'system/info', params: { @@ -166,6 +164,17 @@ export async function initializeAsync( }, }); + // initialize rule engine + let ruleEngineSettings; + try { + ruleEngineSettings = await onQueryApi({ + resource: 'dataStore/capture/ruleEngine', + }); + } catch { + ruleEngineSettings = { version: 'default' }; + } + initRulesEngine(ruleEngineSettings.version, userRoles); + // initialize storage controllers try { await initControllersAsync(onCacheExpired, currentUserId); diff --git a/src/core_modules/capture-core-utils/featuresSupport/support.js b/src/core_modules/capture-core-utils/featuresSupport/support.js index ea338b2981..9cee1450f4 100644 --- a/src/core_modules/capture-core-utils/featuresSupport/support.js +++ b/src/core_modules/capture-core-utils/featuresSupport/support.js @@ -17,6 +17,7 @@ export const FEATURES = Object.freeze({ newOrgUnitModeQueryParam: 'newOrgUnitModeQueryParam', moreGenericErrorMessages: 'moreGenericErrorMessages', sendEmptyScheduledAt: 'sendEmptyScheduledAt', + kotlinRuleEngine: 'kotlinRuleEngine', }); // The first minor version that supports the feature @@ -38,6 +39,7 @@ const MINOR_VERSION_SUPPORT = Object.freeze({ [FEATURES.newOrgUnitModeQueryParam]: 41, [FEATURES.moreGenericErrorMessages]: 42, [FEATURES.sendEmptyScheduledAt]: 41, + [FEATURES.kotlinRuleEngine]: 42, }); export const hasAPISupportForFeature = (minorVersion: string | number, featureName: string) => diff --git a/src/core_modules/capture-core/components/DataEntries/Enrollment/actions/enrollment.actionBatchs.js b/src/core_modules/capture-core/components/DataEntries/Enrollment/actions/enrollment.actionBatchs.js index da21783486..0b4a30ff06 100644 --- a/src/core_modules/capture-core/components/DataEntries/Enrollment/actions/enrollment.actionBatchs.js +++ b/src/core_modules/capture-core/components/DataEntries/Enrollment/actions/enrollment.actionBatchs.js @@ -5,7 +5,7 @@ import type { Enrollment, TEIValues, OrgUnit, -} from '@dhis2/rules-engine-javascript'; +} from '../../../../rules/RuleEngine/types/ruleEngine.types'; import { getApplicableRuleEffectsForTrackerProgram, updateRulesEffects, diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useRuleEffects/useRuleEffects.js b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useRuleEffects/useRuleEffects.js index f1d24d1b6f..8119338ff6 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useRuleEffects/useRuleEffects.js +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useRuleEffects/useRuleEffects.js @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { convertValue } from '../../../../../converters/serverToClient'; import { getApplicableRuleEffectsForTrackerProgram } from '../../../../../rules'; -import { dataElementTypes, type TrackerProgram } from '../../../../../metaData'; +import { dataElementTypes, getTrackerProgramThrowIfNotFound, type TrackerProgram } from '../../../../../metaData'; import type { UseRuleEffectsInput } from './useRuleEffects.types'; // $FlowFixMe @@ -48,12 +48,14 @@ const useEnrollmentData = enrollment => useMemo(() => { return undefined; } - const { enrollment: enrollmentId, enrolledAt, occurredAt } = enrollment; + const { enrollment: enrollmentId, enrolledAt, occurredAt, status, program } = enrollment; return { enrolledAt: convertDate(enrolledAt), occurredAt: occurredAt ? convertDate(occurredAt) : undefined, enrollmentId, + enrollmentStatus: status, + programName: getTrackerProgramThrowIfNotFound(program).name, }; }, [enrollment]); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/epics/editEventDataEntry.epics.js b/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/epics/editEventDataEntry.epics.js index 8faa384940..669c11c684 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/epics/editEventDataEntry.epics.js +++ b/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/epics/editEventDataEntry.epics.js @@ -9,7 +9,8 @@ import { batchActionTypes as editEventDataEntryBatchActionTypes, actionTypes as editEventDataEntryActionTypes, } from '../editEventDataEntry.actions'; -import { getProgramThrowIfNotFound } from '../../../../metaData'; +import { getProgramThrowIfNotFound, dataElementTypes } from '../../../../metaData'; +import { convertValue } from '../../../../converters/serverToClient'; import { getCurrentClientValues, getCurrentClientMainData, @@ -85,7 +86,7 @@ const runRulesForEditSingleEvent = async ({ program, stage, orgUnit: coreOrgUnit, - currentEvent: { ...currentEvent, createdAt: apiCurrentEventOriginal.createdAt }, + currentEvent: { ...currentEvent, createdAt: convertValue(apiCurrentEventOriginal.createdAt, dataElementTypes.DATETIME) }, otherEvents: prepareEnrollmentEventsForRulesEngine(apiOtherEvents), enrollmentData: getEnrollmentForRulesEngine(enrollment), attributeValues: getAttributeValuesForRulesEngine(attributeValues, program.attributes), diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/helpers/getEnrollmentForRulesEngine.js b/src/core_modules/capture-core/components/WidgetEventEdit/helpers/getEnrollmentForRulesEngine.js index 2f0eba0609..fc9ace7b0e 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/helpers/getEnrollmentForRulesEngine.js +++ b/src/core_modules/capture-core/components/WidgetEventEdit/helpers/getEnrollmentForRulesEngine.js @@ -1,14 +1,28 @@ // @flow import { convertServerToClient } from '../../../converters'; -import { dataElementTypes } from '../../../metaData'; +import { dataElementTypes, getTrackerProgramThrowIfNotFound } from '../../../metaData'; import type { EnrollmentData, } from '../../Pages/common/EnrollmentOverviewDomain/useCommonEnrollmentDomainData'; -export const getEnrollmentForRulesEngine = ({ enrolledAt, occurredAt, enrollment }: EnrollmentData = {}): { enrollmentId: string, enrolledAt: string, occurredAt?: string } => ({ +export const getEnrollmentForRulesEngine = ({ + enrolledAt, + occurredAt, + enrollment, + status, + program, +}: EnrollmentData = {}): { + enrollmentId: string, + enrolledAt: string, + occurredAt?: string, + enrollmentStatus: string, + programName: string, +} => ({ enrollmentId: enrollment, // $FlowFixMe enrolledAt: convertServerToClient(enrolledAt, dataElementTypes.DATE), // $FlowFixMe occurredAt: convertServerToClient(occurredAt, dataElementTypes.DATE), + enrollmentStatus: status, + programName: getTrackerProgramThrowIfNotFound(program).name, }); diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/ProgramRules/getRulesActionsForTEI.js b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/ProgramRules/getRulesActionsForTEI.js index 03c2aff309..7ef8431075 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/ProgramRules/getRulesActionsForTEI.js +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/ProgramRules/getRulesActionsForTEI.js @@ -9,23 +9,31 @@ import type { ProgramRulesContainer, EventsData, DataElements, -} from '@dhis2/rules-engine-javascript'; -import { rulesEngine } from '../../../../rules/rulesEngine'; -import type { RenderFoundation } from '../../../../metaData'; +} from '../../../../rules/RuleEngine'; +import { ruleEngine } from '../../../../rules/rulesEngine'; +import { + dataElementTypes, + type RenderFoundation, +} from '../../../../metaData'; import { updateRulesEffects, postProcessRulesEffects, buildEffectsHierarchy, validateAssignEffects, } from '../../../../rules'; +import { convertServerToClient } from '../../../../converters'; import type { QuerySingleResource } from '../../../../utils/api'; +import type { EnrollmentData } from '../Types'; -const getEnrollmentForRulesExecution = enrollment => +const getEnrollmentForRulesExecution = (enrollment: ?EnrollmentData, programName: string): ?Enrollment => enrollment && { - // $FlowFixMe[prop-missing] enrollmentId: enrollment.enrollment, - enrolledAt: enrollment.enrolledAt, - occurredAt: enrollment.occurredAt, + // $FlowFixMe + enrolledAt: convertServerToClient(enrollment.enrolledAt, dataElementTypes.DATE), + // $FlowFixMe + occurredAt: convertServerToClient(enrollment.occurredAt, dataElementTypes.DATE), + enrollmentStatus: enrollment.status, + programName, }; const getDataElementsForRulesExecution = (dataElements: ?DataElements) => @@ -54,11 +62,12 @@ export const getRulesActionsForTEI = ({ otherEvents, dataElements, userRoles, + programName, }: { foundation: RenderFoundation, formId: string, orgUnit: OrgUnit, - enrollmentData?: ?Enrollment, + enrollmentData?: EnrollmentData, teiValues?: ?TEIValues, trackedEntityAttributes: ?TrackedEntityAttributes, optionSets: OptionSets, @@ -66,14 +75,15 @@ export const getRulesActionsForTEI = ({ otherEvents?: ?EventsData, dataElements: ?DataElements, userRoles: Array, + programName: string, }) => { - const effects: OutputEffects = rulesEngine.getProgramRuleEffects({ + const effects: OutputEffects = ruleEngine().getProgramRuleEffects({ programRulesContainer: rulesContainer, currentEvent: null, otherEvents, dataElements: getDataElementsForRulesExecution(dataElements), trackedEntityAttributes, - selectedEnrollment: getEnrollmentForRulesExecution(enrollmentData), + selectedEnrollment: getEnrollmentForRulesExecution(enrollmentData, programName), selectedEntity: teiValues, selectedOrgUnit: orgUnit, selectedUserRoles: userRoles, @@ -95,13 +105,14 @@ export const getRulesActionsForTEIAsync = async ({ otherEvents, dataElements, userRoles, + programName, querySingleResource, onGetValidationContext, }: { foundation: RenderFoundation, formId: string, orgUnit: OrgUnit, - enrollmentData?: ?Enrollment, + enrollmentData?: EnrollmentData, teiValues?: ?TEIValues, trackedEntityAttributes: ?TrackedEntityAttributes, optionSets: OptionSets, @@ -109,16 +120,18 @@ export const getRulesActionsForTEIAsync = async ({ otherEvents?: ?EventsData, dataElements: ?DataElements, userRoles: Array, + programName: string, querySingleResource: QuerySingleResource, onGetValidationContext: () => Object, }) => { - const effects: OutputEffects = rulesEngine.getProgramRuleEffects({ + const effects: OutputEffects = ruleEngine().getProgramRuleEffects({ programRulesContainer: rulesContainer, currentEvent: null, otherEvents, dataElements: getDataElementsForRulesExecution(dataElements), trackedEntityAttributes, - selectedEnrollment: getEnrollmentForRulesExecution(enrollmentData), + // $FlowFixMe (flow doesn't understand that selectedEnrollment.enrolledAt/occurredAt are strings) + selectedEnrollment: getEnrollmentForRulesExecution(enrollmentData, programName), selectedEntity: teiValues, selectedOrgUnit: orgUnit, selectedUserRoles: userRoles, diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/ProgramRules/rulesContainer.js b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/ProgramRules/rulesContainer.js index 6e8f0a9757..dea0912af8 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/ProgramRules/rulesContainer.js +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/ProgramRules/rulesContainer.js @@ -23,7 +23,7 @@ const addProgramRules = (program, programRules) => { })); }; -const addRulesAndVariablesFromProgramIndicators = (program, programIndicators) => { +const addRulesAndVariablesFromProgramIndicators = (rulesContainer, programIndicators, programId) => { const validProgramIndicators = programIndicators.filter((indicator) => { if (!indicator.expression) { log.error( @@ -43,13 +43,13 @@ const addRulesAndVariablesFromProgramIndicators = (program, programIndicators) = ...programIndicator, programId: getProgramId(programIndicator), })); - const { rules, variables } = getRulesAndVariablesFromProgramIndicators(indicators, program.id); + const { rules, variables } = getRulesAndVariablesFromProgramIndicators(indicators, programId); if (variables) { - program.programRuleVariables = [...program.programRuleVariables, ...variables]; + rulesContainer.programRuleVariables = [...rulesContainer.programRuleVariables, ...variables]; } if (rules) { - program.programRules = [...program.programRules, ...rules]; + rulesContainer.programRules = [...rulesContainer.programRules, ...rules]; } }; @@ -70,7 +70,7 @@ export const buildRulesContainer = async ({ programRuleVariables && addProgramVariables(rulesContainer, programRuleVariables); programRules && addProgramRules(rulesContainer, programRules); - programIndicators && addRulesAndVariablesFromProgramIndicators(rulesContainer, programIndicators); + programIndicators && addRulesAndVariablesFromProgramIndicators(rulesContainer, programIndicators, programAPI.id); rulesContainer.constants = constants; setRulesContainer(rulesContainer); diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/Types/EnrollmentData.type.js b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/Types/EnrollmentData.type.js new file mode 100644 index 0000000000..f20e4a1c0f --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/Types/EnrollmentData.type.js @@ -0,0 +1,7 @@ +// @flow +export type EnrollmentData = { + enrollment: string, + enrolledAt: string, + occurredAt: string, + status: string, +}; diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/Types/index.js b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/Types/index.js new file mode 100644 index 0000000000..184061a081 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/Types/index.js @@ -0,0 +1,2 @@ +// @flow +export { EnrollmentData } from './EnrollmentData.type'; diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/dataEntry.actions.js b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/dataEntry.actions.js index dccc04badc..61db5d0cd7 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/dataEntry.actions.js +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/dataEntry.actions.js @@ -1,5 +1,6 @@ // @flow import { batchActions } from 'redux-batched-actions'; +import { convertGeometryOut } from 'capture-core/components/DataEntries/converters'; import type { OrgUnit, TrackedEntityAttributes, @@ -7,9 +8,8 @@ import type { ProgramRulesContainer, EventsData, DataElements, - Enrollment, -} from '@dhis2/rules-engine-javascript'; -import { convertGeometryOut } from 'capture-core/components/DataEntries/converters'; +} from '../../../rules/RuleEngine'; +import type { EnrollmentData } from './Types'; import { actionCreator } from '../../../actions/actions.utils'; import { effectMethods } from '../../../trackerOffline'; import type { RenderFoundation } from '../../../metaData'; @@ -54,13 +54,14 @@ const dataEntryPropsToInclude: Array = [ type Context = { orgUnit: OrgUnit, + programName: string, trackedEntityAttributes: ?TrackedEntityAttributes, optionSets: OptionSets, rulesContainer: ProgramRulesContainer, formFoundation: RenderFoundation, otherEvents?: ?EventsData, dataElements: ?DataElements, - enrollment?: ?Enrollment, + enrollment?: EnrollmentData, userRoles: Array, state: ReduxState, }; @@ -89,6 +90,7 @@ export const getUpdateFieldActions = async ({ dataElements, enrollment, userRoles, + programName, } = context; const { dataEntryId, itemId, elementId, value, uiState } = innerAction.payload || {}; const fieldData: FieldData = { @@ -110,6 +112,7 @@ export const getUpdateFieldActions = async ({ otherEvents, dataElements, userRoles, + programName, querySingleResource, onGetValidationContext, }); diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/hooks/useLifecycle.js b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/hooks/useLifecycle.js index 16a8ff9cea..4721d675d1 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/hooks/useLifecycle.js +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/hooks/useLifecycle.js @@ -1,14 +1,13 @@ // @flow import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useOrganisationUnit } from 'capture-core/dataQueries/useOrganisationUnit'; +import { useCoreOrgUnit, type CoreOrgUnit } from 'capture-core/metadataRetrieval/coreOrgUnit'; import type { - OrgUnit, TrackedEntityAttributes, OptionSets, ProgramRulesContainer, DataElements, -} from '@dhis2/rules-engine-javascript'; +} from '../../../../rules/RuleEngine'; import { cleanUpDataEntry } from '../../../DataEntry'; import { RenderFoundation } from '../../../../metaData'; import { getOpenDataEntryActions, cleanTeiModal } from '../dataEntry.actions'; @@ -25,6 +24,7 @@ import { import type { Geometry } from '../helpers/types'; import { getRulesActionsForTEI } from '../ProgramRules'; import type { DataEntryFormConfig } from '../../../DataEntries/common/TEIAndEnrollment'; +import type { EnrollmentData } from '../Types'; export const useLifecycle = ({ programAPI, @@ -50,10 +50,10 @@ export const useLifecycle = ({ // The problem is the helper methods that take the entire state object. // Refactor the helper methods (getCurrentClientValues, getCurrentClientMainData in rules/actionsCreator) to be more explicit with the arguments. const state = useSelector(stateArg => stateArg); - const enrollment = useSelector(({ enrollmentDomain }) => enrollmentDomain?.enrollment); + const enrollment: EnrollmentData = useSelector(({ enrollmentDomain }) => enrollmentDomain?.enrollment); const dataElements: DataElements = useDataElements(programAPI); const otherEvents = useEvents(enrollment, dataElements); - const orgUnit: ?OrgUnit = useOrganisationUnit(orgUnitId).orgUnit; + const orgUnit: ?CoreOrgUnit = useCoreOrgUnit(orgUnitId).orgUnit; const rulesContainer: ProgramRulesContainer = useRulesContainer(programAPI); const formFoundation: RenderFoundation = useFormFoundation(programAPI, dataEntryFormConfig); const { formValues, clientValues } = useFormValues({ formFoundation, clientAttributesWithSubvalues, orgUnit }); @@ -101,6 +101,7 @@ export const useLifecycle = ({ dataElements, enrollmentData: enrollment, userRoles, + programName: programAPI.displayName, }), ); } @@ -120,6 +121,7 @@ export const useLifecycle = ({ enrollment, clientGeometryValues, userRoles, + programAPI, ]); return { @@ -133,5 +135,6 @@ export const useLifecycle = ({ dataElements, enrollment, userRoles, + programName: programAPI.displayName, }; }; diff --git a/src/core_modules/capture-core/events/eventRequests.js b/src/core_modules/capture-core/events/eventRequests.js index c8e6ca1695..fb7cf239ee 100644 --- a/src/core_modules/capture-core/events/eventRequests.js +++ b/src/core_modules/capture-core/events/eventRequests.js @@ -69,6 +69,8 @@ function getConvertedValue(valueToConvert: any, inputKey: string) { let convertedValue; if (inputKey === 'occurredAt' || inputKey === 'scheduledAt' || inputKey === 'completedAt') { convertedValue = convertValue(valueToConvert, dataElementTypes.DATE); + } else if (inputKey === 'createdAt') { + convertedValue = convertValue(valueToConvert, dataElementTypes.DATETIME); } else { convertedValue = valueToConvert; } diff --git a/src/core_modules/capture-core/events/prepareEnrollmentEvents.js b/src/core_modules/capture-core/events/prepareEnrollmentEvents.js index 31e19524e1..b01b92bc50 100644 --- a/src/core_modules/capture-core/events/prepareEnrollmentEvents.js +++ b/src/core_modules/capture-core/events/prepareEnrollmentEvents.js @@ -60,6 +60,8 @@ function convertMainProperties(apiEvent: ApiEnrollmentEvent): (CaptureClientEven let convertedValue; if (inputKey === 'occurredAt' || inputKey === 'scheduledAt' || inputKey === 'completedAt') { convertedValue = convertValue(valueToConvert, dataElementTypes.DATE); + } else if (inputKey === 'createdAt') { + convertedValue = convertValue(valueToConvert, dataElementTypes.DATETIME); } else { convertedValue = valueToConvert; } diff --git a/src/core_modules/capture-core/metaData/Program/ProgramStage.js b/src/core_modules/capture-core/metaData/Program/ProgramStage.js index 226cf9d313..4016650487 100644 --- a/src/core_modules/capture-core/metaData/Program/ProgramStage.js +++ b/src/core_modules/capture-core/metaData/Program/ProgramStage.js @@ -4,6 +4,7 @@ import isFunction from 'd2-utilizr/lib/isFunction'; import type { ProgramRule } from '@dhis2/rules-engine-javascript'; +import type { CachedDataElement } from '../../storageControllers/cache.types'; import type { Icon } from '../Icon'; import type { RenderFoundation } from '../RenderFoundation'; import type { RelationshipType } from '../RelationshipType'; @@ -27,6 +28,7 @@ export class ProgramStage { _reportDateToUse: string; _minDaysFromStart: number; _icon: Icon | void; + _dataElements: Array; _programRules: Array; constructor(initFn: ?(_this: ProgramStage) => void) { @@ -177,6 +179,14 @@ export class ProgramStage { this._minDaysFromStart = minDays; } + get dataElements(): Array { + return this._dataElements; + } + + set dataElements(dataElements: Array) { + this._dataElements = dataElements; + } + set programRules(programRules: Array) { this._programRules = programRules; } diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.js b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.js index 4241f27b2d..8023affcfc 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.js +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.js @@ -18,7 +18,7 @@ import { buildIcon } from '../../../common/helpers'; import { isNonEmptyArray } from '../../../../utils/isNonEmptyArray'; import { DataElementFactory } from './DataElementFactory'; import { RelationshipTypesFactory } from './RelationshipTypesFactory'; -import type { ConstructorInput, SectionSpecs } from './programStageFactory.types'; +import type { ConstructorInput, SectionSpecs, RuleProgramStageDataElement } from './programStageFactory.types'; import { transformEventNode } from '../transformNodeFuntions/transformNodeFunctions'; import type { DataEntryFormConfig } from '../../../../components/DataEntries/common/TEIAndEnrollment'; import { FormFieldTypes } from '../../../../components/D2Form/FormFieldPlugin/FormFieldPlugin.const'; @@ -155,6 +155,39 @@ export class ProgramStageFactory { return section; } + async _buildProgramStageDataElements( + cachedProgramStageDataElements: Array, + ): Promise { + const cachedDataElements = this.cachedDataElements; + if (cachedDataElements) { + // $FlowIgnore + return cachedProgramStageDataElements + .map(dataElement => cachedDataElements.get(dataElement.dataElementId)) + .filter(Boolean) + .map(dataElement => (dataElement && { + id: dataElement.id, + name: dataElement.displayFormName || dataElement.displayName, + valueType: dataElement.valueType, + optionSetId: dataElement.optionSet?.id, + })); + } + // $FlowIgnore + const dataElementPromises = cachedProgramStageDataElements + .map(async (cachedDataElement) => { + // $FlowIgnore + const dataElement = await this.dataElementFactory.build(cachedDataElement); + return dataElement && { + id: dataElement.id, + name: dataElement.formName || dataElement.name, + valueType: dataElement.type, + optionSetId: dataElement.optionSet?.id, + }; + }); + const dataElements: Array = await Promise.all(dataElementPromises); + // $FlowIgnore + return dataElements.filter(Boolean); + } + static _convertProgramStageDataElementsToObject( cachedProgramStageDataElements: ?Array): CachedProgramStageDataElementsAsObject { if (!cachedProgramStageDataElements) { @@ -309,6 +342,9 @@ export class ProgramStageFactory { stageForm.addSection(await this._buildMainSection(cachedProgramStage.programStageDataElements)); } + // $FlowIgnore + stage.dataElements = await this._buildProgramStageDataElements(cachedProgramStage.programStageDataElements); + return stage; } } diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/programStageFactory.types.js b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/programStageFactory.types.js index 5e676cc968..428a4c5654 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/programStageFactory.types.js +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/programStageFactory.types.js @@ -24,3 +24,10 @@ export type SectionSpecs = {| displayDescription: string, dataElements: ?Array, |}; + +export type RuleProgramStageDataElement = {| + id: string, + name: string, + valueType: string, + optionSetId: ?string, +|}; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/getRulesAndVariablesFromIndicators.js b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/getRulesAndVariablesFromIndicators.js index 5ebbbb23f0..0b283f823d 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/getRulesAndVariablesFromIndicators.js +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/getRulesAndVariablesFromIndicators.js @@ -4,6 +4,11 @@ import log from 'loglevel'; import { errorCreator } from 'capture-core-utils'; import type { ProgramRule, ProgramRuleAction, ProgramRuleVariable } from '@dhis2/rules-engine-javascript'; import { variableSourceTypes } from '@dhis2/rules-engine-javascript'; +import { TrackerProgram, getProgramThrowIfNotFound } from '../../metaData'; +import { + getDataElementsForRulesExecution, + getTrackedEntityAttributesForRulesExecution, +} from '../../rules'; export type CachedProgramIndicator = { id: string, @@ -18,6 +23,14 @@ export type CachedProgramIndicator = { style?: ?{ color?: ?string }, }; +type ValueTypeReference = { [id: string]: { valueType: string } }; + +type ProgramData = {| + programId: string, + dataElements: ValueTypeReference, + attributes: ValueTypeReference, +|}; + const staticReplacements = [ { regExp: new RegExp('([^\\w\\d])(and)([^\\w\\d])', 'gi'), replacement: '$1&&$3' }, { regExp: new RegExp('([^\\w\\d])(or)([^\\w\\d])', 'gi'), replacement: '$1||$3' }, @@ -43,7 +56,7 @@ function trimVariableQualifiers(input) { return trimmed; } -function getDirectAddressedVariable(variableWithCurls, programId) { +function getDirectAddressedVariable(variableWithCurls, programData) { const variableName = trimVariableQualifiers(variableWithCurls); const variableNameParts = variableName.split('.'); @@ -55,10 +68,10 @@ function getDirectAddressedVariable(variableWithCurls, programId) { id: variableName, displayName: variableName, programRuleVariableSourceType: variableSourceTypes.DATAELEMENT_NEWEST_EVENT_PROGRAM_STAGE, - valueType: 'TEXT', + valueType: programData.dataElements[variableNameParts[1]].valueType, programStageId: variableNameParts[0], dataElementId: variableNameParts[1], - programId, + programId: programData.programId, }; } else { // if (variableNameParts.length === 1) // This is an attribute @@ -66,21 +79,21 @@ function getDirectAddressedVariable(variableWithCurls, programId) { id: variableName, displayName: variableName, programRuleVariableSourceType: variableSourceTypes.TEI_ATTRIBUTE, - valueType: 'TEXT', + valueType: programData.attributes[variableNameParts[0]].valueType, trackedEntityAttributeId: variableNameParts[0], - programId, + programId: programData.programId, }; } return newVariableObject; } -function getVariables(action, rule, programId) { +function getVariables(action, rule, programData) { const variablesInCondition = getVariablesFromExpression(rule.condition); // $FlowFixMe[incompatible-call] automated comment const variablesInData = getVariablesFromExpression(action.data); - const directAddressedVariablesFromConditions = variablesInCondition.map(variableInCondition => getDirectAddressedVariable(variableInCondition, programId)); - const directAddressedVariablesFromData = variablesInData.map(variableInData => getDirectAddressedVariable(variableInData, programId)); + const directAddressedVariablesFromConditions = variablesInCondition.map(variableInCondition => getDirectAddressedVariable(variableInCondition, programData)); + const directAddressedVariablesFromData = variablesInData.map(variableInData => getDirectAddressedVariable(variableInData, programData)); const variables = [...directAddressedVariablesFromConditions, ...directAddressedVariablesFromData]; return { @@ -145,7 +158,10 @@ function replacePositiveValueCountIfPresent(rule, action, variableObjectsCurrent } } -function buildIndicatorRuleAndVariables(programIndicator: CachedProgramIndicator, programId: string) { +function buildIndicatorRuleAndVariables( + programIndicator: CachedProgramIndicator, + programData: ProgramData, +) { // $FlowFixMe[prop-missing] automated comment const newAction: ProgramRuleAction = { id: programIndicator.id, @@ -168,7 +184,7 @@ function buildIndicatorRuleAndVariables(programIndicator: CachedProgramIndicator programRuleActions: [newAction], }; - const { variables, variableObjectsCurrentExpression } = getVariables(newAction, newRule, programId); + const { variables, variableObjectsCurrentExpression } = getVariables(newAction, newRule, programData); // Change expression or data part of the rule to match the program rules execution model replaceValueCountIfPresent(newRule, newAction, variableObjectsCurrentExpression); @@ -186,7 +202,18 @@ function buildIndicatorRuleAndVariables(programIndicator: CachedProgramIndicator export function getRulesAndVariablesFromProgramIndicators( cachedProgramIndicators: Array, - programId: string) { + programId: string, +) { + const program = getProgramThrowIfNotFound(programId); + const dataElements = getDataElementsForRulesExecution(program.stages); + const attributes = (program instanceof TrackerProgram) ? + getTrackedEntityAttributesForRulesExecution(program.attributes) : {}; + const programData = { + programId, + dataElements, + attributes, + }; + // Filter out program indicators without an expression const validProgramIndicators = cachedProgramIndicators.filter((indicator) => { if (!indicator.expression) { @@ -204,7 +231,7 @@ export function getRulesAndVariablesFromProgramIndicators( }); return validProgramIndicators - .map(programIndicator => buildIndicatorRuleAndVariables(programIndicator, programId)) + .map(programIndicator => buildIndicatorRuleAndVariables(programIndicator, programData)) .filter(container => container) .reduce((accOneLevelContainer, container) => { // $FlowFixMe[incompatible-type] automated comment diff --git a/src/core_modules/capture-core/rules/RuleEngine/RuleEngine.js b/src/core_modules/capture-core/rules/RuleEngine/RuleEngine.js new file mode 100644 index 0000000000..d05cc96111 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/RuleEngine.js @@ -0,0 +1,114 @@ +// @flow +/* eslint-disable complexity */ +import { RuleEngineJs } from '@dhis2/rule-engine'; +import { + InputBuilder, + ValueProcessor, + getRulesEffectsProcessor, +} from './helpers'; +import type { + OutputEffects, + RulesEngineInput, + IConvertInputRulesValue, + IConvertOutputRulesEffectsValue, + Flag, +} from './types/ruleEngine.types'; + +export class RuleEngine { + inputConverter: IConvertInputRulesValue; + outputConverter: IConvertOutputRulesEffectsValue; + valueProcessor: ValueProcessor; + userRoles: Array; + flags: Flag; + + constructor( + inputConverter: IConvertInputRulesValue, + outputConverter: IConvertOutputRulesEffectsValue, + flags?: Flag, + ) { + this.inputConverter = inputConverter; + this.outputConverter = outputConverter; + this.valueProcessor = new ValueProcessor(inputConverter); + this.flags = flags ?? {}; + } + + getProgramRuleEffects({ + programRulesContainer, + currentEvent, + otherEvents, + dataElements, + trackedEntityAttributes, + selectedEntity, + selectedEnrollment, + selectedOrgUnit, + selectedUserRoles, + optionSets, + }: RulesEngineInput): OutputEffects { + if (!programRulesContainer.programRules || + !selectedOrgUnit || + (!currentEvent && !selectedEnrollment)) return []; + + const inputBuilder = new InputBuilder( + this.inputConverter, + dataElements, + trackedEntityAttributes, + optionSets, + selectedOrgUnit, + ); + const executionContext = inputBuilder.buildRuleEngineContext({ + programRulesContainer, + selectedUserRoles, + }); + const enrollment = selectedEnrollment ? + inputBuilder.buildEnrollment({ + selectedEnrollment, + selectedEntity, + selectedOrgUnit, + }) : null; + + const events = otherEvents ? + otherEvents.map(inputBuilder.convertEvent) : + []; + + const ruleEngine = new RuleEngineJs(this.flags.verbose || false); + const effects = (currentEvent ? + ruleEngine.evaluateEvent( + inputBuilder.convertEvent(currentEvent), + enrollment, + events, + executionContext, + ) : + ruleEngine.evaluateEnrollment( + enrollment, + events, + executionContext, + )) + .map(effect => ({ + ...Object.fromEntries(effect.ruleAction.values), + action: effect.ruleAction.type, + data: effect.data, + })); + + const processRulesEffects = getRulesEffectsProcessor(this.outputConverter); + return processRulesEffects({ + effects, + dataElements, + trackedEntityAttributes, + // $FlowFixMe[exponential-spread] + formValues: { ...selectedEntity, ...currentEvent }, + onProcessValue: this.valueProcessor.processValue, + }); + } + + setSelectedUserRoles(userRoles: Array) { + this.userRoles = userRoles; + } + + setFlags(flags: Flag) { + this.flags = flags; + } + + getFlags(): Flag { + return this.flags; + } +} diff --git a/src/core_modules/capture-core/rules/RuleEngine/constants/attributeTypes.const.js b/src/core_modules/capture-core/rules/RuleEngine/constants/attributeTypes.const.js new file mode 100644 index 0000000000..15fa48e617 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/constants/attributeTypes.const.js @@ -0,0 +1,7 @@ +// @flow + +export const attributeTypes = { + DATA_ELEMENT: 'DATA_ELEMENT', + TRACKED_ENTITY_ATTRIBUTE: 'TRACKED_ENTITY_ATTRIBUTE', + UNKNOWN: 'UNKNOWN', +}; diff --git a/src/core_modules/capture-core/rules/RuleEngine/constants/effectActions.const.js b/src/core_modules/capture-core/rules/RuleEngine/constants/effectActions.const.js new file mode 100644 index 0000000000..1978e98bc8 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/constants/effectActions.const.js @@ -0,0 +1,20 @@ +// @flow + +export const effectActions = Object.freeze({ + ASSIGN_VALUE: 'ASSIGN', + HIDE_FIELD: 'HIDEFIELD', + SHOW_ERROR: 'SHOWERROR', + SHOW_WARNING: 'SHOWWARNING', + SHOW_ERROR_ONCOMPLETE: 'ERRORONCOMPLETE', + SHOW_WARNING_ONCOMPLETE: 'WARNINGONCOMPLETE', + HIDE_PROGRAM_STAGE: 'HIDEPROGRAMSTAGE', + HIDE_SECTION: 'HIDESECTION', + MAKE_COMPULSORY: 'SETMANDATORYFIELD', + DISPLAY_TEXT: 'DISPLAYTEXT', + DISPLAY_KEY_VALUE_PAIR: 'DISPLAYKEYVALUEPAIR', + HIDE_OPTION_GROUP: 'HIDEOPTIONGROUP', + HIDE_OPTION: 'HIDEOPTION', + SHOW_OPTION_GROUP: 'SHOWOPTIONGROUP', + SCHEDULE_MESSAGE: 'SCHEDULEMESSAGE', + SEND_MESSAGE: 'SENDMESSAGE', +}); diff --git a/src/core_modules/capture-core/rules/RuleEngine/constants/environmentTypes.const.js b/src/core_modules/capture-core/rules/RuleEngine/constants/environmentTypes.const.js new file mode 100644 index 0000000000..f16ae99572 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/constants/environmentTypes.const.js @@ -0,0 +1,6 @@ +// @flow +export const environmentTypes = Object.freeze({ + WebClient: 'WebClient', + AndroidClient: 'AndroidClient', + Server: 'Server', +}); diff --git a/src/core_modules/capture-core/rules/RuleEngine/constants/eventStatuses.const.js b/src/core_modules/capture-core/rules/RuleEngine/constants/eventStatuses.const.js new file mode 100644 index 0000000000..aadc9fdc31 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/constants/eventStatuses.const.js @@ -0,0 +1,9 @@ +// @flow +export const eventStatuses = Object.freeze({ + ACTIVE: 'ACTIVE', + COMPLETED: 'COMPLETED', + VISITED: 'VISITED', + SCHEDULE: 'SCHEDULE', + OVERDUE: 'OVERDUE', + SKIPPED: 'SKIPPED', +}); diff --git a/src/core_modules/capture-core/rules/RuleEngine/constants/index.js b/src/core_modules/capture-core/rules/RuleEngine/constants/index.js new file mode 100644 index 0000000000..85e607dee7 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/constants/index.js @@ -0,0 +1,8 @@ +// @flow +export { attributeTypes } from './attributeTypes.const'; +export { effectActions } from './effectActions.const'; +export { environmentTypes } from './environmentTypes.const'; +export { eventStatuses } from './eventStatuses.const'; +export { mapTypeToInterfaceFnName } from './typeToInterfaceFnName.const'; +export { rulesEngineEffectTargetDataTypes } from './targetDataTypes.const'; +export { typeKeys } from './typeKeys.const'; diff --git a/src/core_modules/capture-core/rules/RuleEngine/constants/targetDataTypes.const.js b/src/core_modules/capture-core/rules/RuleEngine/constants/targetDataTypes.const.js new file mode 100644 index 0000000000..d86f1a025d --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/constants/targetDataTypes.const.js @@ -0,0 +1,6 @@ +// @flow + +export const rulesEngineEffectTargetDataTypes = { + DATA_ELEMENT: 'dataElement', + TRACKED_ENTITY_ATTRIBUTE: 'trackedEntityAttribute', +}; diff --git a/src/core_modules/capture-core/rules/RuleEngine/constants/typeKeys.const.js b/src/core_modules/capture-core/rules/RuleEngine/constants/typeKeys.const.js new file mode 100644 index 0000000000..9280d683dc --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/constants/typeKeys.const.js @@ -0,0 +1,27 @@ +// @flow +export const typeKeys = { + TEXT: 'TEXT', + MULTI_TEXT: 'MULTI_TEXT', + LONG_TEXT: 'LONG_TEXT', + LETTER: 'LETTER', + PHONE_NUMBER: 'PHONE_NUMBER', + EMAIL: 'EMAIL', + BOOLEAN: 'BOOLEAN', // Yes/No + TRUE_ONLY: 'TRUE_ONLY', // Yes Only + DATE: 'DATE', + DATETIME: 'DATETIME', + TIME: 'TIME', + NUMBER: 'NUMBER', + PERCENTAGE: 'PERCENTAGE', + INTEGER: 'INTEGER', + INTEGER_POSITIVE: 'INTEGER_POSITIVE', + INTEGER_NEGATIVE: 'INTEGER_NEGATIVE', + INTEGER_ZERO_OR_POSITIVE: 'INTEGER_ZERO_OR_POSITIVE', + USERNAME: 'USERNAME', + COORDINATE: 'COORDINATE', + ORGANISATION_UNIT: 'ORGANISATION_UNIT', + AGE: 'AGE', + URL: 'URL', + FILE_RESOURCE: 'FILE_RESOURCE', + IMAGE: 'IMAGE', +}; diff --git a/src/core_modules/capture-core/rules/RuleEngine/constants/typeToInterfaceFnName.const.js b/src/core_modules/capture-core/rules/RuleEngine/constants/typeToInterfaceFnName.const.js new file mode 100644 index 0000000000..f1fc9af926 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/constants/typeToInterfaceFnName.const.js @@ -0,0 +1,29 @@ +// @flow +import { typeKeys } from './typeKeys.const'; + +export const mapTypeToInterfaceFnName = { + [typeKeys.TEXT]: 'convertText', + [typeKeys.MULTI_TEXT]: 'convertMultiText', + [typeKeys.LONG_TEXT]: 'convertLongText', + [typeKeys.LETTER]: 'convertLetter', + [typeKeys.PHONE_NUMBER]: 'convertPhoneNumber', + [typeKeys.EMAIL]: 'convertEmail', + [typeKeys.BOOLEAN]: 'convertBoolean', + [typeKeys.TRUE_ONLY]: 'convertTrueOnly', + [typeKeys.DATE]: 'convertDate', + [typeKeys.DATETIME]: 'convertDateTime', + [typeKeys.TIME]: 'convertTime', + [typeKeys.NUMBER]: 'convertNumber', + [typeKeys.INTEGER]: 'convertInteger', + [typeKeys.INTEGER_POSITIVE]: 'convertIntegerPositive', + [typeKeys.INTEGER_NEGATIVE]: 'convertIntegerNegative', + [typeKeys.INTEGER_ZERO_OR_POSITIVE]: 'convertIntegerZeroOrPositive', + [typeKeys.PERCENTAGE]: 'convertPercentage', + [typeKeys.URL]: 'convertUrl', + [typeKeys.AGE]: 'convertAge', + [typeKeys.FILE_RESOURCE]: 'convertFile', + [typeKeys.ORGANISATION_UNIT]: 'convertOrganisationUnit', + [typeKeys.IMAGE]: 'convertImage', + [typeKeys.USERNAME]: 'convertUserName', + [typeKeys.COORDINATE]: 'convertCoordinate', +}; diff --git a/src/core_modules/capture-core/rules/RuleEngine/helpers/InputBuilder.js b/src/core_modules/capture-core/rules/RuleEngine/helpers/InputBuilder.js new file mode 100644 index 0000000000..2691447f98 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/helpers/InputBuilder.js @@ -0,0 +1,379 @@ +// @flow +import { Instant, LocalDate } from '@js-joda/core'; +import { + Option, + RuleActionJs, + RuleDataValue, + RuleEngineContextJs, + RuleEnrollmentJs, + RuleEventJs, + RuleJs, + RuleVariableJs, + RuleAttributeValue, + RuleEnrollmentStatus, + RuleEventStatus, + RuleValueType, + RuleVariableType, +} from '@dhis2/rule-engine'; +import { ValueProcessor } from './ValueProcessor'; +import { + attributeTypes, + effectActions, + typeKeys, +} from '../constants'; +import type { + ProgramRule, + ProgramRuleAction, + ProgramRuleVariable, + ProgramRulesContainer, + OrgUnit, + Option as RawOption, + OptionSets, + Constants, + EventData, + Enrollment, + TEIValues, + DataElements, + TrackedEntityAttributes, + IConvertInputRulesValue, +} from '../types/ruleEngine.types'; +import type { + KotlinOptionSet, + KotlinOptionSets, +} from '../types/kotlinRuleEngine.types'; + +const variableSourceTypesDataElementSpecific = { + DATAELEMENT_CURRENT_EVENT: 'DATAELEMENT_CURRENT_EVENT', + DATAELEMENT_NEWEST_EVENT_PROGRAM_STAGE: 'DATAELEMENT_NEWEST_EVENT_PROGRAM_STAGE', + DATAELEMENT_NEWEST_EVENT_PROGRAM: 'DATAELEMENT_NEWEST_EVENT_PROGRAM', + DATAELEMENT_PREVIOUS_EVENT: 'DATAELEMENT_PREVIOUS_EVENT', +}; + +const variableSourceTypesTrackedEntitySpecific = { + TEI_ATTRIBUTE: 'TEI_ATTRIBUTE', +}; + +export const variableSourceTypes = { + ...variableSourceTypesDataElementSpecific, + ...variableSourceTypesTrackedEntitySpecific, + CALCULATED_VALUE: 'CALCULATED_VALUE', +}; + +const programRuleVariableSourceIdExtractor = { + [variableSourceTypes.DATAELEMENT_CURRENT_EVENT]: variable => variable.dataElementId, + [variableSourceTypes.DATAELEMENT_NEWEST_EVENT_PROGRAM]: variable => variable.dataElementId, + [variableSourceTypes.DATAELEMENT_NEWEST_EVENT_PROGRAM_STAGE]: variable => variable.dataElementId, + [variableSourceTypes.DATAELEMENT_PREVIOUS_EVENT]: variable => variable.dataElementId, + [variableSourceTypes.TEI_ATTRIBUTE]: variable => variable.trackedEntityAttributeId, + [variableSourceTypes.CALCULATED_VALUE]: variable => '', // eslint-disable-line +}; + +const eventMainKeys = new Set([ + 'eventId', + 'programId', + 'programStageId', + 'programStageName', + 'orgUnitId', + 'trackedEntityInstanceId', + 'enrollmentId', + 'enrollmentStatus', + 'status', + 'occurredAt', + 'createdAt', + 'scheduledAt', + 'completedAt', +]); + +const ruleValueTypeMap = { + [typeKeys.BOOLEAN]: RuleValueType.BOOLEAN, + [typeKeys.TRUE_ONLY]: RuleValueType.BOOLEAN, + [typeKeys.DATE]: RuleValueType.DATE, + [typeKeys.DATETIME]: RuleValueType.DATE, + [typeKeys.AGE]: RuleValueType.DATE, + [typeKeys.INTEGER]: RuleValueType.NUMERIC, + [typeKeys.INTEGER_POSITIVE]: RuleValueType.NUMERIC, + [typeKeys.INTEGER_NEGATIVE]: RuleValueType.NUMERIC, + [typeKeys.INTEGER_ZERO_OR_POSITIVE]: RuleValueType.NUMERIC, + [typeKeys.NUMBER]: RuleValueType.NUMERIC, + [typeKeys.PERCENTAGE]: RuleValueType.NUMERIC, +}; + +const convertAssignAction = (action: ProgramRuleAction) => { + const { + data, + programRuleActionType: type, + dataElementId, + trackedEntityAttributeId, + content, + } = action; + + const actions = []; + + const pushAction = (values) => { + actions.push(new RuleActionJs(data, type, values)); + }; + + if (dataElementId) { + pushAction(new Map([ + ['field', dataElementId], + ['attributeType', attributeTypes.DATA_ELEMENT], + ])); + } + if (trackedEntityAttributeId) { + pushAction(new Map([ + ['field', trackedEntityAttributeId], + ['attributeType', attributeTypes.TRACKED_ENTITY_ATTRIBUTE], + ])); + } + if (content) { + pushAction(new Map([ + ['content', content], + ['attributeType', attributeTypes.UNKNOWN], + ])); + } + + return actions; +}; + +const convertProgramRuleAction = (action: ProgramRuleAction) => { + if (action.programRuleActionType === effectActions.ASSIGN_VALUE) { + return convertAssignAction(action); + } + + const { + data, + programRuleActionType: type, + ...rest + } = action; + + return new RuleActionJs( + data, + type, + new Map(Object.keys(rest).map(key => [key, rest[key]])), + ); +}; + +const convertProgramRule = (rule: ProgramRule) => { + const { + condition, + programRuleActions, + id: uid, + displayName: name, + programStageId: programStage, + priority, + } = rule; + + return new RuleJs( + condition, + programRuleActions.flatMap(convertProgramRuleAction), + uid, + name, + programStage, + priority, + ); +}; + +const convertConstants = (constants: Constants): Map => + constants.reduce((acc, constant) => { + acc.set(constant.displayName, constant.value); + return acc; + }, new Map); + +const convertOption = (option: RawOption) => new Option(option.displayName, option.code); + +const buildSupplementaryData = ({ + selectedOrgUnit, + selectedUserRoles, +}: { + selectedOrgUnit: OrgUnit, + selectedUserRoles: ?Array, +}) => { + const orgUnitId = selectedOrgUnit.id; + const supplementaryData = selectedOrgUnit.groups.reduce( + (acc, group) => { + if (group.code) { + acc.set(group.code, [orgUnitId]); + } + acc.set(group.id, [orgUnitId]); + return acc; + }, + new Map>(), + ); + + supplementaryData.set('USER', selectedUserRoles || []); + + return supplementaryData; +}; + +export class InputBuilder { + processValue: (value: any, type: $Values) => any; + dataElements: DataElements; + trackedEntityAttributes: TrackedEntityAttributes; + optionSets: OptionSets; + kotlinOptionSets: KotlinOptionSets; + selectedOrgUnit: OrgUnit; + constructor( + inputConverter: IConvertInputRulesValue, + dataElements: ?DataElements, + trackedEntityAttributes: ?TrackedEntityAttributes, + optionSets: OptionSets, + selectedOrgUnit: OrgUnit, + ) { + this.processValue = new ValueProcessor(inputConverter).processValue; + this.dataElements = dataElements || {}; + this.trackedEntityAttributes = trackedEntityAttributes || {}; + this.optionSets = optionSets; + this.kotlinOptionSets = {}; + this.selectedOrgUnit = selectedOrgUnit; + } + + toLocalDate = (dateString: ?string, defaultValue: any = null) => + (dateString ? LocalDate.parse(this.processValue(dateString, typeKeys.DATE)) : defaultValue); + + convertDataElementValue = (id: string, rawValue: any) => + this.convertDataValue(rawValue, this.dataElements[id]?.valueType); + + convertTrackedEntityAttributeValue = (id: string, rawValue: any) => + this.convertDataValue(rawValue, this.trackedEntityAttributes[id]?.valueType); + + convertDataValue = (rawValue: any, valueType: ?string) => + String(valueType ? this.processValue(rawValue, valueType) : rawValue); + + convertEvent = (eventData: EventData) => { + const { + eventId: event, + programStageId: programStage, + programStageName, + status, + occurredAt, + createdAt, + scheduledAt: dueDate, + completedAt: completedDate, + } = eventData; + + const eventDate = occurredAt ? Instant.parse(occurredAt) : null; + const createdDate = createdAt ? Instant.parse(createdAt) : Instant.now(); + const dataValues = Object + .keys(eventData) + .filter(key => !eventMainKeys.has(key)) + .filter(key => eventData[key] !== null) + .map(key => + new RuleDataValue( + key, + this.convertDataElementValue(key, eventData[key]), + )); + + return new RuleEventJs( + event || '', + programStage || '', + programStageName || '', + status ? RuleEventStatus[status] : RuleEventStatus.ACTIVE, + eventDate, + createdDate, + this.toLocalDate(dueDate), + this.toLocalDate(completedDate), + this.selectedOrgUnit.id, + this.selectedOrgUnit.code, + dataValues, + ); + }; + + convertOptionSet(optionSetId: ?string): KotlinOptionSet { + if (!optionSetId || !this.optionSets[optionSetId]) { + return []; + } + if (this.kotlinOptionSets[optionSetId]) { + return this.kotlinOptionSets[optionSetId]; + } + this.kotlinOptionSets[optionSetId] = this.optionSets[optionSetId].options.map(convertOption); + return this.kotlinOptionSets[optionSetId]; + } + + getOptionSet(field: string, type: string): KotlinOptionSet { + if (variableSourceTypesDataElementSpecific[type]) { + return this.convertOptionSet(this.dataElements[field]?.optionSetId); + } else if (variableSourceTypesTrackedEntitySpecific[type]) { + return this.convertOptionSet(this.trackedEntityAttributes[field]?.optionSetId); + } + return []; + } + + convertRuleVariable = (variable: ProgramRuleVariable) => { + const { + programRuleVariableSourceType, + displayName: name, + useNameForOptionSet, + valueType: fieldType, + programStageId: programStage, + } = variable; + + const type = programRuleVariableSourceIdExtractor[programRuleVariableSourceType] + ? programRuleVariableSourceType : 'CALCULATED_VALUE'; + + const field = programRuleVariableSourceIdExtractor[type](variable); + + return new RuleVariableJs( + RuleVariableType[type], + name, + !useNameForOptionSet, + this.getOptionSet(field, type), + field, + ruleValueTypeMap[fieldType] || RuleValueType.TEXT, + programStage, + ); + }; + + buildEnrollment = ({ + selectedEnrollment, + selectedEntity, + }: { + selectedEnrollment: Enrollment, + selectedEntity: ?TEIValues, + }) => { + const { + enrollmentId: enrollment, + enrolledAt: enrollmentDate, + occurredAt: incidentDate, + programName, + enrollmentStatus, + } = selectedEnrollment; + + const attributeValues = selectedEntity ? Object + .keys(selectedEntity) + .filter(key => selectedEntity[key] !== null) + .map(key => new RuleAttributeValue( + key, + this.convertTrackedEntityAttributeValue(key, selectedEntity[key]), + )) : null; + + const convertDate = (dateString: ?string) => this.toLocalDate(dateString, LocalDate.now()); + + return new RuleEnrollmentJs( + enrollment, + programName || '', + convertDate(incidentDate), + convertDate(enrollmentDate), + enrollmentStatus ? RuleEnrollmentStatus[enrollmentStatus] : RuleEnrollmentStatus.ACTIVE, + this.selectedOrgUnit.id, + this.selectedOrgUnit.code, + attributeValues, + ); + }; + + buildRuleEngineContext = ({ + programRulesContainer, + selectedUserRoles, + }: { + programRulesContainer: ProgramRulesContainer, + selectedUserRoles: ?Array, + }) => { + const { programRules, programRuleVariables, constants } = programRulesContainer; + + return new RuleEngineContextJs( + programRules && programRules.map(convertProgramRule), + programRuleVariables && programRuleVariables.map(this.convertRuleVariable), + buildSupplementaryData({ selectedOrgUnit: this.selectedOrgUnit, selectedUserRoles }), + constants && convertConstants(constants), + ); + }; +} diff --git a/src/core_modules/capture-core/rules/RuleEngine/helpers/ValueProcessor.js b/src/core_modules/capture-core/rules/RuleEngine/helpers/ValueProcessor.js new file mode 100644 index 0000000000..43c7b0854b --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/helpers/ValueProcessor.js @@ -0,0 +1,37 @@ +// @flow +import log from 'loglevel'; +import { mapTypeToInterfaceFnName, typeKeys } from '../constants'; +import type { IConvertInputRulesValue } from '../types/ruleEngine.types'; + +const errorCreator = (message: string) => (details?: ?Object) => ({ + ...details, + message, +}); + +export class ValueProcessor { + static errorMessages = { + TYPE_NOT_SUPPORTED: 'value type not supported', + CONVERTER_NOT_FOUND: 'converter for type is missing', + }; + + converterObject: IConvertInputRulesValue; + processValue: (value: any, type: $Values) => any = (value, type) => { + if (!typeKeys[type]) { + log.warn(ValueProcessor.errorMessages.TYPE_NOT_SUPPORTED); + return ''; + } + const convertFnName = mapTypeToInterfaceFnName[type]; + if (!convertFnName) { + log.warn(errorCreator(ValueProcessor.errorMessages.CONVERTER_NOT_FOUND)({ type })); + return value; + } + // $FlowFixMe + const convertedValue = this.converterObject[convertFnName] ? this.converterObject[convertFnName](value) : value; + return convertedValue ?? null; + }; + + constructor(converterObject: IConvertInputRulesValue) { + this.converterObject = converterObject; + this.processValue = this.processValue.bind(this); + } +} diff --git a/src/core_modules/capture-core/rules/RuleEngine/helpers/effectCreators.js b/src/core_modules/capture-core/rules/RuleEngine/helpers/effectCreators.js new file mode 100644 index 0000000000..c0158e19ad --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/helpers/effectCreators.js @@ -0,0 +1,109 @@ +// @flow +import { + effectActions, + rulesEngineEffectTargetDataTypes, +} from '../constants'; +import { sanitiseFalsy } from './sanitiseFalsy'; +import type { + ProgramRuleEffect, + MessageEffect, + GeneralErrorEffect, + GeneralWarningEffect, + ErrorEffects, + WarningEffects, + OutputEffect, + OutputEffects, +} from '../types/ruleEngine.types'; + + +// Effects with targetDataType + +const createDataElementEffect = ( + effect: ProgramRuleEffect, + type: $Values, +): ?OutputEffect => + (effect.dataElementId ? ({ + id: effect.dataElementId, + type, + targetDataType: rulesEngineEffectTargetDataTypes.DATA_ELEMENT, + }) : null); + +const createTrackedEntityAttributeEffect = ( + effect: ProgramRuleEffect, + type: $Values, +): ?OutputEffect => + (effect.trackedEntityAttributeId ? ({ + id: effect.trackedEntityAttributeId, + type, + targetDataType: rulesEngineEffectTargetDataTypes.TRACKED_ENTITY_ATTRIBUTE, + }) : null); + + +const effectForConfiguredDataTypeCreators = [ + createDataElementEffect, + createTrackedEntityAttributeEffect, +]; + +export const createEffectsForConfiguredDataTypes = ( + effect: ProgramRuleEffect, + type: $Values, +): OutputEffects => + effectForConfiguredDataTypeCreators + .map(creator => creator(effect, type)) + .filter(Boolean); + + +// Errors & Warnings + +const createGeneralWarningEffect = ( + id: string, + type: $Values, + message: string, +): Array => [{ + id: 'general', + type, + warning: { id, message }, +}]; + +const createGeneralErrorEffect = ( + id: string, + type: $Values, + message: string, +): Array => [{ + id: 'general', + type, + error: { id, message }, +}]; + +const createMessageEffects = ( + effects: OutputEffects, + message: string, +): Array => + effects.map(effect => ({ + id: effect.id, + type: effect.type, + targetDataType: effect.targetDataType, + message, + })); + +export const createWarningEffect = ( + effect: ProgramRuleEffect, + type: $Values, +): WarningEffects => { + const message = `${effect.displayContent || ''} ${sanitiseFalsy(effect.data)}`; + const result = createEffectsForConfiguredDataTypes(effect, type); + return (result.length !== 0) ? + createMessageEffects(result, message) : + createGeneralWarningEffect(effect.id, type, message); +}; + +export const createErrorEffect = ( + effect: ProgramRuleEffect, + type: $Values, +): ErrorEffects => { + const message = `${effect.displayContent || ''} ${sanitiseFalsy(effect.data)}`; + const result = createEffectsForConfiguredDataTypes(effect, type); + return (result.length !== 0) ? + createMessageEffects(result, message) : + createGeneralErrorEffect(effect.id, type, message); +}; diff --git a/src/core_modules/capture-core/rules/RuleEngine/helpers/index.js b/src/core_modules/capture-core/rules/RuleEngine/helpers/index.js new file mode 100644 index 0000000000..3d7d6b8c87 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/helpers/index.js @@ -0,0 +1,4 @@ +// @flow +export { InputBuilder } from './InputBuilder'; +export { ValueProcessor } from './ValueProcessor'; +export { getRulesEffectsProcessor } from './rulesEffectsProcessor'; diff --git a/src/core_modules/capture-core/rules/RuleEngine/helpers/normalizeRuleVariable.js b/src/core_modules/capture-core/rules/RuleEngine/helpers/normalizeRuleVariable.js new file mode 100644 index 0000000000..6c10602fc7 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/helpers/normalizeRuleVariable.js @@ -0,0 +1,50 @@ +// @flow +import log from 'loglevel'; +import isString from 'd2-utilizr/lib/isString'; +import { typeKeys } from '../constants'; + +const convertNumber = (numberRepresentation) => { + if (isString(numberRepresentation)) { + if (isNaN(numberRepresentation)) { + log.warn(`rule execution service could not convert ${numberRepresentation} to number`); + return null; + } + return Number(numberRepresentation); + } + return numberRepresentation; +}; + +const convertBoolean = (value) => { + if (isString(value)) { + return value === 'true'; + } + return value; +}; + +const convertString = (stringRepresentation: number | string): string => stringRepresentation.toString(); + +// Turns the internal representation of a program rule variable into its "canonical" format +// (e.g. numbers represented as strings get converted to numbers) +// Used to preprocess a computed value before assigning it to a calculated program rule variable +export const normalizeRuleVariable = (data: any, valueType: string) => { + const ruleEffectDataConvertersByType = { + [typeKeys.BOOLEAN]: convertBoolean, + [typeKeys.TRUE_ONLY]: convertBoolean, + [typeKeys.PERCENTAGE]: convertString, + [typeKeys.INTEGER]: convertNumber, + [typeKeys.INTEGER_NEGATIVE]: convertNumber, + [typeKeys.INTEGER_POSITIVE]: convertNumber, + [typeKeys.INTEGER_ZERO_OR_POSITIVE]: convertNumber, + [typeKeys.NUMBER]: convertNumber, + [typeKeys.AGE]: convertString, + [typeKeys.TEXT]: convertString, + [typeKeys.LONG_TEXT]: convertString, + [typeKeys.MULTI_TEXT]: convertString, + }; + + if (!data && data !== 0 && data !== false) { + return null; + } + + return ruleEffectDataConvertersByType[valueType] ? ruleEffectDataConvertersByType[valueType](data) : data; +}; diff --git a/src/core_modules/capture-core/rules/RuleEngine/helpers/previousValueCheck.js b/src/core_modules/capture-core/rules/RuleEngine/helpers/previousValueCheck.js new file mode 100644 index 0000000000..39f96bdf62 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/helpers/previousValueCheck.js @@ -0,0 +1,81 @@ +// @flow +import { rulesEngineEffectTargetDataTypes, typeKeys } from '../constants'; +import type { DataElements, TrackedEntityAttributes, HideOutputEffect } from '../types/ruleEngine.types'; + +const processDataElementValue = ({ + dataElementId, + dataElements, +}: { + dataElementId: ?string, + dataElements: ?DataElements, +}) => { + const dataElement = dataElementId && dataElements?.[dataElementId]; + if (dataElement) { + return { + name: dataElement.name, + valueType: dataElement.valueType, + }; + } + return null; +}; + +const processTEAValue = ({ + trackedEntityAttributeId, + trackedEntityAttributes, +}: { + trackedEntityAttributeId: ?string, + trackedEntityAttributes: ?TrackedEntityAttributes, +}) => { + const attribute = trackedEntityAttributeId && trackedEntityAttributes?.[trackedEntityAttributeId]; + if (attribute) { + return { + name: attribute.displayFormName || attribute.displayName, + valueType: attribute.valueType, + }; + } + return null; +}; + +const mapByTargetDataTypes = Object.freeze({ + [rulesEngineEffectTargetDataTypes.DATA_ELEMENT]: processDataElementValue, + [rulesEngineEffectTargetDataTypes.TRACKED_ENTITY_ATTRIBUTE]: processTEAValue, +}); + +export const getOutputEffectsWithPreviousValueCheck = ({ + outputEffects, + formValues, + dataElementId, + trackedEntityAttributeId, + dataElements, + trackedEntityAttributes, + onProcessValue, +}: { + outputEffects: Array, + dataElementId: ?string, + trackedEntityAttributeId: ?string, + dataElements: ?DataElements, + trackedEntityAttributes: ?TrackedEntityAttributes, + formValues?: ?{ [key: string]: any }, + onProcessValue: (value: any, type: $Values) => any, +}) => + outputEffects.reduce((acc, outputEffect) => { + if (formValues && Object.keys(formValues).length !== 0 && outputEffect.targetDataType) { + const formValue = formValues[outputEffect.id]; + const rawValue = mapByTargetDataTypes[outputEffect.targetDataType]({ + dataElementId, + trackedEntityAttributeId, + dataElements, + trackedEntityAttributes, + }); + if (rawValue) { + const { valueType, name } = rawValue; + const value = onProcessValue(formValue, valueType); + + if (value != null) { + return [...acc, { ...outputEffect, hadValue: true, name }]; + } + } + return [...acc, outputEffect]; + } + return [...acc, outputEffect]; + }, []); diff --git a/src/core_modules/capture-core/rules/RuleEngine/helpers/rulesEffectsProcessor.js b/src/core_modules/capture-core/rules/RuleEngine/helpers/rulesEffectsProcessor.js new file mode 100644 index 0000000000..379dfeb432 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/helpers/rulesEffectsProcessor.js @@ -0,0 +1,268 @@ +// @flow +import log from 'loglevel'; +import { + attributeTypes, + effectActions, + mapTypeToInterfaceFnName, + rulesEngineEffectTargetDataTypes, + typeKeys, +} from '../constants'; +import type { + ProgramRuleEffect, + DataElement, + DataElements, + TrackedEntityAttribute, + TrackedEntityAttributes, + IConvertOutputRulesEffectsValue, + AssignOutputEffect, + HideOutputEffect, + HideProgramStageEffect, + ErrorEffects, + WarningEffects, + CompulsoryEffect, + OutputEffects, +} from '../types/ruleEngine.types'; +import { normalizeRuleVariable } from './normalizeRuleVariable'; +import { sanitiseFalsy } from './sanitiseFalsy'; +import { getOutputEffectsWithPreviousValueCheck } from './previousValueCheck'; +import { + createEffectsForConfiguredDataTypes, + createWarningEffect, + createErrorEffect, +} from './effectCreators'; + +type BaseValueType = number | ?string | boolean; + +const errorCreator = (message: string) => (details?: ?Object) => ({ + ...details, + message, +}); + +const numberToString = (number: number): string => + (isNaN(number) || number === Infinity ? '' : String(number)); + +export function getRulesEffectsProcessor( + outputConverters: IConvertOutputRulesEffectsValue, +) { + function convertNormalizedValueToOutputValue(normalizedValue: BaseValueType, valueType: string) { + let outputValue; + if (normalizedValue || normalizedValue === 0 || normalizedValue === false) { + const converterName: string = mapTypeToInterfaceFnName[valueType]; + // $FlowExpectedError + const outputConverter = outputConverters[converterName]; + if (!converterName || !outputConverter) { + log.warn(errorCreator('converter for valueType is missing')({ valueType })); + return ''; + } + outputValue = outputConverter(normalizedValue); + } else { + outputValue = normalizedValue; + } + return outputValue; + } + + function createAssignValueEffect( + data: any, + element: DataElement | TrackedEntityAttribute, + targetDataType: $Values, + ): AssignOutputEffect { + const normalizedValue = normalizeRuleVariable(data, element.valueType); + const outputValue = convertNormalizedValueToOutputValue(normalizedValue, element.valueType); + + return { + type: effectActions.ASSIGN_VALUE, + id: element.id, + value: outputValue, + targetDataType, + }; + } + + function processAssignValue( + effect: ProgramRuleEffect, + dataElements: ?DataElements, + trackedEntityAttributes: ?TrackedEntityAttributes, + ): Array { + if (effect.attributeType === attributeTypes.DATA_ELEMENT) { + if (dataElements?.[effect.field]) { + return [createAssignValueEffect( + effect.data, + dataElements[effect.field], + rulesEngineEffectTargetDataTypes.DATA_ELEMENT, + )]; + } + } else if (effect.attributeType === attributeTypes.TRACKED_ENTITY_ATTRIBUTE) { + if (trackedEntityAttributes?.[effect.field]) { + return [createAssignValueEffect( + effect.data, + trackedEntityAttributes[effect.field], + rulesEngineEffectTargetDataTypes.TRACKED_ENTITY_ATTRIBUTE, + )]; + } + } + return []; + } + + function processHideField( + effect: ProgramRuleEffect, + dataElements: ?DataElements, + trackedEntityAttributes: ?TrackedEntityAttributes, + formValues?: ?{[key: string]: any}, + onProcessValue: (value: any, type: $Values) => any, + ): Array { + const outputEffects = createEffectsForConfiguredDataTypes( + effect, + effectActions.HIDE_FIELD, + ).map(outputEffect => ({ + ...outputEffect, + ...(effect.content ? { content: effect.content } : {}), + })); + return getOutputEffectsWithPreviousValueCheck({ + outputEffects, + formValues, + dataElementId: effect.dataElementId, + trackedEntityAttributeId: effect.trackedEntityAttributeId, + dataElements, + trackedEntityAttributes, + onProcessValue, + }); + } + + function processShowError(effect: ProgramRuleEffect): ErrorEffects { + return createErrorEffect(effect, effectActions.SHOW_ERROR); + } + + function processShowWarning(effect: ProgramRuleEffect): WarningEffects { + return createWarningEffect(effect, effectActions.SHOW_WARNING); + } + + function processShowErrorOnComplete(effect: ProgramRuleEffect): ErrorEffects { + return createErrorEffect(effect, effectActions.SHOW_ERROR_ONCOMPLETE); + } + + function processShowWarningOnComplete(effect: ProgramRuleEffect): WarningEffects { + return createWarningEffect(effect, effectActions.SHOW_WARNING_ONCOMPLETE); + } + + function processHideSection(effect: ProgramRuleEffect): ?HideOutputEffect { + if (!effect.programStageSectionId) { + return null; + } + + return { + type: effectActions.HIDE_SECTION, + id: effect.programStageSectionId, + }; + } + + function processHideProgramStage(effect: ProgramRuleEffect): ?HideProgramStageEffect { + if (!effect.programStageId) { + return null; + } + + return { + type: effectActions.HIDE_PROGRAM_STAGE, + id: effect.programStageId, + }; + } + + function processMakeCompulsory(effect: ProgramRuleEffect): Array { + return createEffectsForConfiguredDataTypes(effect, effectActions.MAKE_COMPULSORY); + } + + function processDisplayText(effect: ProgramRuleEffect): any { + const message = effect.displayContent || ''; + return { + type: effectActions.DISPLAY_TEXT, + id: effect.location, + displayText: { + id: effect.id, + message: `${message} ${sanitiseFalsy(effect.data)}`, + ...effect.style, + }, + }; + } + + function processDisplayKeyValuePair(effect: ProgramRuleEffect): any { + const data = effect.data !== undefined ? effect.data : ''; + + return { + type: effectActions.DISPLAY_KEY_VALUE_PAIR, + id: effect.location, + displayKeyValuePair: { + id: effect.id, + key: effect.displayContent, + value: typeof data === 'number' ? numberToString(data) : String(data), + ...effect.style, + }, + }; + } + + function processHideOptionGroup(effect: ProgramRuleEffect): any { + return createEffectsForConfiguredDataTypes(effect, effectActions.HIDE_OPTION_GROUP) + .map(outputEffect => ({ + ...outputEffect, + optionGroupId: effect.optionGroupId, + })); + } + + function processHideOption(effect: ProgramRuleEffect): any { + return createEffectsForConfiguredDataTypes(effect, effectActions.HIDE_OPTION) + .map(outputEffect => ({ + ...outputEffect, + optionId: effect.optionId, + })); + } + + function processShowOptionGroup(effect: ProgramRuleEffect): any { + return createEffectsForConfiguredDataTypes(effect, effectActions.SHOW_OPTION_GROUP) + .map(outputEffect => ({ + ...outputEffect, + optionGroupId: effect.optionGroupId, + })); + } + + const mapActionsToProcessor = { + [effectActions.ASSIGN_VALUE]: processAssignValue, + [effectActions.HIDE_FIELD]: processHideField, + [effectActions.SHOW_ERROR]: processShowError, + [effectActions.SHOW_WARNING]: processShowWarning, + [effectActions.SHOW_ERROR_ONCOMPLETE]: processShowErrorOnComplete, + [effectActions.SHOW_WARNING_ONCOMPLETE]: processShowWarningOnComplete, + [effectActions.HIDE_PROGRAM_STAGE]: processHideProgramStage, + [effectActions.HIDE_SECTION]: processHideSection, + [effectActions.MAKE_COMPULSORY]: processMakeCompulsory, + [effectActions.DISPLAY_TEXT]: processDisplayText, + [effectActions.DISPLAY_KEY_VALUE_PAIR]: processDisplayKeyValuePair, + [effectActions.HIDE_OPTION_GROUP]: processHideOptionGroup, + [effectActions.HIDE_OPTION]: processHideOption, + [effectActions.SHOW_OPTION_GROUP]: processShowOptionGroup, + }; + + function processRulesEffects({ + effects, + dataElements, + trackedEntityAttributes, + formValues, + onProcessValue, + }: { + effects: Array, + dataElements: ?DataElements, + trackedEntityAttributes: ?TrackedEntityAttributes, + formValues?: ?{ [key: string]: any }, + onProcessValue: (value: any, type: $Values) => any, + }): OutputEffects { + return effects + .filter(({ action }) => mapActionsToProcessor[action]) + .flatMap(effect => mapActionsToProcessor[effect.action]( + effect, + dataElements, + trackedEntityAttributes, + formValues, + onProcessValue, + )) + // when mapActionsToProcessor function returns `null` we filter those values out. + .filter(Boolean); + } + + return processRulesEffects; +} diff --git a/src/core_modules/capture-core/rules/RuleEngine/helpers/sanitiseFalsy.js b/src/core_modules/capture-core/rules/RuleEngine/helpers/sanitiseFalsy.js new file mode 100644 index 0000000000..26ee4be393 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/helpers/sanitiseFalsy.js @@ -0,0 +1,10 @@ +// @flow +export const sanitiseFalsy = (value: any) => { + if (value) { + return value; + } + if (value === 0) { + return 0; + } + return ''; +}; diff --git a/src/core_modules/capture-core/rules/RuleEngine/index.js b/src/core_modules/capture-core/rules/RuleEngine/index.js new file mode 100644 index 0000000000..ac1101009b --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/index.js @@ -0,0 +1,2 @@ +// @flow +export type * from './types/ruleEngine.types'; diff --git a/src/core_modules/capture-core/rules/RuleEngine/types/kotlinRuleEngine.types.js b/src/core_modules/capture-core/rules/RuleEngine/types/kotlinRuleEngine.types.js new file mode 100644 index 0000000000..8670fa0d7e --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/types/kotlinRuleEngine.types.js @@ -0,0 +1,8 @@ +// @flow +import { Option } from '@dhis2/rule-engine'; + +export type KotlinOptionSet = Array; + +export type KotlinOptionSets = {| + [id: string]: KotlinOptionSet, +|}; diff --git a/src/core_modules/capture-core/rules/RuleEngine/types/ruleEngine.types.js b/src/core_modules/capture-core/rules/RuleEngine/types/ruleEngine.types.js new file mode 100644 index 0000000000..8e0f0fc485 --- /dev/null +++ b/src/core_modules/capture-core/rules/RuleEngine/types/ruleEngine.types.js @@ -0,0 +1,333 @@ +// @flow +import { + typeof effectActions, + typeof eventStatuses, + typeof rulesEngineEffectTargetDataTypes, +} from '../constants'; + +export type ProgramRuleVariable = { + id: string, + displayName: string, + programRuleVariableSourceType: string, + valueType: string, + programId: string, + dataElementId?: ?string, + trackedEntityAttributeId?: ?string, + programStageId?: ?string, + useNameForOptionSet?: ?boolean, +}; + +type EventMain = { + +eventId?: string, + +programId?: string, + +programStageId?: string, + +programStageName?: string, + +orgUnitId?: string, + +trackedEntityInstanceId?: string, + +enrollmentId?: string, + +enrollmentStatus?: string, + +status?: $Values, + +occurredAt?: string, + +scheduledAt?: string, + +completedAt?: string, + +createdAt?: string, +}; + +export type EventValues = { + [elementId: string]: any, +}; + +export type EventData = EventValues & EventMain; + +export type EventsData = Array; + +export type EventsDataContainer = { + all: EventsData, + byStage: { [stageId: string]: EventsData }, +}; + +export type TEIValues = { + [attributeId: string]: any, +}; + +export type Enrollment = { + +enrolledAt?: string, + +occurredAt?: string, + +enrollmentId?: string, + +programName?: string, + +enrollmentStatus?: string, +}; + +export type Option = { + id: string, + code: string, + displayName: string, +}; + +export type OptionSet = { + id: string, + displayName: string, + options: Array