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)) +}