From 8fc2ac961f7bf160e500bd9fa64cc21153665d78 Mon Sep 17 00:00:00 2001 From: ismay Date: Thu, 26 Aug 2021 15:35:31 +0200 Subject: [PATCH] feat(program-rule-action): validate program rule action expression (#2046) * feat(program-rule-action): validate program rule action expression * fix(program-rule-action): remove unused import * fix(program-rule-action): show error on validation failure instead of throwing --- .../programRuleActionDialog.component.js | 557 ++++++++++++------ src/i18n/i18n_module_en.properties | 1 + 2 files changed, 392 insertions(+), 166 deletions(-) diff --git a/src/config/field-overrides/program-rules/programRuleActionDialog.component.js b/src/config/field-overrides/program-rules/programRuleActionDialog.component.js index 8e1e2a276..555e59ed8 100644 --- a/src/config/field-overrides/program-rules/programRuleActionDialog.component.js +++ b/src/config/field-overrides/program-rules/programRuleActionDialog.component.js @@ -1,3 +1,4 @@ +import { getInstance } from 'd2/lib/d2'; import React from 'react'; import FormBuilder from 'd2-ui/lib/forms/FormBuilder.component'; import Dialog from 'material-ui/Dialog'; @@ -11,41 +12,69 @@ import ProgramRuleConditionField from './programRuleConditionField.component'; import snackActions from '../../../Snackbar/snack.actions'; import RefreshMask from '../../../forms/form-fields/helpers/RefreshMask.component'; import sortBy from 'lodash/fp/sortBy'; +import { ExpressionStatus } from '../program-indicator/ExpressionStatusIcon'; -function toDisplay (element) { +function toDisplay(element) { return { value: element.id, text: element.displayName, - model: element - } + model: element, + }; } function shouldLoadOptions(ruleAction) { const actionTypesWithOptionSetFilter = ['HIDEOPTION']; - return ruleAction && ruleAction.programRuleActionType && actionTypesWithOptionSetFilter.includes(ruleAction.programRuleActionType); + return ( + ruleAction && + ruleAction.programRuleActionType && + actionTypesWithOptionSetFilter.includes( + ruleAction.programRuleActionType + ) + ); } function shouldLoadOptionGroups(ruleAction) { - const actionTypesWithOptionSetFilter = ['SHOWOPTIONGROUP', 'HIDEOPTIONGROUP']; - return ruleAction && ruleAction.programRuleActionType && actionTypesWithOptionSetFilter.includes(ruleAction.programRuleActionType); + const actionTypesWithOptionSetFilter = [ + 'SHOWOPTIONGROUP', + 'HIDEOPTIONGROUP', + ]; + return ( + ruleAction && + ruleAction.programRuleActionType && + actionTypesWithOptionSetFilter.includes( + ruleAction.programRuleActionType + ) + ); } //helper to know if dataElements/teas needs to be filtered on being related to an optionSet function shouldFilterOnOptionSet(ruleAction) { - const actionTypesWithOptionSetFilter = ['HIDEOPTION', 'SHOWOPTIONGROUP', 'HIDEOPTIONGROUP']; - return ruleAction && ruleAction.programRuleActionType && actionTypesWithOptionSetFilter.includes(ruleAction.programRuleActionType); + const actionTypesWithOptionSetFilter = [ + 'HIDEOPTION', + 'SHOWOPTIONGROUP', + 'HIDEOPTIONGROUP', + ]; + return ( + ruleAction && + ruleAction.programRuleActionType && + actionTypesWithOptionSetFilter.includes( + ruleAction.programRuleActionType + ) + ); } -const DropdownWithLoading = ({loading, ...props}) => { +const DropdownWithLoading = ({ loading, ...props }) => { //only pass display none const style = { - display: props.style.display === 'none' ? 'none' : undefined - } - return
- {loading && } - -
-} + display: props.style.display === 'none' ? 'none' : undefined, + }; + return ( +
+ {loading && } + +
+ ); +}; class ProgramRuleActionDialog extends React.Component { constructor(props, context) { @@ -54,19 +83,25 @@ class ProgramRuleActionDialog extends React.Component { this.state = { programRuleAction: this.props.ruleActionModel, loading: true, + status: null, }; this.d2 = context.d2; this.getTranslation = this.d2.i18n.getTranslation.bind(this.d2.i18n); this.save = this.save.bind(this); this.update = this.update.bind(this); + this.validate = this.validate.bind(this); } componentDidMount() { if (this.props.program && this.props.program.id) { const afterLoaders = [ - this.state.programRuleAction.option ? this.optionsDropdownGetter : null, - this.state.programRuleAction.optionGroup ? this.optionGroupDropdownGetter : null, + this.state.programRuleAction.option + ? this.optionsDropdownGetter + : null, + this.state.programRuleAction.optionGroup + ? this.optionGroupDropdownGetter + : null, ].filter(loader => loader); Promise.all([ this.d2.models.programs.get(this.props.program.id, { @@ -75,90 +110,122 @@ class ProgramRuleActionDialog extends React.Component { 'programStageSections[id,displayName]', 'notificationTemplates[id,displayName]', 'programStageDataElements[id,dataElement[id,displayName,optionSet]]]', - 'notificationTemplates[displayName,id]' + 'notificationTemplates[displayName,id]', ].join(','), }), this.d2.models.programs.get(this.props.program.id, { - fields: 'programTrackedEntityAttributes[id,trackedEntityAttribute[id,displayName,optionSet]]', + fields: + 'programTrackedEntityAttributes[id,trackedEntityAttribute[id,displayName,optionSet]]', }), this.d2.models.programRuleVariables.list({ filter: `program.id:eq:${this.props.program.id}`, fields: 'id,displayName,programRuleVariableSourceType', paging: false, }), - ]).then(([wrappedUpDataElements, wrappedUpTrackedEntityAttributes, programVariables]) => { - const programDataElements = - Object.values(wrappedUpDataElements.programStages.toArray() - .map(stage => stage.programStageDataElements.map(psde => psde.dataElement)) - .reduce((a, s) => a.concat(s), []) - .reduce((o, de) => { o[de.id] = de; return o; }, {}) - ) - .map(toDisplay) - .sort((a, b) => a.text.localeCompare(b.text)); - - const programSections = - wrappedUpDataElements.programStages.toArray() - .reduce((a, s) => a.concat(s.programStageSections.toArray()), []) - .map(toDisplay) - .sort((a, b) => a.text.localeCompare(b.text)); - - const programStages = - wrappedUpDataElements.programStages.toArray() - .map(toDisplay) - .sort((a, b) => a.text.localeCompare(b.text)); - - const programTrackedEntityAttributes = - wrappedUpTrackedEntityAttributes.programTrackedEntityAttributes - .map(ptea => ({ - text: ptea.trackedEntityAttribute.displayName, - value: ptea.trackedEntityAttribute.id, - model: ptea.trackedEntityAttribute, - })) - .sort((a, b) => a.text.localeCompare(b.text)); - - const programNotificationTemplates = wrappedUpDataElements - .notificationTemplates.toArray() - .map(toDisplay); - - const programStagesNotificationTemplates = wrappedUpDataElements - .programStages.toArray() - .reduce((a, b) => a.concat(b.notificationTemplates.toArray()), []) - .map(toDisplay); - - let notificationTemplates = programStagesNotificationTemplates - .concat(programNotificationTemplates) - .sort((a, b) => a.text.localeCompare(b.text)); - - const dedupe = function dedupe(arr) { - return [...new Set([].concat(...arr))]; - }; - - notificationTemplates = dedupe(notificationTemplates); - - this.setState({ - programStages, - programSections, - programDataElements, - programTrackedEntityAttributes, - programVariables: programVariables.toArray().map((v) => { - const sign = v.programRuleVariableSourceType === 'TEI_ATTRIBUTE' ? 'A' : '#'; - return { - text: `${sign}{${v.displayName}}`, - value: `${sign}{${v.displayName}}`, + ]) + .then( + ([ + wrappedUpDataElements, + wrappedUpTrackedEntityAttributes, + programVariables, + ]) => { + const programDataElements = Object.values( + wrappedUpDataElements.programStages + .toArray() + .map(stage => + stage.programStageDataElements.map( + psde => psde.dataElement + ) + ) + .reduce((a, s) => a.concat(s), []) + .reduce((o, de) => { + o[de.id] = de; + return o; + }, {}) + ) + .map(toDisplay) + .sort((a, b) => a.text.localeCompare(b.text)); + + const programSections = wrappedUpDataElements.programStages + .toArray() + .reduce( + (a, s) => + a.concat(s.programStageSections.toArray()), + [] + ) + .map(toDisplay) + .sort((a, b) => a.text.localeCompare(b.text)); + + const programStages = wrappedUpDataElements.programStages + .toArray() + .map(toDisplay) + .sort((a, b) => a.text.localeCompare(b.text)); + + const programTrackedEntityAttributes = wrappedUpTrackedEntityAttributes.programTrackedEntityAttributes + .map(ptea => ({ + text: ptea.trackedEntityAttribute.displayName, + value: ptea.trackedEntityAttribute.id, + model: ptea.trackedEntityAttribute, + })) + .sort((a, b) => a.text.localeCompare(b.text)); + + const programNotificationTemplates = wrappedUpDataElements.notificationTemplates + .toArray() + .map(toDisplay); + + const programStagesNotificationTemplates = wrappedUpDataElements.programStages + .toArray() + .reduce( + (a, b) => + a.concat(b.notificationTemplates.toArray()), + [] + ) + .map(toDisplay); + + let notificationTemplates = programStagesNotificationTemplates + .concat(programNotificationTemplates) + .sort((a, b) => a.text.localeCompare(b.text)); + + const dedupe = function dedupe(arr) { + return [...new Set([].concat(...arr))]; }; - }), - notificationTemplates, - options: [], - optionGroups: [], - loading: afterLoaders.length > 0 || false, - programRuleAction: this.state.programRuleAction, - }) - }) - .then(() => Promise.all(afterLoaders.map(func => func()))) - .catch((err) => { - this.props.onRequestClose(); - snackActions.show({ message: `Error: ${err}`, action: 'ok' }); - }); + + notificationTemplates = dedupe(notificationTemplates); + + this.setState({ + programStages, + programSections, + programDataElements, + programTrackedEntityAttributes, + programVariables: programVariables + .toArray() + .map(v => { + const sign = + v.programRuleVariableSourceType === + 'TEI_ATTRIBUTE' + ? 'A' + : '#'; + return { + text: `${sign}{${v.displayName}}`, + value: `${sign}{${v.displayName}}`, + }; + }), + notificationTemplates, + options: [], + optionGroups: [], + loading: afterLoaders.length > 0 || false, + programRuleAction: this.state.programRuleAction, + }); + } + ) + .then(() => Promise.all(afterLoaders.map(func => func()))) + .catch(err => { + this.props.onRequestClose(); + snackActions.show({ + message: `Error: ${err}`, + action: 'ok', + }); + }); } } @@ -183,16 +250,24 @@ class ProgramRuleActionDialog extends React.Component { option: this.state.options, optionGroup: this.state.optionGroups, }; - Object.keys(fieldRefs).forEach((field) => { + Object.keys(fieldRefs).forEach(field => { if (programRuleAction[field]) { - const ref = fieldRefs[field].find(v => v.value === programRuleAction[field]); - if(ref) { - if(field === 'templateUid') { + const ref = fieldRefs[field].find( + v => v.value === programRuleAction[field] + ); + if (ref) { + if (field === 'templateUid') { // just use the id, instead of object reference and update // notificationTemplate in the programRuleActionList - programRuleAction.notificationTemplate = { id: ref.value, displayName: ref.text }; + programRuleAction.notificationTemplate = { + id: ref.value, + displayName: ref.text, + }; } else { - programRuleAction[field] = { id: ref.value, displayName: ref.text }; + programRuleAction[field] = { + id: ref.value, + displayName: ref.text, + }; } } } else { @@ -203,31 +278,47 @@ class ProgramRuleActionDialog extends React.Component { if (programRuleAction.id) { // // TODO: Add support for modifying an existing member of a ModelCollectionProperty in d2 - this.props.parentModel.programRuleActions.set(programRuleAction.id, programRuleAction); + this.props.parentModel.programRuleActions.set( + programRuleAction.id, + programRuleAction + ); this.props.parentModel.programRuleActions.dirty = true; // this.props.onUpdateRuleActionModel(programRuleAction); - this.props.onChange({ target: { value: this.props.parentModel.programRuleActions } }); + this.props.onChange({ + target: { value: this.props.parentModel.programRuleActions }, + }); this.props.onRequestClose(); } else { const newUid = await this.d2.Api.getApi().get('/system/id'); - this.props.parentModel.programRuleActions.add(Object.assign(programRuleAction, { id: newUid.codes[0] })); + this.props.parentModel.programRuleActions.add( + Object.assign(programRuleAction, { id: newUid.codes[0] }) + ); this.props.onUpdateRuleActionModel(programRuleAction); - this.props.onChange({ target: { value: this.props.parentModel.programRuleActions } }); + this.props.onChange({ + target: { value: this.props.parentModel.programRuleActions }, + }); this.props.onRequestClose(); } } update(fieldName, value) { + if (fieldName === 'data') { + this.validate(value); + } + const ruleAction = this.state.programRuleAction; ruleAction[fieldName] = value; //Fetch options for dataElement and trackedEntityAttribute - if (shouldFilterOnOptionSet(ruleAction) && ['dataElement', 'trackedEntityAttribute'].includes(fieldName)) { - if(shouldLoadOptions(ruleAction)) { + if ( + shouldFilterOnOptionSet(ruleAction) && + ['dataElement', 'trackedEntityAttribute'].includes(fieldName) + ) { + if (shouldLoadOptions(ruleAction)) { this.optionsDropdownGetter(); } - if(shouldLoadOptionGroups(ruleAction)) { + if (shouldLoadOptionGroups(ruleAction)) { this.optionGroupDropdownGetter(); } } @@ -237,36 +328,101 @@ class ProgramRuleActionDialog extends React.Component { }); } + async validate(expression) { + const api = this.d2.Api.getApi(); + if (!this.props.program || !this.props.program.id) { + return; + } + + const pendingStatus = { + status: ExpressionStatus.PENDING, + message: this.getTranslation('checking_expression_status'), + }; + this.setState(prevState => ({ + ...prevState, + status: pendingStatus, + })); + + const programId = this.props.program.id; + const url = `programRuleActions/data/expression/description?programId=${programId}`; + const requestOptions = { + headers: { + 'Content-Type': 'text/plain', + }, + }; + + try { + const { status, description, message } = await api.post( + url, + `"${expression}"`, + requestOptions + ); + const isOk = status === 'OK'; + + const newStatus = { + status: isOk + ? ExpressionStatus.VALID + : ExpressionStatus.INVALID, + message: isOk ? description : message, + details: description, + }; + + this.setState(prevState => ({ + ...prevState, + status: newStatus, + })); + } catch (error) { + const fallback = this.getTranslation('program_rule_action_fallback_error_message'); + const message = error.message || fallback; + const newStatus = { + status: ExpressionStatus.INVALID, + message, + }; + + this.setState(prevState => ({ + ...prevState, + status: newStatus, + })); + } + } getRelatedOptionSetFromSelected = () => { const ruleAction = this.state.programRuleAction; const selectedDEId = ruleAction.dataElement; const seleactedTeaId = ruleAction.trackedEntityAttribute; - - if ((!selectedDEId && !seleactedTeaId) || ((selectedDEId && !selectedDEId.model) && (seleactedTeaId && !seleactedTeaId.model))) { + + if ( + (!selectedDEId && !seleactedTeaId) || + (selectedDEId && + !selectedDEId.model && + (seleactedTeaId && !seleactedTeaId.model)) + ) { return null; } let relatedOptionSet; //Get the optionSet that is related to either dataElement or trackedEntityAttribute if (ruleAction.dataElement) { const relatedToOptionSetID = ruleAction.dataElement; - const dataElement = this.state.programDataElements.find(d => d.model.id === relatedToOptionSetID); + const dataElement = this.state.programDataElements.find( + d => d.model.id === relatedToOptionSetID + ); relatedOptionSet = dataElement.model.optionSet; } else if (ruleAction.trackedEntityAttribute) { const relatedToOptionSetID = ruleAction.trackedEntityAttribute; - const teaObj = this.state.programTrackedEntityAttributes.find(d => d.model.id === relatedToOptionSetID); + const teaObj = this.state.programTrackedEntityAttributes.find( + d => d.model.id === relatedToOptionSetID + ); relatedOptionSet = teaObj.model.optionSet; } return relatedOptionSet; - } + }; optionsDropdownGetter = async () => { - let relatedOptionSet = this.getRelatedOptionSetFromSelected(); - if(!relatedOptionSet) return null; + if (!relatedOptionSet) return null; //load options related to optionSet - this.setState({loading: true}) + this.setState({ loading: true }); const options = await this.d2.models.options.list({ fields: 'id,displayName', filter: `optionSet.id:eq:${relatedOptionSet.id}`, @@ -275,13 +431,13 @@ class ProgramRuleActionDialog extends React.Component { const withDisplay = options.toArray().map(toDisplay); this.setState({ options: withDisplay, loading: false }); return withDisplay; - } + }; optionGroupDropdownGetter = async () => { let relatedOptionSet = this.getRelatedOptionSetFromSelected(); - if(!relatedOptionSet) return null; + if (!relatedOptionSet) return null; //load optiongroups related to optionSet - this.setState({loading: true}) + this.setState({ loading: true }); const optionGroups = await this.d2.models.optionGroup.list({ fields: 'id,displayName,options[id,optionSet]', filter: `options.optionSet.id:eq:${relatedOptionSet.id}`, @@ -290,15 +446,16 @@ class ProgramRuleActionDialog extends React.Component { const withDisplay = optionGroups.toArray().map(toDisplay); this.setState({ optionGroups: withDisplay, loading: false }); return withDisplay; - } + }; getFilteredByOptionSetOrAll(models) { - if(!models) return []; + if (!models) return []; const ruleAction = this.state.programRuleAction; if (shouldFilterOnOptionSet(ruleAction)) { - const filtered = models.filter(elem => - elem.model && elem.model.optionSet); - return filtered; + const filtered = models.filter( + elem => elem.model && elem.model.optionSet + ); + return filtered; } return models; } @@ -316,9 +473,24 @@ class ProgramRuleActionDialog extends React.Component { props: { labelText: `${this.getTranslation('action')} (*)`, fullWidth: true, - options: sortBy('text', modelDefinition.modelProperties.programRuleActionType.constants - .filter(o => programRuleActionTypes[o] && (o !== 'CREATEEVENT' || (ruleActionModel.id !== undefined && ruleActionModel.programRuleActionType === 'CREATEEVENT'))) - .map(o => ({ text: this.getTranslation(programRuleActionTypes[o].label), value: o }))), + options: sortBy( + 'text', + modelDefinition.modelProperties.programRuleActionType.constants + .filter( + o => + programRuleActionTypes[o] && + (o !== 'CREATEEVENT' || + (ruleActionModel.id !== undefined && + ruleActionModel.programRuleActionType === + 'CREATEEVENT')) + ) + .map(o => ({ + text: this.getTranslation( + programRuleActionTypes[o].label + ), + value: o, + })) + ), value: ruleActionModel.programRuleActionType, isRequired: true, }, @@ -330,8 +502,16 @@ class ProgramRuleActionDialog extends React.Component { labelText: this.getTranslation('display_widget'), value: ruleActionModel.location, options: [ - { text: this.getTranslation('feedback_widget'), value: 'feedback' }, - { text: this.getTranslation('program_indicator_widget'), value: 'indicators' }, + { + text: this.getTranslation('feedback_widget'), + value: 'feedback', + }, + { + text: this.getTranslation( + 'program_indicator_widget' + ), + value: 'indicators', + }, ], fullWidth: true, }, @@ -341,10 +521,17 @@ class ProgramRuleActionDialog extends React.Component { component: DropDown, props: { labelText: this.getTranslation('data_element'), - options: this.state && this.getFilteredByOptionSetOrAll(this.state.programDataElements) || [], + options: + (this.state && + this.getFilteredByOptionSetOrAll( + this.state.programDataElements + )) || + [], value: ruleActionModel.dataElement, fullWidth: true, - disabled: shouldFilterOnOptionSet(ruleActionModel) && !!ruleActionModel.trackedEntityAttribute, + disabled: + shouldFilterOnOptionSet(ruleActionModel) && + !!ruleActionModel.trackedEntityAttribute, }, }, { @@ -352,11 +539,22 @@ class ProgramRuleActionDialog extends React.Component { component: DropDown, props: { labelText: this.getTranslation('tracked_entity_attribute'), - options: this.state && this.getFilteredByOptionSetOrAll(this.state.programTrackedEntityAttributes) || [], + options: + (this.state && + this.getFilteredByOptionSetOrAll( + this.state.programTrackedEntityAttributes + )) || + [], value: ruleActionModel.trackedEntityAttribute, - style: { display: ruleActionModel.trackedEntityAttribute ? 'inline-block' : 'none' }, + style: { + display: ruleActionModel.trackedEntityAttribute + ? 'inline-block' + : 'none', + }, fullWidth: true, - disabled: shouldFilterOnOptionSet(ruleActionModel) && !!ruleActionModel.dataElement, + disabled: + shouldFilterOnOptionSet(ruleActionModel) && + !!ruleActionModel.dataElement, }, }, { @@ -364,7 +562,7 @@ class ProgramRuleActionDialog extends React.Component { component: DropDown, props: { labelText: this.getTranslation('program_stage'), - options: this.state && this.state.programStages || [], + options: (this.state && this.state.programStages) || [], value: ruleActionModel.programStage, fullWidth: true, }, @@ -374,22 +572,26 @@ class ProgramRuleActionDialog extends React.Component { component: DropDown, props: { labelText: this.getTranslation('program_stage_section'), - options: this.state && this.state.programSections || [], + options: (this.state && this.state.programSections) || [], value: ruleActionModel.programStageSection, - disabled: !this.state.programSections || this.state.programSections.length === 0, + disabled: + !this.state.programSections || + this.state.programSections.length === 0, fullWidth: true, }, }, { name: 'content', - component: currentActionType === 'ASSIGN' ? - DropDown : TextField, + component: + currentActionType === 'ASSIGN' ? DropDown : TextField, props: { labelText: this.getTranslation('content'), value: ruleActionModel.content, fullWidth: true, - options: currentActionType === 'ASSIGN' ? - this.state.programVariables : undefined, + options: + currentActionType === 'ASSIGN' + ? this.state.programVariables + : undefined, }, }, { @@ -401,16 +603,22 @@ class ProgramRuleActionDialog extends React.Component { fullWidth: true, hideOperators: true, quickAddLink: false, + status: this.state.status, }, }, { name: 'templateUid', component: DropDown, props: { - labelText: this.getTranslation('program_notification_template'), - options: this.state && this.state.notificationTemplates || [], + labelText: this.getTranslation( + 'program_notification_template' + ), + options: + (this.state && this.state.notificationTemplates) || [], value: ruleActionModel.templateUid, - disabled: !this.state.notificationTemplates || this.state.notificationTemplates === 0, + disabled: + !this.state.notificationTemplates || + this.state.notificationTemplates === 0, fullWidth: true, }, }, @@ -438,32 +646,44 @@ class ProgramRuleActionDialog extends React.Component { loading: this.state.loading, }, }, - ].map((field) => { - if (field.name !== 'programRuleActionType') { - const isRequired = fieldMapping && fieldMapping.required && - fieldMapping.required.includes(field.name); - - const isOptional = fieldMapping && fieldMapping.optional && - fieldMapping.optional.includes(field.name); - - if (isOptional || isRequired) { - field.props.style = { display: 'inline-block' }; - if (isRequired) { - field.props.isRequired = true; - field.props.labelText += ' (*)'; + ] + .map(field => { + if (field.name !== 'programRuleActionType') { + const isRequired = + fieldMapping && + fieldMapping.required && + fieldMapping.required.includes(field.name); + + const isOptional = + fieldMapping && + fieldMapping.optional && + fieldMapping.optional.includes(field.name); + + if (isOptional || isRequired) { + field.props.style = { display: 'inline-block' }; + if (isRequired) { + field.props.isRequired = true; + field.props.labelText += ' (*)'; + } + } else { + field.props.style = { display: 'none' }; } - } else { - field.props.style = { display: 'none' }; } - } - return field; - }).map((field) => { - if (fieldMapping && fieldMapping.labelOverrides && fieldMapping.labelOverrides[field.name]) { - field.props.labelText = this.getTranslation(fieldMapping.labelOverrides[field.name]); - } + return field; + }) + .map(field => { + if ( + fieldMapping && + fieldMapping.labelOverrides && + fieldMapping.labelOverrides[field.name] + ) { + field.props.labelText = this.getTranslation( + fieldMapping.labelOverrides[field.name] + ); + } - return field; - }); + return field; + }); return ( {this.state.programDataElements ? ( - - ) :
Loading...
} + + ) : ( +
Loading...
+ )}
); } diff --git a/src/i18n/i18n_module_en.properties b/src/i18n/i18n_module_en.properties index cdc5fb458..f9f5fe78d 100644 --- a/src/i18n/i18n_module_en.properties +++ b/src/i18n/i18n_module_en.properties @@ -2271,3 +2271,4 @@ org_unit_image_storage_status_error=Image could not be stored, storageStatus is: org_unit_image_save_reminder=Submit the form to save changes org_unit_image_remove_image=Remove image org_unit_image_reset=Reset changes +program_rule_action_fallback_error_message=Something went wrong whilst validating the expression