diff --git a/i18n/en.pot b/i18n/en.pot index 73d40913..2d11f7b9 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,13 @@ 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: 2022-01-10T08:43:46.954Z\n" -"PO-Revision-Date: 2022-01-10T08:43:46.954Z\n" +"POT-Creation-Date: 2022-05-06T05:50:16.003Z\n" +"PO-Revision-Date: 2022-05-06T05:50:16.003Z\n" + +msgid "" +"ERROR Dataelement with UID '{{dataElementId}}' does not exist in dataset " +"with UID '{{dataSetId}}'" +msgstr "" msgid "" msgstr "" @@ -77,10 +82,10 @@ msgstr "" msgid "NHWA Data Approval Status Report" msgstr "" -msgid "Data set" +msgid "Period" msgstr "" -msgid "Period" +msgid "Data set" msgstr "" msgid "Attribute" @@ -154,3 +159,12 @@ msgstr "" msgid "Toggle filters" msgstr "" + +msgid "No errors detected" +msgstr "" + +msgid "Custom Form Validation" +msgstr "" + +msgid "Select custom form to validate..." +msgstr "" diff --git a/i18n/es.po b/i18n/es.po index a81ba594..ef9ff87f 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,13 +1,18 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2022-01-10T08:43:46.954Z\n" +"POT-Creation-Date: 2022-05-06T05:50:16.003Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "" +"ERROR Dataelement with UID '{{dataElementId}}' does not exist in dataset " +"with UID '{{dataSetId}}'" +msgstr "" + msgid "" msgstr "" @@ -77,10 +82,10 @@ msgstr "" msgid "NHWA Data Approval Status Report" msgstr "" -msgid "Data set" +msgid "Period" msgstr "" -msgid "Period" +msgid "Data set" msgstr "" msgid "Attribute" @@ -155,6 +160,15 @@ msgstr "" msgid "Toggle filters" msgstr "" +msgid "No errors detected" +msgstr "" + +msgid "Custom Form Validation" +msgstr "" + +msgid "Select custom form to validate..." +msgstr "" + #~ msgid "Add" #~ msgstr "AƱadir" diff --git a/src/compositionRoot.ts b/src/compositionRoot.ts index ea31c433..17705c6a 100644 --- a/src/compositionRoot.ts +++ b/src/compositionRoot.ts @@ -1,5 +1,6 @@ import { Dhis2ConfigRepository } from "./data/Dhis2ConfigRepository"; import { Dhis2OrgUnitsRepository } from "./data/Dhis2OrgUnitsRepository"; +import { DataSetsDefaultRepository } from "./data/DataSetsDefaultRepository"; import { NHWADataApprovalDefaultRepository } from "./data/NHWADataApprovalDefaultRepository"; import { NHWADataCommentsDefaultRepository } from "./data/NHWADataCommentsDefaultRepository"; import { WIDPAdminDefaultRepository } from "./data/WIDPAdminDefaultRepository"; @@ -15,6 +16,7 @@ import { SaveDataSetsUseCase } from "./domain/nhwa-approval-status/usecases/Save import { GetDataValuesUseCase } from "./domain/nhwa-comments/usecases/GetDataValuesUseCase"; import { SaveDataValuesUseCase } from "./domain/nhwa-comments/usecases/SaveDataValuesCsvUseCase"; import { D2Api } from "./types/d2-api"; +import { GetValidatedDataSetsUseCase } from "./domain/validatecustomforms/usecases/GetValidatedDataSetsUseCase"; export function getCompositionRoot(api: D2Api) { const configRepository = new Dhis2ConfigRepository(api); @@ -22,6 +24,7 @@ export function getCompositionRoot(api: D2Api) { const dataApprovalRepository = new NHWADataApprovalDefaultRepository(api); const widpAdminDefaultRepository = new WIDPAdminDefaultRepository(api); const orgUnitsRepository = new Dhis2OrgUnitsRepository(api); + const dataSetsRepositoryDefaultRepository = new DataSetsDefaultRepository(api); return { admin: getExecute({ @@ -42,6 +45,9 @@ export function getCompositionRoot(api: D2Api) { orgUnits: getExecute({ get: new GetOrgUnitsUseCase(orgUnitsRepository), }), + validateCustomForm: getExecute({ + get: new GetValidatedDataSetsUseCase(dataSetsRepositoryDefaultRepository), + }), config: getExecute({ get: new GetConfig(configRepository), }), diff --git a/src/data/DataSetsDefaultRepository.ts b/src/data/DataSetsDefaultRepository.ts new file mode 100644 index 00000000..744eb407 --- /dev/null +++ b/src/data/DataSetsDefaultRepository.ts @@ -0,0 +1,75 @@ +import _ from "lodash"; +import { DataSetsRepository } from "../domain/validatecustomforms/repositories/DataSetsRepository"; +import i18n from "../locales"; +import { D2Api } from "../types/d2-api"; + +export class DataSetsDefaultRepository implements DataSetsRepository { + constructor(private api: D2Api) {} + + async validate(id: string): Promise { + const metadata$ = this.api.metadata.get({ + dataSets: { + fields: { + id: true, + displayName: true, + dataEntryForm: { htmlCode: true }, + dataSetElements: { + dataElement: { id: true, categoryCombo: { id: true } }, + }, + }, + }, + dataElements: { + fields: { id: true, categoryCombo: { id: true } }, + }, + categoryCombos: { + fields: { id: true, categoryOptionCombos: { id: true } }, + }, + }); + + const { dataSets, categoryCombos, dataElements } = await metadata$.getData(); + + const dataSet = dataSets.find(dataset => dataset.id === id); + const htmlCode = dataSet?.dataEntryForm.htmlCode; + const newRegExp = new RegExp(/(([a-zA-Z0-9]){11})-(([A-Za-zA-Z0-9]){11})-(val)/g); + + const matches = htmlCode?.match(newRegExp); + const customFormIds = _(matches).map(item => { + return { dataElementId: item.split("-")[0] ?? "-", categoryOptionComboId: item.split("-")[1] ?? "-" }; + }).commit().value(); + + const categoryCombosById = _.keyBy(categoryCombos, cc => cc.id); + const dataElementsDataSetById = _.keyBy(dataSet?.dataSetElements, cc => cc.dataElement.id); + const dataElementsById = _.keyBy(dataElements, cc => cc.id); + + const errors = _.map(customFormIds, dataElementFromCustomForm => { + const dataElement = dataElementsDataSetById[dataElementFromCustomForm.dataElementId]?.dataElement; + if (dataElement) { + const categoryCombo = + dataElement.categoryCombo.id ?? + dataElementsById[dataElementFromCustomForm.dataElementId]?.categoryCombo.id; + const isValid = categoryCombosById[categoryCombo ?? ""]?.categoryOptionCombos.find( + coc => coc.id === dataElementFromCustomForm.categoryOptionComboId + ); + if (!isValid) { + return ( + i18n.t("ERROR Dataelement with UID:") + + " " + + dataElementFromCustomForm["dataElementId"] + + " " + + i18n.t("is not associated with CategoryOptionComboID:") + + " " + + dataElementFromCustomForm["categoryOptionComboId"] + ); + } + } else { + return i18n.t( + "ERROR Dataelement with UID '{{dataElementId}}' does not exist in dataset with UID '{{dataSetId}}'", + { dataElementId: dataElementFromCustomForm.dataElementId, dataSetId: id, nsSeparator: false } + ); + } + }); + const newerror = _.compact(errors); + + return _.uniq(newerror); + } +} diff --git a/src/domain/validatecustomforms/repositories/DataSetsRepository.ts b/src/domain/validatecustomforms/repositories/DataSetsRepository.ts new file mode 100644 index 00000000..31a41cef --- /dev/null +++ b/src/domain/validatecustomforms/repositories/DataSetsRepository.ts @@ -0,0 +1,3 @@ +export interface DataSetsRepository { + validate(id: string): Promise; +} diff --git a/src/domain/validatecustomforms/usecases/GetValidatedDataSetsUseCase.ts b/src/domain/validatecustomforms/usecases/GetValidatedDataSetsUseCase.ts new file mode 100644 index 00000000..1de66d73 --- /dev/null +++ b/src/domain/validatecustomforms/usecases/GetValidatedDataSetsUseCase.ts @@ -0,0 +1,10 @@ +import { DataSetsRepository } from "../repositories/DataSetsRepository"; + +export class GetValidatedDataSetsUseCase { + constructor(private dataSetsRepository: DataSetsRepository) {} + + execute(id: string): Promise { + // FUTURE: Return a Future-like instead, to allow better error handling and cancellation. + return this.dataSetsRepository.validate(id); + } +} diff --git a/src/webapp/components/select/Select.tsx b/src/webapp/components/select/Select.tsx new file mode 100644 index 00000000..11814dd4 --- /dev/null +++ b/src/webapp/components/select/Select.tsx @@ -0,0 +1,79 @@ +import { + FormControl, + InputLabel, + MenuItem, + Select as MuiSelect, + SelectProps as MuiSelectProps, +} from "@material-ui/core"; +import { createStyles, makeStyles } from "@material-ui/core/styles"; +import _ from "lodash"; +import React, { useMemo, useState } from "react"; + +export type SelectOption = { value: string; label: string }; + +export interface SelectProps extends Omit { + placeholder?: string; + options: Array; + onChange: (option: SelectOption) => void; + defaultValue?: SelectOption; + value?: string; + allowEmpty?: boolean; + emptyLabel?: string; +} + +export const Select: React.FC = ({ + placeholder, + options, + onChange, + defaultValue, + value, + allowEmpty = false, + emptyLabel = "", + ...rest +}) => { + const classes = useStyles(); + const [stateValue, setValue] = useState(defaultValue ? defaultValue.value : ""); + const optionsByValue = useMemo(() => _.keyBy(options, option => option.value), [options]); + const defaultOption = allowEmpty ? { label: "", value: "" } : undefined; + + const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => { + const newValue = event.target.value as string; + const option = _(optionsByValue).get(newValue, defaultOption); + setValue(newValue); + if (option) onChange(option); + }; + + const defaultLabel = allowEmpty ? emptyLabel : placeholder; + + return ( +
+ + {!!placeholder && {placeholder}} + + {!!defaultLabel && ( + + {defaultLabel} + + )} + {options.map(option => ( + + {option.label} + + ))} + + +
+ ); +}; + +const useStyles = makeStyles(() => + createStyles({ + formControl: { + margin: 0, + display: "flex", + }, + menuItem: { + minHeight: 35, + }, + }) +); diff --git a/src/webapp/reports/Reports.tsx b/src/webapp/reports/Reports.tsx index 15f4cf3c..78fd01de 100644 --- a/src/webapp/reports/Reports.tsx +++ b/src/webapp/reports/Reports.tsx @@ -2,6 +2,7 @@ import React from "react"; import AdminReport from "./admin/AdminReport"; import NHWACommentsReport from "./nhwa-comments/NHWACommentsReport"; import NHWADataApprovalStatusReport from "./nhwa-approval-status/NHWADataApprovalStatusReport"; +import ValidateCustomFormsReport from "./validate-custom-forms/ValidateCustomFormsReport"; const widget = process.env.REACT_APP_REPORT_VARIANT || ""; @@ -16,6 +17,9 @@ const Component: React.FC = () => { case "admin": { return ; } + case "validate-custom-forms": { + return ; + } default: { return

{`Please provide a valid REACT_APP_REPORT_VARIANT`}

; } diff --git a/src/webapp/reports/validate-custom-forms/ValidateCustomFormsReport.tsx b/src/webapp/reports/validate-custom-forms/ValidateCustomFormsReport.tsx new file mode 100644 index 00000000..0e9e394e --- /dev/null +++ b/src/webapp/reports/validate-custom-forms/ValidateCustomFormsReport.tsx @@ -0,0 +1,102 @@ +import React, { useState } from "react"; +import _ from "lodash"; +import { Typography, makeStyles } from "@material-ui/core"; + +import i18n from "../../../locales"; +import { Spinner } from "../../components/objects-list/Spinner"; +import { Select, SelectOption } from "../../components/select/Select"; +import { useAppContext } from "../../contexts/app-context"; + +const ValidateCustomFormsReport: React.FC = () => { + const [isLoading, setLoading] = useState(false); + + const [errors, setErrors] = React.useState>([]); + const { compositionRoot, config } = useAppContext(); + + const validateCustomForm = async ({ value }: SelectOption) => { + setLoading(true); + const result = await compositionRoot.validateCustomForm.get(value); + + if (_.isEmpty(result)) { + setErrors([{ text: i18n.t("No errors detected") }]); + } else { + setErrors(_.map(result, item => ({ text: item }))); + } + + setLoading(false); + return value; + }; + + const classes = useStyles(); + + const dataSetsOptions = React.useMemo(() => { + return _(config.dataSets) + .filter(ds => ds.name.indexOf("Maturity") === -1) + .map(ds => ({ value: ds.id, label: ds.name })) + .value(); + }, [config]); + + return ( + +

{i18n.t("Custom Form Validation")}

+ +
+