diff --git a/i18n/en.pot b/i18n/en.pot
index b585a146..b4710bf9 100644
--- a/i18n/en.pot
+++ b/i18n/en.pot
@@ -5,8 +5,8 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
-"POT-Creation-Date: 2023-10-03T11:54:31.751Z\n"
-"PO-Revision-Date: 2023-10-03T11:54:31.751Z\n"
+"POT-Creation-Date: 2023-10-06T16:23:08.006Z\n"
+"PO-Revision-Date: 2023-10-06T16:23:08.006Z\n"
msgid ""
"The initial configuration of the app has been completed and it is now ready "
@@ -862,6 +862,15 @@ msgstr ""
"apply to all programs a mobile user has access to. Settings specific to "
"individual programs can also be applied."
+msgid "TEI Header"
+msgstr "TEI Header"
+
+msgid "Advanced options"
+msgstr "Advanced options"
+
+msgid "Filter"
+msgstr "Filter"
+
msgid "Enrollment Date"
msgstr "Enrollment Date"
@@ -877,6 +886,12 @@ msgstr "Event status"
msgid "Follow up"
msgstr "Follow up"
+msgid "There are no Program Indicators with valid expressions for Android"
+msgstr "There are no Program Indicators with valid expressions for Android"
+
+msgid "Disable elements do not meet Android criteria"
+msgstr "Disable elements do not meet Android criteria"
+
msgid "Enrollment Status"
msgstr "Enrollment Status"
diff --git a/src/components/field/Section.js b/src/components/field/Section.js
new file mode 100644
index 00000000..7bd5fff0
--- /dev/null
+++ b/src/components/field/Section.js
@@ -0,0 +1,20 @@
+import { FieldSet, Legend } from '@dhis2/ui'
+import PropTypes from 'prop-types'
+import React from 'react'
+import styles from './Section.module.css'
+
+export const Section = ({ legend, children }) => (
+
+
+
+ {legend}
+
+ {children}
+
+
+)
+
+Section.propTypes = {
+ legend: PropTypes.string,
+ children: PropTypes.element.isRequired,
+}
diff --git a/src/components/field/Section.module.css b/src/components/field/Section.module.css
new file mode 100644
index 00000000..d595dbb1
--- /dev/null
+++ b/src/components/field/Section.module.css
@@ -0,0 +1,16 @@
+.legend {
+ display: inline-block;
+ padding-bottom: var(--spacers-dp16);
+ font-size: 15px;
+ color: var(--colors-grey900);
+ font-weight: 500;
+ letter-spacing: 0.2px;
+}
+
+.container {
+ padding: var(--spacers-dp16) 0;
+}
+
+.container:not(:last-child) {
+ margin-bottom: var(--spacers-dp8);
+}
diff --git a/src/components/field/index.js b/src/components/field/index.js
index e408d626..289070c1 100644
--- a/src/components/field/index.js
+++ b/src/components/field/index.js
@@ -19,6 +19,7 @@ export * from './OptionalTEISearch'
export * from './PhoneNumberField'
export * from './ProgramCompletionSpinner'
export * from './ReservedValues'
+export * from './Section'
export * from './SelectField'
export * from './ShareScreen'
export * from './SmsGateway'
diff --git a/src/pages/Appearance/Programs/NewProgramSpecific.js b/src/pages/Appearance/Programs/NewProgramSpecific.js
index 2614cb3a..a55d9060 100644
--- a/src/pages/Appearance/Programs/NewProgramSpecific.js
+++ b/src/pages/Appearance/Programs/NewProgramSpecific.js
@@ -1,4 +1,5 @@
import i18n from '@dhis2/d2-i18n'
+import isNil from 'lodash/isNil'
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { AddNewSetting } from '../../../components/field'
@@ -49,7 +50,7 @@ const NewProgramSpecific = ({
}
const handleChange = (e, key) => {
- if (typeof key === 'string') {
+ if (key === 'id') {
const states = programHasCategoryCombo(e.selected, programList)
? createInitialSpecificValues('')
: createInitialValues('')
@@ -67,9 +68,13 @@ const NewProgramSpecific = ({
setTrackerProgram(isTrackerProgram(e.selected, programList))
setDisableSave(false)
} else {
- if (isProgramConfiguration(e.name)) {
+ if (isProgramConfiguration(e.name || key)) {
+ const spinnerSettings = !isNil(e.name)
+ ? { ...spinner, [e.name]: e.checked }
+ : { ...spinner, [key]: e.selected }
+
setSpinner({
- ...spinner,
+ ...spinnerSettings,
[e.name]: e.checked,
id: specificSettings.id,
name: specificSettings.name,
diff --git a/src/pages/Appearance/Programs/ProgramQueries.js b/src/pages/Appearance/Programs/ProgramQueries.js
new file mode 100644
index 00000000..664a780a
--- /dev/null
+++ b/src/pages/Appearance/Programs/ProgramQueries.js
@@ -0,0 +1,28 @@
+import { useDataQuery } from '@dhis2/app-runtime'
+
+/**
+ * Query to get program list with attributes and indicators
+ * */
+
+const programQuery = {
+ programs: {
+ resource: 'programs',
+ params: {
+ fields: [
+ 'id',
+ 'name',
+ 'programTrackedEntityAttributes[id,trackedEntityAttribute[id,name,valueType]]',
+ 'programIndicators[id,name,expression]',
+ ],
+ paging: 'false',
+ },
+ },
+}
+
+export const useGetPrograms = () => {
+ const { data } = useDataQuery(programQuery)
+
+ return {
+ programs: data && data.programs?.programs,
+ }
+}
diff --git a/src/pages/Appearance/Programs/ProgramsAppearance.js b/src/pages/Appearance/Programs/ProgramsAppearance.js
index cd7ce004..3b586eeb 100644
--- a/src/pages/Appearance/Programs/ProgramsAppearance.js
+++ b/src/pages/Appearance/Programs/ProgramsAppearance.js
@@ -16,6 +16,7 @@ import {
createSpecificValues,
prepareSettingsSaveDataStore,
prepareSpinnerPreviousSpinner,
+ prepareSpinnerSettingsDataStore,
removeAttributes,
} from './helper'
import ProgramGlobalSettings from './ProgramGlobalSettings'
@@ -86,7 +87,7 @@ const ProgramsAppearance = () => {
...spinnerGlobal,
},
specificSettings: {
- ...prepareSettingsSaveDataStore(spinnerSpecific),
+ ...prepareSpinnerSettingsDataStore(spinnerSpecific),
},
},
completionSpinner: {
diff --git a/src/pages/Appearance/Programs/SpecificSettings.js b/src/pages/Appearance/Programs/SpecificSettings.js
index c24b86f5..0575f259 100644
--- a/src/pages/Appearance/Programs/SpecificSettings.js
+++ b/src/pages/Appearance/Programs/SpecificSettings.js
@@ -6,10 +6,12 @@ import {
HideFormSections,
OptionalTEISearch,
ProgramCompletionSpinner,
+ Section,
} from '../../../components/field'
import { TableHeader } from '../../../components/table'
import Wrapper from '../../../components/Wrapper'
import { TableSettings } from './TableSettings'
+import { TeiHeader } from './TeiHeader'
const SpecificSettings = ({
hasCategoryCombo,
@@ -21,37 +23,50 @@ const SpecificSettings = ({
<>
-
+
-
-
>
diff --git a/src/pages/Appearance/Programs/SpecificTableAction.js b/src/pages/Appearance/Programs/SpecificTableAction.js
index dfa92058..5ab9ae37 100644
--- a/src/pages/Appearance/Programs/SpecificTableAction.js
+++ b/src/pages/Appearance/Programs/SpecificTableAction.js
@@ -1,4 +1,5 @@
import i18n from '@dhis2/d2-i18n'
+import isNil from 'lodash/isNil'
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import DialogDelete from '../../../components/dialog/DialogDelete'
@@ -67,20 +68,25 @@ const SpecificTableAction = ({
handleCloseEdit()
}
- const handleChange = (e) => {
- isProgramConfiguration(e.name)
- ? setSpinner({
- ...spinner,
- [e.name]: e.checked,
- id: specificSetting.id,
- })
- : setSpecificSetting({
- ...specificSetting,
- [e.name]: {
- filter: e.checked,
- sort: e.checked,
- },
- })
+ const handleChange = (e, key) => {
+ if (isProgramConfiguration(e.name || key)) {
+ const spinnerSettings = !isNil(e.name)
+ ? { ...spinner, [e.name]: e.checked }
+ : { ...spinner, [key]: e.selected }
+
+ setSpinner({
+ ...spinnerSettings,
+ id: specificSetting.id,
+ })
+ } else {
+ setSpecificSetting({
+ ...specificSetting,
+ [e.name]: {
+ filter: e.checked,
+ sort: e.checked,
+ },
+ })
+ }
}
return (
diff --git a/src/pages/Appearance/Programs/TeiHeader.js b/src/pages/Appearance/Programs/TeiHeader.js
new file mode 100644
index 00000000..e28142ea
--- /dev/null
+++ b/src/pages/Appearance/Programs/TeiHeader.js
@@ -0,0 +1,112 @@
+import i18n from '@dhis2/d2-i18n'
+import {
+ SingleSelectField,
+ SingleSelectOption,
+ spacers,
+ Tooltip,
+ IconInfo16,
+ InputField,
+ NoticeBox,
+} from '@dhis2/ui'
+import isEmpty from 'lodash/isEmpty'
+import PropTypes from 'prop-types'
+import React, { useEffect, useState } from 'react'
+import { FieldSection } from '../../../components/field'
+import {
+ getAttributes,
+ getExpressionDescription,
+ validateAndroidExpressions,
+} from './expressionHelper'
+import { useGetPrograms } from './ProgramQueries'
+
+const CODE = 'programIndicator'
+
+export const TeiHeader = ({ settings, program, handleChange }) => {
+ const { programs } = useGetPrograms()
+ const [options, setOptions] = useState([])
+ const [attributes, setAttributes] = useState([])
+ const [expression, setExpression] = useState('')
+
+ useEffect(() => {
+ if (program && programs) {
+ const selectedProgram = programs.find((p) => p.id === program)
+ setOptions(
+ validateAndroidExpressions(selectedProgram?.programIndicators)
+ )
+ setAttributes(getAttributes(selectedProgram))
+ }
+ }, [program, programs])
+
+ useEffect(() => {
+ if (options) {
+ const indicator = options.find(
+ (i) => i.id === settings[CODE]
+ )?.expression
+
+ setExpression(getExpressionDescription(indicator, attributes))
+ }
+ }, [settings[CODE], options])
+
+ if (isEmpty(options)) {
+ return (
+
+ {i18n.t(
+ 'There are no Program Indicators with valid expressions for Android'
+ )}
+
+ )
+ }
+
+ return (
+ <>
+
+ <>
+ handleChange(e, CODE)}
+ >
+ {options.map((option) => (
+
+ ))}
+
+
+
+
+
+
+ >
+
+
+ {settings[CODE] && (
+
+
+
+ )}
+ >
+ )
+}
+
+TeiHeader.propTypes = {
+ settings: PropTypes.object,
+ handleChange: PropTypes.func,
+ program: PropTypes.string,
+}
diff --git a/src/pages/Appearance/Programs/__tests__/getExpressionDescription.test.js b/src/pages/Appearance/Programs/__tests__/getExpressionDescription.test.js
new file mode 100644
index 00000000..19353cc5
--- /dev/null
+++ b/src/pages/Appearance/Programs/__tests__/getExpressionDescription.test.js
@@ -0,0 +1,73 @@
+import { getExpressionDescription } from '../expressionHelper'
+
+const attributes = [
+ {
+ id: 'w75KJ2mc4zz',
+ name: 'First name',
+ valueType: 'TEXT',
+ },
+ {
+ id: 'zDhUuAYrxNC',
+ name: 'Last name',
+ valueType: 'TEXT',
+ },
+ {
+ id: 'cejWyOfXge6',
+ name: 'Gender',
+ valueType: 'TEXT',
+ },
+ {
+ id: 'lZGmxYbs97q',
+ name: 'Unique ID',
+ valueType: 'TEXT',
+ },
+ {
+ id: 'iESIqZ0R0R0',
+ name: 'Date of birth',
+ valueType: 'DATE',
+ },
+]
+
+test('From a list of attributes you can get this expression', () => {
+ const expression =
+ "A{w75KJ2mc4zz} A{zDhUuAYrxNC}, if(A{cejWyOfXge6} == 'Male', 'M', 'F')"
+ const resultExpression =
+ "First name Last name, if(Gender == 'Male', 'M', 'F')"
+ expect(getExpressionDescription(expression, attributes)).toMatch(
+ resultExpression
+ )
+})
+
+test('From a list of attributes you can get this expression', () => {
+ const expression = 'A{w75KJ2mc4zz} + A{zDhUuAYrxNC}'
+ const resultExpression = 'First name + Last name'
+ expect(getExpressionDescription(expression, attributes)).toMatch(
+ resultExpression
+ )
+})
+
+test('From a list of attributes you can get this expression', () => {
+ const expression = 'A{w75KJ2mc4zz} + A{cejWyOfXge6}'
+ const resultExpression = 'First name + Gender'
+ expect(getExpressionDescription(expression, attributes)).toMatch(
+ resultExpression
+ )
+})
+
+test('From a list of attributes you can get this expression', () => {
+ const expression = "d2:concatenate(A{w75KJ2mc4zz}, ' ', A{zDhUuAYrxNC})"
+ const resultExpression = "d2:concatenate(First name, ' ', Last name)"
+ expect(getExpressionDescription(expression, attributes)).toMatch(
+ resultExpression
+ )
+})
+
+test('From a list of attributes you can get this expression', () => {
+ const expression =
+ "d2:concatenate(A{w75KJ2mc4zz}, ' ', A{zDhUuAYrxNC}, ', ', A{iESIqZ0R0R0})"
+ const resultExpression =
+ "d2:concatenate(First name, ' ', Last name, ', ', Date of birth)"
+ expect(getExpressionDescription(expression, attributes)).toMatch(
+ resultExpression
+ )
+})
diff --git a/src/pages/Appearance/Programs/expressionHelper.js b/src/pages/Appearance/Programs/expressionHelper.js
new file mode 100644
index 00000000..9ad2bf2a
--- /dev/null
+++ b/src/pages/Appearance/Programs/expressionHelper.js
@@ -0,0 +1,40 @@
+import every from 'lodash/every'
+import orderBy from 'lodash/orderBy'
+import { isValidAndroidExpression } from '../../../utils/validators'
+
+/**
+ * Show an ordered list of program indicators
+ * If all elements are invalid return an empty array
+ * */
+export const validateAndroidExpressions = (programIndicators) => {
+ programIndicators.map((element) => {
+ isValidAndroidExpression(element.expression)
+ ? (element.valid = true)
+ : (element.valid = false)
+ })
+
+ if (every(programIndicators, ['valid', false])) {
+ return []
+ }
+
+ return orderBy(programIndicators, [(item) => item.valid === true], ['desc'])
+}
+
+export const getAttributes = (program) =>
+ program.programTrackedEntityAttributes.map(
+ (tea) => tea.trackedEntityAttribute
+ )
+
+/**
+ * Replaces substrings matching the "attribute" pattern ("A{...}")
+ * with the corresponding values from the given array of trackedEntityAttributes
+ * */
+export const getExpressionDescription = (expression, attributes) => {
+ const regex = /A{([A-Za-z0-9]{11,13})}/g
+
+ // Replace each match with the corresponding value from the array
+ return expression?.replace(regex, (match, p1) => {
+ const replacementObj = attributes.find((obj) => obj.id === p1)
+ return replacementObj ? replacementObj.name : match
+ })
+}
diff --git a/src/pages/Appearance/Programs/helper.js b/src/pages/Appearance/Programs/helper.js
index 1259cceb..441b56a8 100644
--- a/src/pages/Appearance/Programs/helper.js
+++ b/src/pages/Appearance/Programs/helper.js
@@ -32,6 +32,7 @@ export const createInitialSpinnerValue = (prevDetails) => {
optionalSearch: false,
disableReferrals: false,
disableCollapsibleSections: false,
+ programIndicator: '',
})
return {
@@ -39,6 +40,9 @@ export const createInitialSpinnerValue = (prevDetails) => {
optionalSearch: prevDetails.optionalSearch,
disableReferrals: prevDetails.disableReferrals,
disableCollapsibleSections: prevDetails.disableCollapsibleSections,
+ programIndicator:
+ prevDetails.programIndicator ||
+ prevDetails?.itemHeader?.programIndicator,
}
}
@@ -84,6 +88,7 @@ export const prepareSpinnerPreviousSpinner = (settings) => {
'completionSpinner',
'disableReferrals',
'disableCollapsibleSections',
+ 'programIndicator',
]
)
}
@@ -196,7 +201,34 @@ export const isProgramConfiguration = (configurationType) =>
'optionalSearch',
'disableReferrals',
'disableCollapsibleSections',
+ 'programIndicator',
].includes(configurationType)
export const removeAttributes = (itemList) =>
removePropertiesFromObject(itemList, ['summarySettings', 'id', 'name'])
+
+export const prepareSpinnerSettingsDataStore = (settings) => {
+ const settingsToSave = mapValues(settings, (setting) =>
+ createItemHeader(setting)
+ )
+
+ return removePropertiesFromObject(settingsToSave, [
+ 'summarySettings',
+ 'id',
+ 'name',
+ 'programIndicator',
+ ])
+}
+
+const createItemHeader = (settings) => {
+ const programIndicator = !isNil(settings.programIndicator) && {
+ itemHeader: {
+ programIndicator: settings.programIndicator,
+ },
+ }
+
+ return {
+ ...settings,
+ ...programIndicator,
+ }
+}
diff --git a/src/utils/validators/__tests__/isValidAndroidExpression.test.js b/src/utils/validators/__tests__/isValidAndroidExpression.test.js
new file mode 100644
index 00000000..12a13298
--- /dev/null
+++ b/src/utils/validators/__tests__/isValidAndroidExpression.test.js
@@ -0,0 +1,65 @@
+import { isValidAndroidExpression } from '../isValidAndroidExpression'
+
+test('"A{ZzYYXq4fJie}" is a valid Android expression', () => {
+ const expression = 'A{ZzYYXq4fJie}'
+ expect(isValidAndroidExpression(expression)).toBeTruthy()
+})
+
+test('"A{ZzYYXq4fJie} + A{ZzYYXq4fJie}" is a valid Android expression', () => {
+ const expression = 'A{ZzYYXq4fJie} + A{ZzYYXq4fJie}'
+ expect(isValidAndroidExpression(expression)).toBeTruthy()
+})
+
+test('"(A{A03MvHHogjR}+A{GQY2lXrypjO})/A{value_count}" is a valid Android expression', () => {
+ const expression = '(A{A03MvHHogjR}+A{GQY2lXrypjO})/A{value_count}'
+ expect(isValidAndroidExpression(expression)).toBeTruthy()
+})
+
+test("\"A{w75KJ2mc4zz} A{zDhUuAYrxNC}, if(A{cejWyOfXge6} == 'Male', 'M', 'F')\" is a valid Android expression", () => {
+ const expression =
+ "A{w75KJ2mc4zz} A{zDhUuAYrxNC}, if(A{cejWyOfXge6} == 'Male', 'M', 'F')"
+ expect(isValidAndroidExpression(expression)).toBeTruthy()
+})
+
+test("\"d2:concatenate(A{w75KJ2mc4zz}, ' ', A{zDhUuAYrxNC}, ', ', d2:substring(A{cejWyOfXge6}, 0, 1)\" is a valid Android expression", () => {
+ const expression =
+ "d2:concatenate(A{w75KJ2mc4zz}, ' ', A{zDhUuAYrxNC}, ', ', d2:substring(A{cejWyOfXge6}, 0, 1))"
+ expect(isValidAndroidExpression(expression)).toBeTruthy()
+})
+
+test('"#{ZzYYXq4fJie.rxBfISxXS2U}" is not a valid Android expression', () => {
+ const expression = '#{ZzYYXq4fJie.rxBfISxXS2U}'
+ expect(isValidAndroidExpression(expression)).toBeFalsy()
+})
+
+test('"#{ZzYYXq4fJie.FqlgKAG8HOu} + #{ZzYYXq4fJie.rxBfISxXS2U}" is not a valid Android expression', () => {
+ const expression = '#{ZzYYXq4fJie.FqlgKAG8HOu} + #{ZzYYXq4fJie.rxBfISxXS2U}'
+ expect(isValidAndroidExpression(expression)).toBeFalsy()
+})
+
+test('"(#{A03MvHHogjR.UXz7xuGCEhU}+#{ZzYYXq4fJie.GQY2lXrypjO})/A{value_count}" is not a valid Android expression', () => {
+ const expression =
+ '(#{A03MvHHogjR.UXz7xuGCEhU}+#{ZzYYXq4fJie.GQY2lXrypjO})/A{value_count}'
+ expect(isValidAndroidExpression(expression)).toBeFalsy()
+})
+
+test('"(A{A03MvHHogjR}+#{ZzYYXq4fJie.GQY2lXrypjO})/A{value_count}" is not a valid Android expression', () => {
+ const expression =
+ '(A{A03MvHHogjR}+#{ZzYYXq4fJie.GQY2lXrypjO})/A{value_count}'
+ expect(isValidAndroidExpression(expression)).toBeFalsy()
+})
+
+test('"(A{ZzYYXq4fJie}+#{ZzYYXq4fJie.GQY2lXrypjO})" is not a valid Android expression', () => {
+ const expression = '(A{ZzYYXq4fJie}+#{ZzYYXq4fJie.GQY2lXrypjO})'
+ expect(isValidAndroidExpression(expression)).toBeFalsy()
+})
+
+test('"V{event_count}" is not a valid Android expression', () => {
+ const expression = 'V{event_count}'
+ expect(isValidAndroidExpression(expression)).toBeFalsy()
+})
+
+test('"#{ZzYYXq4fJie.GQY2lXrypjO}" is not a valid Android expression', () => {
+ const expression = '#{ZzYYXq4fJie.GQY2lXrypjO}'
+ expect(isValidAndroidExpression(expression)).toBeFalsy()
+})
diff --git a/src/utils/validators/index.js b/src/utils/validators/index.js
index b1245334..c922d82d 100644
--- a/src/utils/validators/index.js
+++ b/src/utils/validators/index.js
@@ -1,3 +1,4 @@
+export * from './isValidAndroidExpression'
export * from './isValidURL'
export * from './isValidValue'
export * from './validateObjectByProperty'
diff --git a/src/utils/validators/isValidAndroidExpression.js b/src/utils/validators/isValidAndroidExpression.js
new file mode 100644
index 00000000..fed65c06
--- /dev/null
+++ b/src/utils/validators/isValidAndroidExpression.js
@@ -0,0 +1,19 @@
+/**
+ * An expression is valid for android when:
+ * - Based only on attributes and functions
+ * - attributes: "A{attribute}"
+ * - functions: "d2:concatenate"
+ * - No Data Elements: "#{programStage.dataElement}"
+ * - No variables: "V{variable}"
+ * */
+
+export const isValidAndroidExpression = (inputString) => {
+ const invalidPatterns = [
+ /#\{[^}]*/, // Matches anything starting with "#{"
+ /V\{\s*[^}]+\s*/, // Matches "V{}" with optional spaces inside the braces
+ /#\{[^}]*|V\{\s*[^}]+\s*/, // Combination of the above two
+ ]
+
+ // Check if the string contains any invalid patterns
+ return !invalidPatterns.some((pattern) => pattern.test(inputString))
+}