diff --git a/i18n/en.pot b/i18n/en.pot index d39fb53..c32cd1b 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: 2025-08-27T03:27:08.033Z\n" -"PO-Revision-Date: 2025-08-27T03:27:08.033Z\n" +"POT-Creation-Date: 2025-09-13T17:01:13.807Z\n" +"PO-Revision-Date: 2025-09-13T17:01:13.807Z\n" msgid "" msgstr "" @@ -44,6 +44,9 @@ msgstr "" msgid "Period" msgstr "" +msgid "Totals" +msgstr "" + msgid "Occupation" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 41ac2a4..d740b13 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-08-27T03:27:08.033Z\n" +"POT-Creation-Date: 2025-09-13T17:01:13.807Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -44,6 +44,9 @@ msgstr "" msgid "Period" msgstr "" +msgid "Totals" +msgstr "" + msgid "Occupation" msgstr "Ocupación" diff --git a/i18n/fr.po b/i18n/fr.po index 82647e7..d0a7ecd 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-08-27T03:27:08.033Z\n" +"POT-Creation-Date: 2025-09-13T17:01:13.807Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -44,6 +44,9 @@ msgstr "" msgid "Period" msgstr "" +msgid "Totals" +msgstr "" + msgid "Occupation" msgstr "Profession" diff --git a/src/data/common/Dhis2DataElement.ts b/src/data/common/Dhis2DataElement.ts index 83a41f3..a143e69 100644 --- a/src/data/common/Dhis2DataElement.ts +++ b/src/data/common/Dhis2DataElement.ts @@ -46,6 +46,7 @@ const dataElementFields = { categories: { id: true, name: true, + code: true, categoryOptions: { id: true, code: true, @@ -75,7 +76,13 @@ type D2DataElement = MetadataPick<{ dataElements: { fields: typeof dataElementFields }; }>["dataElements"][number]; -function makeCocOrderArray(namesArray: string[][]): string[] { +type D2DataElementTypes = D2DataElement["valueType"] | "MULTI_TEXT"; + +type D2DataElementNewType = Omit & { + valueType: D2DataElementTypes; +}; + +export function makeCocOrderArray(namesArray: string[][]): string[] { return namesArray.reduce((prev, current) => { return prev .map(prevValue => { @@ -90,6 +97,7 @@ function makeCocOrderArray(namesArray: string[][]): string[] { } function getCocOrdered(categoryCombo: D2DataElement["categoryCombo"], config: Dhis2DataStoreDataForm) { + const keyName = config.categoryCombinationsConfig[categoryCombo.code]?.viewType || "formName"; const allCategoryOptions = categoryCombo.categories .map(c => { return c.categoryOptions.flatMap(co => ({ @@ -113,9 +121,13 @@ function getCocOrdered(categoryCombo: D2DataElement["categoryCombo"], config: Dh return coc.name === cocOrdered; }); - const optionsNames = match?.categoryOptions.map(co => co.displayName); - const optionsShortNames = match?.categoryOptions.map(co => co.displayShortName); - const optionsFormNames = match?.categoryOptions.map(co => co.displayFormName); + const orderedOptions = categoryCombo.categories.map(category => { + return match?.categoryOptions.find(co => category.categoryOptions.some(catOpt => catOpt.id === co.id)); + }); + + const optionsNames = orderedOptions.map(co => co?.displayName); + const optionsShortNames = orderedOptions.map(co => co?.displayShortName); + const optionsFormNames = orderedOptions.map(co => co?.displayFormName); const categoryOption = categoryCombo.categories.length === 1 @@ -136,11 +148,10 @@ function getCocOrdered(categoryCombo: D2DataElement["categoryCombo"], config: Dh : []; }); - const keyName = config.categoryCombinationsConfig[categoryCombo.code]?.viewType || "formName"; return result.map(x => ({ ...x, name: x[keyName] || x.name || "" })); } -function getDataElement(dataElement: D2DataElement, config: Dhis2DataStoreDataForm): DataElement | null { +function getDataElement(dataElement: D2DataElementNewType, config: Dhis2DataStoreDataForm): DataElement | null { const { valueType } = dataElement; const deConfig = config.dataElementsConfig[dataElement.code]; const optionSetFromDataElement = dataElement.optionSet @@ -157,6 +168,22 @@ function getDataElement(dataElement: D2DataElement, config: Dhis2DataStoreDataFo const categoryCombination = { id: dataElement.categoryCombo?.id, name: dataElement.categoryCombo?.name, + categories: dataElement.categoryCombo?.categories.map(cat => { + const keyName = config.categoryCombinationsConfig[dataElement.categoryCombo.code]?.viewType || "formName"; + return { + ...cat, + categoryOptions: cat.categoryOptions.map(co => { + const record = { + id: co.id, + name: co.displayName, + formName: co.displayFormName, + shortName: co.displayShortName, + code: co.code, + }; + return { id: record.id, code: co.code, name: record[keyName] ?? record.name }; + }), + }; + }), categoryOptionCombos: getCocOrdered(dataElement.categoryCombo, config), }; const categoryOptionCombos = dataElement.categoryCombo.categoryOptionCombos; @@ -173,12 +200,15 @@ function getDataElement(dataElement: D2DataElement, config: Dhis2DataStoreDataFo : undefined, rules: [], htmlText: undefined, + disabled: deConfig?.disabled || false, }; switch (valueType) { case "TEXT": case "LONG_TEXT": return { type: "TEXT", related: undefined, ...base }; + case "MULTI_TEXT": + return { type: "MULTI_TEXT", related: undefined, ...base }; case "INTEGER": case "INTEGER_NEGATIVE": case "INTEGER_POSITIVE": diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index f6aa550..324ab02 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -1,6 +1,7 @@ import _ from "lodash"; import { Code, getId, Id } from "../../domain/common/entities/Base"; +import { CompulsoryDataValue } from "../../domain/common/entities/CompulsoryDataValue"; import { DataElement } from "../../domain/common/entities/DataElement"; import { DataElementRuleOptions, @@ -11,7 +12,7 @@ import { SectionTotalRule, TotalRules, } from "../../domain/common/entities/DataElementRule"; -import { DataForm, defaultTexts, Section, SectionBase } from "../../domain/common/entities/DataForm"; +import { DataForm, defaultTexts, RowConfigDetails, Section, SectionBase } from "../../domain/common/entities/DataForm"; import { Period } from "../../domain/common/entities/DataValue"; import { Indicator } from "../../domain/common/entities/Indicator"; import { SectionStyle } from "../../domain/common/entities/SectionStyle"; @@ -54,6 +55,9 @@ export class Dhis2DataFormRepository implements DataFormRepository { texts: dataSetConfig.texts, options: { dataElements: dataElementsOptions }, totalRules: totalRules, + compulsoryDataValues: dataSet.compulsoryDataElementOperands.map( + operand => new CompulsoryDataValue(operand.dataElement.id, operand.categoryOptionCombo.id) + ), }; } @@ -109,7 +113,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { texts: config?.texts || defaultTexts, tabs: config?.tabs && config.tabs.active - ? { active: true, order: config.tabs.order.toString() } + ? { active: true, order: config.tabs.order.toString(), rules: config.tabs.rules } : { active: false }, sortRowsBy: config?.sortRowsBy || "", disableComments: config?.disableComments ?? false, @@ -151,9 +155,24 @@ export class Dhis2DataFormRepository implements DataFormRepository { toggleMultiple: config?.toggleMultiple ? buildToggleMultiple(config.toggleMultiple, dataElements) : undefined, + fixedHeaders: config?.fixedHeaders || false, + enableTopScroll: config?.enableTopScroll || false, + fixedRowNames: config?.fixedRowNames || false, }; - if (!config) return { viewType: "table", calculateTotals: undefined, ...base }; + if (!config) + return { + viewType: "table", + calculateTotals: undefined, + ...base, + fixedHeaders: false, + columnsOrder: undefined, + enableGroups: false, + fixedRowNames: false, + enableTopScroll: false, + columnsConfig: undefined, + firstColumnConfig: undefined, + }; const base2 = getSectionBaseWithToggle(config, base, dataElements); @@ -163,7 +182,15 @@ export class Dhis2DataFormRepository implements DataFormRepository { case "table": case "grid": case "grid-with-totals": - return { viewType: config.viewType, calculateTotals: config.calculateTotals, ...base2 }; + return { + viewType: config.viewType, + calculateTotals: config.calculateTotals, + columnsOrder: config.columnsOrder, + enableGroups: config.enableGroups || false, + columnsConfig: config.columnsConfig, + firstColumnConfig: config.firstColumnConfig, + ...base2, + }; case "grid-with-subnational-ous": return { viewType: config.viewType, @@ -193,6 +220,33 @@ export class Dhis2DataFormRepository implements DataFormRepository { }), ...base2, }; + case "grid-category-columns": { + const rowsConfigWithTexts = _(config.rowsConfig) + .map((rowConfig, key): [string, RowConfigDetails] => { + const constant = configDataForm.constants.find( + c => c.code === rowConfig.rowNameConstant + ); + + return [ + key, + { + cellsVisible: rowConfig.cellsVisible ?? true, + rowName: constant?.displayDescription, + }, + ]; + }) + .fromPairs() + .value(); + + return { + ...base2, + viewType: config.viewType, + showCalculatedTotals: config.showCalculatedTotals, + categoriesColumns: config.categoriesColumns, + rowsConfig: rowsConfigWithTexts ?? undefined, + singleCategoryInColumns: config.singleCategoryInColumns ?? false, + }; + } default: return { viewType: config.viewType, ...base2 }; } @@ -480,6 +534,7 @@ function getMetadataQuery(options: { dataSetId: Id }) { id: true, code: true, expiryDays: true, + compulsoryDataElementOperands: { dataElement: { id: true }, categoryOptionCombo: { id: true } }, dataInputPeriods: { closingDate: true, openingDate: true, diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index ebf2231..ca0d394 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -6,11 +6,18 @@ import { Maybe, NonPartial } from "../../utils/ts-utils"; import { Code, getCode, Id, NamedRef } from "../../domain/common/entities/Base"; import { Option } from "../../domain/common/entities/DataElement"; import { Period } from "../../domain/common/entities/DataValue"; -import { DescriptionText, Texts, Totals } from "../../domain/common/entities/DataForm"; +import { + CategoryColumnConfig, + ColumnOrder, + DescriptionText, + Texts, + Totals, +} from "../../domain/common/entities/DataForm"; import { titleVariant } from "../../domain/common/entities/TitleVariant"; import { SectionStyle, SectionStyleAttrs } from "../../domain/common/entities/SectionStyle"; import { DataElementRuleOptions, SectionRuleOptions } from "../../domain/common/entities/DataElementRule"; import { ToggleMultiple } from "../../domain/common/entities/ToggleMultiple"; +import { FromRulesFormulaCodec, rulesFormulaCodec } from "./RulesFormula"; interface DataSetConfig { texts: Texts; @@ -23,7 +30,8 @@ export type SectionConfig = | GridWithPeriodsSectionConfig | GridWithTotalsSectionConfig | GridWithSubnationalSectionConfig - | GridIndicatorsCalculated; + | GridIndicatorsCalculated + | GridCategoryColumnsConfig; export type TotalsRule = ( | { @@ -48,7 +56,7 @@ interface BaseSectionConfig { | { type: "none" } | { type: "dataElement"; code: Code } | { type: "dataElementExternal"; code: Code; condition: string | undefined }; - tabs: { active: true; order: string | number } | { active: false }; + tabs: { active: true; order: string | number; rules?: FromRulesFormulaCodec } | { active: false }; sortRowsBy: string; titleVariant: titleVariant; styles: SectionStyleAttrs; @@ -58,6 +66,9 @@ interface BaseSectionConfig { totals?: Record; toggleMultiple: Maybe; indicators?: Record; + fixedHeaders: boolean; + fixedRowNames: boolean; + enableTopScroll: boolean; } interface BasicSectionConfig extends BaseSectionConfig { @@ -67,6 +78,17 @@ interface BasicSectionConfig extends BaseSectionConfig { interface GridSectionConfig extends BaseSectionConfig { viewType: "table" | "grid"; calculateTotals: CalculateTotalType; + columnsOrder: Maybe; + enableGroups: boolean; + columnsConfig?: Record< + string, + { + rules?: FromRulesFormulaCodec; + } + >; + firstColumnConfig?: { + width: number; + }; } interface GridWithPeriodsSectionConfig extends BaseSectionConfig { @@ -77,6 +99,14 @@ interface GridWithPeriodsSectionConfig extends BaseSectionConfig { interface GridWithTotalsSectionConfig extends BaseSectionConfig { viewType: "grid-with-totals"; calculateTotals: CalculateTotalType; + columnsOrder: Maybe; + fixedRowNames: boolean; + enableGroups: boolean; + enableTopScroll: boolean; + columnsConfig?: GridColumnsConfig; + firstColumnConfig?: { + width: number; + }; } interface GridIndicatorsCalculated extends BaseSectionConfig { @@ -101,6 +131,16 @@ export type GridIndicatorsCalculatedRow = { }>; }; +type GridColumnsConfig = Record; + +interface GridCategoryColumnsConfig extends BaseSectionConfig { + viewType: "grid-category-columns"; + showCalculatedTotals: boolean; + categoriesColumns: CategoryColumnConfig[]; + rowsConfig: Maybe>; + singleCategoryInColumns: boolean; +} + interface GridWithSubnationalSectionConfig extends BaseSectionConfig { viewType: "grid-with-subnational-ous"; calculateTotals: CalculateTotalType; @@ -149,6 +189,7 @@ const viewType = oneOf([ exactly("grid-with-periods"), exactly("grid-with-subnational-ous"), exactly("grid-indicators-calculated"), + exactly("grid-category-columns"), ]); const titleVariantType = oneOf([ @@ -226,6 +267,7 @@ const DataStoreConfigCodec = Codec.interface({ viewType: optional(oneOf([exactly("name"), exactly("shortName"), exactly("formName")])), }), dataElements: sectionConfig({ + disabled: optional(boolean), disableComments: optional(boolean), rules: optional(dataElementRuleCodec), selection: optional( @@ -250,6 +292,22 @@ const DataStoreConfigCodec = Codec.interface({ texts: optional(textsCodec), sections: optional( sectionConfig({ + firstColumnConfig: optional(Codec.interface({ width: number })), + singleCategoryInColumns: optional(boolean), + rowsConfig: optional( + record( + string, + Codec.interface({ cellsVisible: optional(boolean), rowNameConstant: optional(string) }) + ) + ), + showCalculatedTotals: optional(boolean), + categoriesColumns: optional(array(Codec.interface({ dataElementCode: string, categoryCode: string }))), + columnsConfig: optional(record(string, Codec.interface({ rules: optional(rulesFormulaCodec) }))), + columnsOrder: optional(record(string, number)), + fixedHeaders: optional(boolean), + fixedRowNames: optional(boolean), + enableGroups: optional(boolean), + enableTopScroll: optional(boolean), disableComments: optional(boolean), subNationalDataset: optional(string), sortRowsBy: optional(string), @@ -270,6 +328,7 @@ const DataStoreConfigCodec = Codec.interface({ Codec.interface({ active: exactly(true), order: oneOf([string, number]), + rules: optional(rulesFormulaCodec), }) ), periods: optional( @@ -353,26 +412,6 @@ const DataStoreConfigCodec = Codec.interface({ }) ) ), - rows: optional( - array( - Codec.interface({ - code: string, - denominator: optional( - Codec.interface({ - dataElementCode: string, - }) - ), - value: optional( - Codec.interface({ - dataElementCodes: array(string), - formula: Codec.interface({ - value: string, - }), - }) - ), - }) - ) - ), }) ), }), @@ -380,6 +419,7 @@ const DataStoreConfigCodec = Codec.interface({ export interface DataElementConfig { rules?: DataElementRuleOptions; + disabled?: boolean; disableComments?: boolean; texts?: Texts; selection?: { @@ -604,7 +644,15 @@ export class Dhis2DataStoreDataForm { .compact() .value(); - const virtualCodes = virtualColumnsCodes.concat(virtualRowsCodes); + const rowNamesKeys = _(storeConfig.dataSets) + .values() + .flatMap(dataSet => _.values(dataSet.sections)) + .flatMap(section => _.values(section.rowsConfig)) + .map(rowConfig => rowConfig.rowNameConstant) + .compact() + .value(); + + const virtualCodes = virtualColumnsCodes.concat(virtualRowsCodes).concat(rowNamesKeys); const codes = _([...dataSetTexts, ...dataElementTexts, ...sectionTexts]) .flatMap(t => [ @@ -722,6 +770,9 @@ export class Dhis2DataStoreDataForm { totals: this.getSectionTotals(sectionConfig, constantsByCode), toggleMultiple: sectionConfig.toggleMultiple, indicators: sectionConfig.indicators, + fixedHeaders: sectionConfig.fixedHeaders || false, + fixedRowNames: sectionConfig.fixedRowNames || false, + enableTopScroll: sectionConfig.enableTopScroll || false, }; const baseConfig = { ...base, viewType }; @@ -742,6 +793,10 @@ export class Dhis2DataStoreDataForm { ...baseConfig, viewType, calculateTotals: sectionConfig.calculateTotals, + columnsOrder: sectionConfig.columnsOrder, + enableGroups: sectionConfig.enableGroups || false, + columnsConfig: sectionConfig.columnsConfig, + firstColumnConfig: sectionConfig.firstColumnConfig, }; return [section.id, config] as [typeof section.id, typeof config]; } @@ -751,6 +806,7 @@ export class Dhis2DataStoreDataForm { viewType, calculateTotals: sectionConfig.calculateTotals, subNationalDataset: sectionConfig.subNationalDataset || "", + columns: undefined, }; return [section.id, config] as [typeof section.id, typeof config]; } @@ -758,13 +814,23 @@ export class Dhis2DataStoreDataForm { const config = { ...baseConfig, periods: getPeriods(period, sectionConfig.periods), - rows: sectionConfig.rows ?? [], virtualColumns: sectionConfig.virtualColumns ?? [], virtualRows: sectionConfig.virtualRows ?? [], viewType, }; return [section.id, config]; } + case "grid-category-columns": { + const config = { + ...baseConfig, + viewType, + categoriesColumns: sectionConfig.categoriesColumns || [], + showCalculatedTotals: sectionConfig.showCalculatedTotals || false, + rowsConfig: sectionConfig.rowsConfig, + singleCategoryInColumns: sectionConfig.singleCategoryInColumns || false, + }; + return [section.id, config] as [typeof section.id, typeof config]; + } default: { const config = { ...baseConfig, viewType }; return [section.id, config] as [typeof section.id, typeof config]; diff --git a/src/data/common/Dhis2DataValueRepository.ts b/src/data/common/Dhis2DataValueRepository.ts index a3f1855..1c6977f 100644 --- a/src/data/common/Dhis2DataValueRepository.ts +++ b/src/data/common/Dhis2DataValueRepository.ts @@ -1,5 +1,6 @@ import _ from "lodash"; import { getId, Id } from "../../domain/common/entities/Base"; +import { CompulsoryDataValue } from "../../domain/common/entities/CompulsoryDataValue"; import { DataElement } from "../../domain/common/entities/DataElement"; import { DataValue, @@ -7,6 +8,7 @@ import { DataValueTextMultiple, DateObj, FileResource, + MULTI_TEXT_SEPARATOR, Period, } from "../../domain/common/entities/DataValue"; import { DataValueRepository, DataElementRefType } from "../../domain/common/repositories/DataValueRepository"; @@ -37,11 +39,22 @@ export class Dhis2DataValueRepository implements DataValueRepository { const dataSetCode = dataSetResponse.objects[0]?.code; if (!dataSetCode) throw new Error(`Data set not found: ${options.dataSetId}`); - const dataElements = await this.getDataElements(dataValues, dataSetCode); + const compulsoryDataElements = await this.getCompulsoryDataElements(options.dataSetId); + + const deIdsFromPayload = _.uniq(_.map(dataValues, dv => dv.dataElement)); + const deIdsRequired = _.uniq(_.map(compulsoryDataElements, r => r.dataElementId)); + const allDataElementIds = _(deIdsFromPayload).concat(deIdsRequired).uniq().value(); + + const dataElements = await this.getDataElements(dataValues, dataSetCode, allDataElementIds); const dataValuesFiles = await this.getFileResourcesMapping(dataElements, dataValues); - return _(dataValues) + const isEmptyStr = (s?: string | null): boolean => !s || s.trim() === ""; + + const isRequiredCombo = (deId: Id, cocId: Id): boolean => + _.some(compulsoryDataElements, req => req.dataElementId === deId && req.categoryOptionComboId === cocId); + + const dataValuesByType = _(dataValues) .map((dv): DataValue | null => { const dataElement = dataElements[dv.dataElement]; if (!dataElement) { @@ -49,15 +62,20 @@ export class Dhis2DataValueRepository implements DataValueRepository { return null; } + const isRequired = isRequiredCombo(dv.dataElement, dv.categoryOptionCombo) && isEmptyStr(dv.value); + const selector = { orgUnitId: dv.orgUnit, period: dv.period, categoryOptionComboId: dv.categoryOptionCombo, + isRequired, }; - const isMultiple = dataElement.options?.isMultiple; const { type } = dataElement; + const isMultiTextType = dataElement.type === "MULTI_TEXT"; + const isMultiple = dataElement.options?.isMultiple || isMultiTextType; + switch (type) { case "TEXT": return isMultiple @@ -69,6 +87,14 @@ export class Dhis2DataValueRepository implements DataValueRepository { value: dv.value, ...selector, }; + case "MULTI_TEXT": + return { + type: "MULTI_TEXT", + isMultiple: true, + dataElement, + values: getValues(dv.value, MULTI_TEXT_SEPARATOR), + ...selector, + }; case "NUMBER": return isMultiple ? { @@ -127,6 +153,142 @@ export class Dhis2DataValueRepository implements DataValueRepository { }) .compact() .value(); + + return this.checkCompulsoryAndBuildDataValues({ + compulsoryDataValues: compulsoryDataElements, + orgUnitIds: options.orgUnits, + periods: options.periods, + dataValues: dataValuesByType, + dataElements: dataElements, + }); + } + + private checkCompulsoryAndBuildDataValues(options: { + compulsoryDataValues: CompulsoryDataValue[]; + orgUnitIds: Id[]; + periods: Period[]; + dataValues: DataValue[]; + dataElements: Record; + }): DataValue[] { + const { dataElements, compulsoryDataValues, orgUnitIds, periods, dataValues } = options; + + const allCombos = _.flatMap(orgUnitIds, orgUnitId => + _.flatMap(periods, period => + _.map(compulsoryDataValues, req => ({ + dataElementId: req.dataElementId, + categoryOptionComboId: req.categoryOptionComboId, + orgUnitId, + period, + })) + ) + ); + + const existingKeys = new Set( + _.map(dataValues, dv => `${dv.dataElement.id}|${dv.categoryOptionComboId}|${dv.orgUnitId}|${dv.period}`) + ); + + const missingCombos = _.filter(allCombos, combo => { + const key = `${combo.dataElementId}|${combo.categoryOptionComboId}|${combo.orgUnitId}|${combo.period}`; + return !existingKeys.has(key); + }); + + const missingRequired = _(missingCombos) + .map((combo): Maybe => { + const dataElement = dataElements[combo.dataElementId]; + if (!dataElement) return undefined; + + const isMultiple = Boolean(dataElement.options?.isMultiple) || dataElement.type === "MULTI_TEXT"; + const selector = { + orgUnitId: combo.orgUnitId, + period: combo.period, + categoryOptionComboId: combo.categoryOptionComboId, + isRequired: true, + }; + + const { type } = dataElement; + + switch (type) { + case "TEXT": + return isMultiple + ? { type: "TEXT", isMultiple: true, dataElement: dataElement, values: [], ...selector } + : { type: "TEXT", isMultiple: false, dataElement: dataElement, value: "", ...selector }; + case "MULTI_TEXT": + return { + type: "MULTI_TEXT", + isMultiple: true, + dataElement: dataElement, + values: [], + ...selector, + }; + case "NUMBER": + return isMultiple + ? { type: "NUMBER", isMultiple: true, dataElement: dataElement, values: [], ...selector } + : { + type: "NUMBER", + isMultiple: false, + dataElement: dataElement, + value: "", + ...selector, + }; + case "PERCENTAGE": + return { + type: "PERCENTAGE", + isMultiple: false, + dataElement: dataElement, + value: "", + ...selector, + }; + case "BOOLEAN": + return { + type: "BOOLEAN", + isMultiple: false, + dataElement: dataElement, + value: undefined, + ...selector, + }; + case "FILE": + return { + type: "FILE", + dataElement: dataElement, + file: undefined, + isMultiple: false, + ...selector, + }; + case "DATE": + return { + type: "DATE", + dataElement: dataElement, + value: undefined, + isMultiple: false, + ...selector, + }; + default: + assertUnreachable(type); + } + }) + .compact() + .value(); + + return dataValues.concat(missingRequired); + } + + private async getCompulsoryDataElements(dataSetId: Id): Promise { + return this.api.models.dataSets + .get({ + fields: { + compulsoryDataElementOperands: { dataElement: { id: true }, categoryOptionCombo: { id: true } }, + }, + filter: { id: { eq: dataSetId } }, + }) + .getData() + .then(response => { + const first = response.objects[0]; + if (!first) throw new Error(`Data set not found: ${dataSetId}`); + + return first.compulsoryDataElementOperands.map( + cdeo => new CompulsoryDataValue(cdeo.dataElement.id, cdeo.categoryOptionCombo.id) + ); + }); } private async getFileResourcesMapping( @@ -171,9 +333,10 @@ export class Dhis2DataValueRepository implements DataValueRepository { ); } - private async getDataElements(dataValues: DataValueSetsDataValue[], dataSetCode: string) { + private async getDataElements(dataValues: DataValueSetsDataValue[], dataSetCode: string, allDataElementIds: Id[]) { const dataElementIds = dataValues.map(dv => dv.dataElement); - return new Dhis2DataElement(this.api).get(dataElementIds, dataSetCode); + const uniqDataElementIds = _(dataElementIds).concat(allDataElementIds).uniq().value(); + return new Dhis2DataElement(this.api).get(uniqDataElementIds, dataSetCode); } async save(dataValue: DataValue): Promise { @@ -295,6 +458,8 @@ export class Dhis2DataValueRepository implements DataValueRepository { case "NUMBER": case "TEXT": return (dataValue.isMultiple ? dataValue.values.join("; ") : dataValue.value) || ""; + case "MULTI_TEXT": + return dataValue.values.join(MULTI_TEXT_SEPARATOR); case "FILE": return dataValue.file?.id || ""; case "PERCENTAGE": @@ -311,9 +476,9 @@ function pad2(n: number): string { return n.toString().padStart(2, "0"); } -function getValues(s: string): string[] { +function getValues(s: string, splitSeparator = ";"): string[] { return _(s) - .split(";") + .split(splitSeparator) .map(s => s.trim()) .compact() .value(); diff --git a/src/data/common/RulesFormula.ts b/src/data/common/RulesFormula.ts new file mode 100644 index 0000000..46971cf --- /dev/null +++ b/src/data/common/RulesFormula.ts @@ -0,0 +1,23 @@ +import { array, Codec, exactly, oneOf, record, string, GetType } from "purify-ts"; +import { Code } from "../../domain/common/entities/Base"; + +export const rulesFormulaCodec = record( + oneOf([exactly("visible"), exactly("disabled")]), + Codec.interface({ + formula: Codec.interface({ value: string, condition: string }), + dataElements: array(Codec.interface({ code: string })), + }) +); + +export type FromRulesFormulaCodec = GetType; + +export type RulesFormula = { + visible?: FormulaDetails; + disabled?: FormulaDetails; +}; +export type ColumnsRules = Record; + +type FormulaDetails = { + formula: { condition: string; value: string }; + dataElements: Array<{ code: Code }>; +}; diff --git a/src/domain/common/entities/CompulsoryDataValue.ts b/src/domain/common/entities/CompulsoryDataValue.ts new file mode 100644 index 0000000..4a42525 --- /dev/null +++ b/src/domain/common/entities/CompulsoryDataValue.ts @@ -0,0 +1,5 @@ +import { Id } from "./Base"; + +export class CompulsoryDataValue { + constructor(public dataElementId: Id, public categoryOptionComboId: Id) {} +} diff --git a/src/domain/common/entities/DataElement.ts b/src/domain/common/entities/DataElement.ts index 8652118..8ec2d69 100644 --- a/src/domain/common/entities/DataElement.ts +++ b/src/domain/common/entities/DataElement.ts @@ -8,7 +8,8 @@ export type DataElement = | DataElementText | DataElementPercentage | DataElementFile - | DataElementDate; + | DataElementDate + | DataElementMultiText; interface DataElementBase { id: Id; @@ -28,6 +29,7 @@ interface DataElementBase { disabledComments?: boolean; rules: Rule[]; htmlText: Maybe; + disabled: boolean; } export interface DataElementBoolean extends DataElementBase { @@ -49,6 +51,10 @@ export interface DataElementText extends DataElementBase { type: "TEXT"; } +export interface DataElementMultiText extends DataElementBase { + type: "MULTI_TEXT"; +} + export interface DataElementFile extends DataElementBase { type: "FILE"; } @@ -62,6 +68,7 @@ type Options = Maybe<{ isMultiple: boolean; items: Option[] }>; type CategoryCombos = { id: Id; name: string; + categories: Array<{ id: Id; code: Code; name: string; categoryOptions: { id: Id; name: string; code: Code }[] }>; categoryOptionCombos: { id: Id; name: string; diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index 46979bb..c506b11 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -14,6 +14,8 @@ import { SectionStyle } from "./SectionStyle"; import { titleVariant } from "./TitleVariant"; import { DataElementToggle } from "./ToggleMultiple"; import { DataElementRuleOptions, TotalRules } from "./DataElementRule"; +import { RulesFormula } from "../../../data/common/RulesFormula"; +import { CompulsoryDataValue } from "./CompulsoryDataValue"; export interface DataForm { id: Id; @@ -27,6 +29,7 @@ export interface DataForm { }; indicators: Indicator[]; totalRules: TotalRules; + compulsoryDataValues: CompulsoryDataValue[]; } export interface Texts { @@ -55,6 +58,7 @@ const viewTypes = [ "matrix-grid", "grid-with-subnational-ous", "grid-indicators-calculated", + "grid-category-columns", ] as const; export type ViewType = UnionFromValues; @@ -76,7 +80,7 @@ export interface SectionBase { | { type: "dataElement"; dataElement: DataElement } | { type: "dataElementExternal"; dataElement: DataElement; condition: string }; texts: Texts; - tabs: { active: boolean; order?: string }; + tabs: { active: boolean; order?: string; rules?: RulesFormula }; sortRowsBy: string; titleVariant: titleVariant; styles: SectionStyle; @@ -87,6 +91,9 @@ export interface SectionBase { showRowTotals: boolean; toggleMultiple?: DataElementToggle; indicators: Indicator[]; + fixedHeaders: boolean; + enableTopScroll: boolean; + fixedRowNames: boolean; } export interface SectionSimple extends SectionBase { @@ -100,12 +107,19 @@ export interface SectionWithPeriods extends SectionBase { export interface SectionGrid extends SectionBase { viewType: "table" | "grid"; + enableGroups: boolean; calculateTotals: CalculateTotalType; + columnsOrder: Maybe; + columnsConfig?: Record; + firstColumnConfig: Maybe<{ + width: number; + }>; } export interface SectionWithTotals extends SectionBase { viewType: "grid-with-totals"; calculateTotals: CalculateTotalType; + columnsOrder: Maybe; } export interface SectionWithSubnationals extends SectionBase { @@ -122,6 +136,19 @@ export interface SectionWithIndicatorsCalculated extends SectionBase { virtualRows: { rowConstantCode: string; dataElementCode: string; rowName: string }[]; } +export interface SectionWithCategoryColumns extends SectionBase { + viewType: "grid-category-columns"; + categoriesColumns: CategoryColumnConfig[]; + showCalculatedTotals: boolean; + rowsConfig: Maybe; + singleCategoryInColumns: boolean; +} + +export type RowConfig = Record; +export type RowConfigDetails = { cellsVisible: boolean; rowName: Maybe }; + +export type CategoryColumnConfig = { dataElementCode: Code; categoryCode: Code }; + export type BaseVirtualColumn = { dataElementCode: string; columnName: string; @@ -147,7 +174,10 @@ export type Section = | SectionWithPeriods | SectionWithTotals | SectionWithSubnationals - | SectionWithIndicatorsCalculated; + | SectionWithIndicatorsCalculated + | SectionWithCategoryColumns; + +export type ColumnOrder = Record; export class DataFormM { static viewTypes = viewTypes; diff --git a/src/domain/common/entities/DataValue.ts b/src/domain/common/entities/DataValue.ts index e4ed4fa..8de06da 100644 --- a/src/domain/common/entities/DataValue.ts +++ b/src/domain/common/entities/DataValue.ts @@ -6,6 +6,7 @@ import { DataElementBoolean, DataElementDate, DataElementFile, + DataElementMultiText, DataElementNumber, DataElementPercentage, DataElementText, @@ -15,6 +16,7 @@ export interface DataValueBase { orgUnitId: Id; period: Period; categoryOptionComboId: Id; + isRequired?: boolean; } export interface DataValueBoolean extends DataValueBase { @@ -59,6 +61,13 @@ export interface DataValueTextMultiple extends DataValueBase { values: string[]; } +export interface DataValueMultiText extends DataValueBase { + type: "MULTI_TEXT"; + isMultiple: true; + dataElement: DataElementMultiText; + values: string[]; +} + export interface DataValueFile extends DataValueBase { type: "FILE"; isMultiple: false; @@ -95,7 +104,8 @@ export type DataValue = | DataValueTextSingle | DataValueTextMultiple | DataValueFile - | DataValueDate; + | DataValueDate + | DataValueMultiText; export type Period = string; @@ -157,6 +167,8 @@ function getEmpty(dataElement: DataElement, base: DataValueBase): DataValue { return dataElement.options?.isMultiple ? { ...base, dataElement, type: "TEXT", isMultiple: true, values: [] } : { ...base, dataElement, type: "TEXT", isMultiple: false, value: "" }; + case "MULTI_TEXT": + return { ...base, dataElement, type: "MULTI_TEXT", isMultiple: true, values: [] }; case "PERCENTAGE": return { ...base, dataElement, type: "PERCENTAGE", isMultiple: false, value: "" }; case "FILE": @@ -179,3 +191,5 @@ function getStoreKey(options: { .compact() .join("."); } + +export const MULTI_TEXT_SEPARATOR = ","; diff --git a/src/domain/common/entities/SectionStyle.ts b/src/domain/common/entities/SectionStyle.ts index 81566f8..4606083 100644 --- a/src/domain/common/entities/SectionStyle.ts +++ b/src/domain/common/entities/SectionStyle.ts @@ -1,7 +1,7 @@ import { Maybe } from "../../../utils/ts-utils"; export const DEFAULT_SECTION_STYLES = { - title: { backgroundColor: "transparent", color: "#ffffff" }, + title: { backgroundColor: "transparent", color: "#000000" }, columns: { backgroundColor: "#f3f5f7", color: "#404b5a" }, rows: { backgroundColor: "#ffffff", color: "#404b5a" }, totals: { backgroundColor: "#ffffff", color: "#000000" }, diff --git a/src/domain/common/usecases/GetDataFormValuesUseCase.ts b/src/domain/common/usecases/GetDataFormValuesUseCase.ts index 287ac1c..b49d875 100644 --- a/src/domain/common/usecases/GetDataFormValuesUseCase.ts +++ b/src/domain/common/usecases/GetDataFormValuesUseCase.ts @@ -1,12 +1,15 @@ import { Id } from "@eyeseetea/d2-api"; -import { DataValueStore, Period } from "../entities/DataValue"; +import { DataValue, DataValueStore, Period } from "../entities/DataValue"; import { DataValueRepository } from "../repositories/DataValueRepository"; export class GetDataFormValuesUseCase { constructor(private dataValueRepository: DataValueRepository) {} - async execute(dataSetId: Id, options: { orgUnits: Id[]; periods: Period[] }): Promise { + async execute( + dataSetId: Id, + options: { orgUnits: Id[]; periods: Period[] } + ): Promise<{ store: DataValueStore; dataValues: DataValue[] }> { const dataValues = await this.dataValueRepository.get({ dataSetId: dataSetId, ...options }); - return DataValueStore.from(dataValues); + return { store: DataValueStore.from(dataValues), dataValues }; } } diff --git a/src/domain/common/usecases/SaveDataFormValue.ts b/src/domain/common/usecases/SaveDataFormValue.ts index b4f50a8..b474ef5 100644 --- a/src/domain/common/usecases/SaveDataFormValue.ts +++ b/src/domain/common/usecases/SaveDataFormValue.ts @@ -1,19 +1,37 @@ import _ from "lodash"; +import { getValueAccordingType } from "../../../webapp/reports/autogenerated-forms/DataEntryItem"; +import { CompulsoryDataValue } from "../entities/CompulsoryDataValue"; import { DataValue, DataValueStore } from "../entities/DataValue"; import { DataValueRepository } from "../repositories/DataValueRepository"; export class SaveDataFormValueUseCase { constructor(private dataValueRepository: DataValueRepository) {} - async execute(store: DataValueStore, dataValue: DataValue): Promise { + async execute( + store: DataValueStore, + dataValue: DataValue, + compulsoryDataValues: CompulsoryDataValue[] + ): Promise { const existingDataValue = store.get(dataValue.dataElement, dataValue); if (_.isEqual(existingDataValue, dataValue) && dataValue.type !== "FILE") { return store; } else { + const isCompulsory = compulsoryDataValues.find( + cdv => + cdv.dataElementId === dataValue.dataElement.id && + cdv.categoryOptionComboId === dataValue.categoryOptionComboId + ); + + const value = getValueAccordingType(dataValue); + + const isEmpty = _.isNil(value) || value.toString().trim() === ""; + let storeUpdated = store.set({ ...dataValue, categoryOptionComboId: dataValue.dataElement.cocId || dataValue.categoryOptionComboId, + isRequired: isCompulsory ? isEmpty : false, }); + const dataValueWithUpdate = await this.dataValueRepository.save(dataValue); if (dataValueWithUpdate.type === "FILE") { storeUpdated = store.set({ diff --git a/src/domain/common/usecases/__tests__/SaveDataFormValue.spec.ts b/src/domain/common/usecases/__tests__/SaveDataFormValue.spec.ts index cc05042..63b1116 100644 --- a/src/domain/common/usecases/__tests__/SaveDataFormValue.spec.ts +++ b/src/domain/common/usecases/__tests__/SaveDataFormValue.spec.ts @@ -21,7 +21,7 @@ describe("SaveDataFormValueUseCase", () => { const stubDataValueStore = instance(mockDataValueStore); - const result = await useCase.execute(stubDataValueStore, dataValue); + const result = await useCase.execute(stubDataValueStore, dataValue, []); verify(mockDataValueStore.get(dataValue.dataElement, dataValue)).once(); verify(mockDataValueStore.set(anything())).never(); @@ -39,7 +39,7 @@ describe("SaveDataFormValueUseCase", () => { const dataValueStore = DataValueStore.from([dataValue]); const saveDataValueStore = DataValueStore.from([saveDataValue]); - const result = await useCase.execute(dataValueStore, saveDataValue); + const result = await useCase.execute(dataValueStore, saveDataValue, []); verify(mockDataValueRepository.save(deepEqual(saveDataValue))).once(); expect(result).toStrictEqual(saveDataValueStore); @@ -63,7 +63,7 @@ describe("SaveDataFormValueUseCase", () => { const dataValueStore = DataValueStore.from([dataValue]); const saveDataValueStore = DataValueStore.from([saveDataValue]); - const result = await useCase.execute(dataValueStore, saveDataValue); + const result = await useCase.execute(dataValueStore, saveDataValue, []); verify(mockDataValueRepository.save(deepEqual(saveDataValue))).once(); expect(result).toStrictEqual(saveDataValueStore); diff --git a/src/domain/common/usecases/__tests__/data/dataValue.ts b/src/domain/common/usecases/__tests__/data/dataValue.ts index a44afdf..be9eddd 100644 --- a/src/domain/common/usecases/__tests__/data/dataValue.ts +++ b/src/domain/common/usecases/__tests__/data/dataValue.ts @@ -13,6 +13,7 @@ export const dataElement: Omit = { categoryCombos: { id: "1", name: "Combo", + categories: [], categoryOptionCombos: [ { id: "1", @@ -26,6 +27,7 @@ export const dataElement: Omit = { rules: [], htmlText: undefined, related: undefined, + disabled: false, }; export const dataValueText: DataValueTextSingle = { @@ -36,6 +38,7 @@ export const dataValueText: DataValueTextSingle = { isMultiple: false, type: "TEXT", value: "10", + isRequired: false, }; export const dataValueTextMultiple: DataValueTextMultiple = { @@ -63,6 +66,7 @@ export const dataValueNumberSingle: DataValueNumberSingle = { value: "10", type: "NUMBER", isMultiple: false, + isRequired: false, }; export const dataValueFile: DataValueFile = { @@ -78,4 +82,5 @@ export const dataValueFile: DataValueFile = { size: 1024, url: "/path/to/file", }, + isRequired: false, }; diff --git a/src/webapp/reports/autogenerated-forms-configurator/schemas/dataSets/index.ts b/src/webapp/reports/autogenerated-forms-configurator/schemas/dataSets/index.ts index 3461669..3cda913 100644 --- a/src/webapp/reports/autogenerated-forms-configurator/schemas/dataSets/index.ts +++ b/src/webapp/reports/autogenerated-forms-configurator/schemas/dataSets/index.ts @@ -12,6 +12,7 @@ export const viewTypes = [ "matrix-grid", "grid-with-periods", "grid-with-subnational-ous", + "grid-indicators-calculated", ]; export const getDataSetSchema = ( diff --git a/src/webapp/reports/autogenerated-forms-configurator/schemas/dataSets/section.ts b/src/webapp/reports/autogenerated-forms-configurator/schemas/dataSets/section.ts index 117ba78..a40dc8c 100644 --- a/src/webapp/reports/autogenerated-forms-configurator/schemas/dataSets/section.ts +++ b/src/webapp/reports/autogenerated-forms-configurator/schemas/dataSets/section.ts @@ -85,6 +85,45 @@ export const sectionSchema = ( }, }, }, + columnsConfig: { + type: "object", + }, + columnsOrder: { + type: "object", + additionalProperties: { + type: "object", + }, + }, + virtualRows: { + type: "array", + items: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + }, + virtualColumns: { + type: "array", + items: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + }, + fixedHeaders: { + type: "boolean", + }, + fixedRowNames: { + type: "boolean", + }, + enableGroups: { + type: "boolean", + }, + enableTopScroll: { + type: "boolean", + }, disableComments: { type: "boolean", }, diff --git a/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx b/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx index 5a4c62e..70cb103 100644 --- a/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx +++ b/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx @@ -40,9 +40,10 @@ import { AlertRule } from "../../components/alert-rule/AlertRule"; import { IgnoreValidationRule, ValidationResult } from "../../../domain/common/entities/ValidationResult"; import MatrixGridForm from "./MatrixGridForm"; import GridIndicatorsCalculated from "./GridIndicatorsCalculated"; +import GridWithCategoryColumns from "./GridWithCategoryColumns"; const AutogeneratedForm: React.FC = () => { - const [dataFormInfo, isLoading, rules, ignoreRules, onCloseAlert] = useDataFormInfo(); + const [dataFormInfo, isLoading, rules, ignoreRules, onCloseAlert, loadingCompulsory] = useDataFormInfo(); if (!dataFormInfo) return
{i18n.t("Loading...")}
; const { dataForm } = dataFormInfo.metadata; @@ -69,7 +70,7 @@ const AutogeneratedForm: React.FC = () => { return (
- {} + {} {rules.length > 0 && ( @@ -146,6 +147,14 @@ const AutogeneratedForm: React.FC = () => { section={section as SectionWithIndicatorsCalculated} /> ); + case "grid-category-columns": + return ( + + ); default: assertUnreachable(viewType); } @@ -162,7 +171,8 @@ function useDataFormInfo(): [ boolean, ValidationResult[], IgnoreValidationRule[], - (ValidationResult: ValidationResult) => void + (ValidationResult: ValidationResult) => void, + boolean ] { const [key] = React.useState(0); const { compositionRoot, config } = useAppContext(); @@ -172,15 +182,56 @@ function useDataFormInfo(): [ const [isLoading, loadingActions] = useBooleanState(false); const [ignoreRules, setIgnoreRules] = React.useState([]); const [rules, setRules] = React.useState([]); + const [loadingCompulsory, setLoadingCompulsory] = React.useState(false); const snackbar = useSnackbar(); const defaultCategoryOptionComboId = config.categoryOptionCombos.default.id; + const orgUnits = React.useMemo(() => { + return _(dataForm?.sections) + .flatMap(section => (section.viewType === "grid-with-subnational-ous" ? section.subNationals : [])) + .map(de => de.id) + .compact() + .value(); + }, [dataForm]); + + React.useEffect(() => { + if (!dataForm) return; + // add event listener to button #completeButton + const completeButton = document.getElementById("completeButton"); + function handleCompleteButtonClick() { + // Data Entry display a different popup error for custom forms after complete/incomplete action + document.querySelector("#headerMessage")?.remove(); + setLoadingCompulsory(true); + compositionRoot.dataForms + .getValues(dataSetId, { + orgUnits: orgUnits.length > 0 ? orgUnits : [orgUnitId], + periods: dataForm ? DataFormM.getReferencedPeriods(dataForm, period) : [], + }) + .then(({ store, dataValues }) => { + const requiredDataValues = dataValues.filter(dv => dv.isRequired); + if (requiredDataValues.length > 0) { + window.dhis2?.de.displayValidationDialog( + `

Validation Result  

This form has compulsory fields. Please fill those items marked red in the form.

`, + 300 + ); + } + setDataValues(store); + setLoadingCompulsory(false); + }) + .catch(() => { + setLoadingCompulsory(false); + }); + } + completeButton?.addEventListener("click", handleCompleteButtonClick); + return () => completeButton?.removeEventListener("click", handleCompleteButtonClick); + }, [dataForm, orgUnits, compositionRoot.dataForms, dataSetId, orgUnitId, period]); + React.useEffect(() => { // Hiding arrows for input of type number // adding directly to css works in dev, but not in data entry. const css = - "input::-webkit-outer-spin-button,input::-webkit-inner-spin-button {-webkit-appearance: none !important; margin: 0; } input[type=number] { -moz-appearance: textfield !important; }", + 'input::-webkit-outer-spin-button,input::-webkit-inner-spin-button {-webkit-appearance: none !important; margin: 0; } input[type=number] { -moz-appearance: textfield !important; }[data-test="dhis2-uicore-layer"] {z-index: 10 !important;}', head = document.head || document.getElementsByTagName("head")[0], style = document.createElement("style"); style.setAttribute("id", "disabled-arrows-css"); @@ -210,11 +261,6 @@ function useDataFormInfo(): [ useEffect(() => { if (!dataForm) return; - const orgUnits = _(dataForm.sections) - .flatMap(section => (section.viewType === "grid-with-subnational-ous" ? section.subNationals : [])) - .map(de => de.id) - .compact() - .value(); loadingActions.enable(); compositionRoot.dataForms @@ -222,16 +268,16 @@ function useDataFormInfo(): [ orgUnits: orgUnits.length > 0 ? orgUnits : [orgUnitId], periods: DataFormM.getReferencedPeriods(dataForm, period), }) - .then(dataValues => { + .then(({ store }) => { setRules([]); setIgnoreRules([]); setDataValues(undefined); - setDataValues(dataValues); + setDataValues(store); }) .finally(() => { loadingActions.disable(); }); - }, [dataForm, compositionRoot, orgUnitId, period, reloadKey, loadingActions]); + }, [dataForm, orgUnits, compositionRoot, orgUnitId, period, reloadKey, loadingActions]); const onCloseAlert = React.useCallback((rule: ValidationResult) => { setIgnoreRules(prev => { @@ -243,20 +289,22 @@ function useDataFormInfo(): [ const saveDataValue = useCallback( async (dataValue: DataValue) => { if (!dataValues) return dataValues; - await compositionRoot.dataForms.saveValue(dataValues, dataValue).then(newStore => { - setDataValues(prev => { - if (!prev) return undefined; - return { - get: newStore.get, - set: newStore.set, - getOrEmpty: newStore.getOrEmpty, - store: { - ...prev.store, - ...newStore.store, - }, - }; + await compositionRoot.dataForms + .saveValue(dataValues, dataValue, dataForm?.compulsoryDataValues ?? []) + .then(newStore => { + setDataValues(prev => { + if (!prev) return undefined; + return { + get: newStore.get, + set: newStore.set, + getOrEmpty: newStore.getOrEmpty, + store: { + ...prev.store, + ...newStore.store, + }, + }; + }); }); - }); if (dataForm) { compositionRoot.dataSet .validate(dataForm.id, { cacheKey: key, period: dataValue.period, orgUnitId: dataValue.orgUnitId }) @@ -339,7 +387,7 @@ function useDataFormInfo(): [ } : undefined; - return [dataFormInfo, isLoading, rules, ignoreRules, onCloseAlert]; + return [dataFormInfo, isLoading, rules, ignoreRules, onCloseAlert, loadingCompulsory]; } export interface DataFormInfo { diff --git a/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx b/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx index 2bbd99a..f7fa598 100644 --- a/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx +++ b/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { DataValue, DateObj } from "../../../domain/common/entities/DataValue"; +import { DataValue, DateObj, MULTI_TEXT_SEPARATOR } from "../../../domain/common/entities/DataValue"; import { assertUnreachable, Maybe } from "../../../utils/ts-utils"; import BooleanDropdownWidget from "./widgets/BooleanDropdownWidget"; import NumberWidget from "./widgets/NumberWidget"; @@ -71,6 +71,8 @@ export function getValueAccordingType(dataValue: DataValue): Value { return dataValue.isMultiple ? dataValue.values.join(",") : dataValue.value; case "PERCENTAGE": return dataValue.value; + case "MULTI_TEXT": + return dataValue.values.join(MULTI_TEXT_SEPARATOR); default: assertUnreachable(dataValue); } @@ -228,6 +230,8 @@ const DataEntryItem: React.FC = props => { const { dataElement, dataFormInfo, manualyDisabled: handDisabled, rows } = props; const [dataValue, state, notifyChange] = useUpdatableDataValueWithFeedback(props); + const hasCompulsoryError = dataValue.isRequired ? "required" : state; + const { type } = dataValue; const { options } = dataElement; const disabled = !handDisabled @@ -261,8 +265,18 @@ const DataEntryItem: React.FC = props => { dataValue={dataValue} options={options.items} onValueChange={notifyChange} - state={state} - disabled={disabled} + state={hasCompulsoryError} + disabled={isDisabled || disabled} + /> + ); + case "MULTI_TEXT": + return ( + ); case "TEXT": @@ -273,8 +287,8 @@ const DataEntryItem: React.FC = props => { dataFormInfo={dataFormInfo} options={options.items} onValueChange={notifyChange} - state={state} - disabled={disabled} + state={hasCompulsoryError} + disabled={isDisabled || disabled} sourceTypeDEs={[]} rows={rows} /> @@ -285,7 +299,7 @@ const DataEntryItem: React.FC = props => { dataValue={dataValue} options={options.items} onValueChange={notifyChange} - state={state} + state={hasCompulsoryError} disabled={isDisabled || disabled} /> ) : ( @@ -293,8 +307,8 @@ const DataEntryItem: React.FC = props => { dataValue={dataValue} options={options.items} onValueChange={notifyChange} - state={state} - disabled={disabled} + state={hasCompulsoryError} + disabled={isDisabled || disabled} /> ); } @@ -304,16 +318,16 @@ const DataEntryItem: React.FC = props => { dataValue={dataValue} options={options.items} onValueChange={notifyChange} - state={state} - disabled={disabled} + state={hasCompulsoryError} + disabled={isDisabled || disabled} /> ) : ( ); default: @@ -326,7 +340,7 @@ const DataEntryItem: React.FC = props => { ); @@ -335,7 +349,7 @@ const DataEntryItem: React.FC = props => { ); @@ -344,7 +358,7 @@ const DataEntryItem: React.FC = props => { ); @@ -353,7 +367,7 @@ const DataEntryItem: React.FC = props => { ); @@ -362,7 +376,7 @@ const DataEntryItem: React.FC = props => { ); @@ -371,10 +385,12 @@ const DataEntryItem: React.FC = props => { ); + case "MULTI_TEXT": + return null; default: return assertUnreachable(type); @@ -410,19 +426,36 @@ function useUpdatableDataValueWithFeedback(options: DataEntryItemProps) { const saveWithTotals = dataFormInfo.data.saveWithTotals; const notifyChange = React.useCallback( - dataValue => { + dataValueCb => { setState("saving"); if (columnTotal && columnDataElements && cocId) { - saveWithTotals(dataValue, columnTotal, columnDataElements, cocId) + saveWithTotals(dataValueCb, columnTotal, columnDataElements, cocId) .then(() => setState("saveSuccessful")) .catch(() => setState("saveError")); } else { - save(dataValue) - .then(() => setState("saveSuccessful")) + save(dataValueCb) + .then(() => { + // validate compulsory fields + const value = getValueAccordingType(dataValueCb); + const isRequired = dataFormInfo.metadata.dataForm.compulsoryDataValues.find( + cdv => cdv.dataElementId === dataValue.dataElement.id && cdv.categoryOptionComboId === cocId + ); + const state = + isRequired && (_.isNil(value) || _.isEmpty(value)) ? "required" : "saveSuccessful"; + setState(state); + }) .catch(() => setState("saveError")); } }, - [columnDataElements, columnTotal, save, saveWithTotals, cocId] + [ + columnDataElements, + columnTotal, + save, + saveWithTotals, + cocId, + dataFormInfo.metadata.dataForm.compulsoryDataValues, + dataValue.dataElement.id, + ] ); return [dataValue, state, notifyChange] as const; diff --git a/src/webapp/reports/autogenerated-forms/DataTableSection.tsx b/src/webapp/reports/autogenerated-forms/DataTableSection.tsx index 56fad82..31c4270 100644 --- a/src/webapp/reports/autogenerated-forms/DataTableSection.tsx +++ b/src/webapp/reports/autogenerated-forms/DataTableSection.tsx @@ -74,7 +74,9 @@ const DataTableSection: React.FC = React.memo(props => { return (
-

{section.name}

+

+ {section.name} +

diff --git a/src/webapp/reports/autogenerated-forms/GridForm.tsx b/src/webapp/reports/autogenerated-forms/GridForm.tsx index ffddbdd..b8de641 100644 --- a/src/webapp/reports/autogenerated-forms/GridForm.tsx +++ b/src/webapp/reports/autogenerated-forms/GridForm.tsx @@ -6,17 +6,19 @@ import { TableBody, // @ts-ignore } from "@dhis2/ui"; -import { GridViewModel } from "./GridFormViewModel"; +import { Grid, GridViewModel, Row } from "./GridFormViewModel"; import { DataFormInfo } from "./AutogeneratedForm"; import { SectionGrid } from "../../../domain/common/entities/DataForm"; import { DataElementItem } from "./DataElementItem"; import { makeStyles } from "@material-ui/core"; import DataTableSection from "./DataTableSection"; -import { CustomDataTableCell, CustomDataTableColumnHeader } from "./datatables/CustomDataTables"; +import { CustomDataTableCell, CustomDataTableColumnHeader, fixHeaderClasses } from "./datatables/CustomDataTables"; import { DataTableCellFormula } from "./datatables/DataTableCellFormula"; import { DataTableCellRowName } from "./datatables/DataTableCellRowName"; import _ from "lodash"; import { RowIndicatorItem } from "../../components/IndicatorItem/IndicatorItem"; +import { Maybe } from "../../../utils/ts-utils"; +import { useSyncedScroll } from "./hooks/Scroll"; /* * Convert data forms into table, using "-" as a separator. An example for section ITNs: @@ -43,119 +45,226 @@ const GridForm: React.FC = props => { const grid = React.useMemo(() => GridViewModel.get(section, dataFormInfo), [section, dataFormInfo]); const classes = useStyles(); + const { wrapper1Ref, wrapper2Ref, wrapper2Width } = useSyncedScroll({ enable: section.enableTopScroll }); + + const fixColumns = section.fixedHeaders; + const fixRows = section.fixedRowNames; + const mainContentStyles = fixColumns ? fixHeaderClasses.fixedHeaders : {}; + const firstColumnWidth = section.firstColumnConfig?.width || 800; + return ( - - - - {grid.useIndexes ? ( - - #{" "} - - ) : ( - !_.isEmpty(grid.rows) && ( + {section.enableTopScroll && ( +
+
+
+ )} + +
+ + + + {grid.hasGroups && ( + <> + + + + )} + {grid.useIndexes ? ( - ) - )} - - {grid.columns.map(column => ( - -
- {column.name} - -
-
- ))} -
-
- - - {grid.rows.map((row, idx) => ( - - - - - - {row.items.map((item, idx) => - item.dataElement ? ( - - - - ) : ( - + width="30px" + > + #{" "} + + ) : ( + !_.isEmpty(grid.rows) && ( + ) )} - - ))} - {grid.summary.map(summary => ( - - - {summary.cellName} - - {summary.cells.map(itemTotal => { + + {grid.columns.map(column => { + if (!column.isVisible) { + return null; + } return ( - + +
+ {column.name} + +
+
); })}
- ))} - - {section.indicators.map(indicator => { - return ( - - ); - })} -
-
+ + + + {grid.rows.map((row, idx) => { + return ( + + + + + + + + + + {row.items.map((item, idx) => { + if (!item.isVisible) return null; + + return item.dataElement ? ( + + + + ) : ( + + ); + })} + + ); + })} + {grid.summary.map(summary => ( + + + {summary.cellName} + + {summary.cells.map(itemTotal => { + return ( + + ); + })} + + ))} + + {section.indicators.map(indicator => { + return ( + + ); + })} + + +
); }; +const GridGroups: React.FC<{ grid: Grid; row: Row; backgroundColor: Maybe; fixed?: boolean }> = props => { + const { grid, row, backgroundColor, fixed } = props; + if (!grid.hasGroups) return null; + if (!grid.groupInfo) return null; + if (!row.group) return null; + + const groupInfo = grid.groupInfo[row.group]; + + if (groupInfo?.rowName !== row.name) return null; + + return ( + + + + ); +}; + +const GridSubGroups: React.FC<{ grid: Grid; row: Row; backgroundColor: Maybe; fixed?: boolean }> = props => { + const { grid, row, backgroundColor, fixed } = props; + if (!grid.hasGroups) return null; + if (!grid.subGroupInfo) return null; + if (!row.subGroup) return null; + + const groupInfo = grid.subGroupInfo[row.subGroup]; + + if (groupInfo?.rowName !== row.name) return null; + + return ( + + + + ); +}; + const useStyles = makeStyles({ wrapper: { margin: 10 }, header: { @@ -169,6 +278,7 @@ const useStyles = makeStyles({ description: { fontWeight: "normal", fontSize: "0.8em" }, table: { borderWidth: "3px !important" }, columnWidth: { minWidth: "14.25em !important" }, + tableHeader: { position: "sticky", top: 0, left: 0, zIndex: 3 }, }); export default React.memo(GridForm); diff --git a/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts b/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts index c70a1cb..d29340e 100644 --- a/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts +++ b/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts @@ -1,5 +1,5 @@ import _ from "lodash"; -import { Section, SectionGrid, Texts } from "../../../domain/common/entities/DataForm"; +import { ColumnOrder, Section, SectionGrid, Texts } from "../../../domain/common/entities/DataForm"; import { DataElement, DataElementNumber } from "../../../domain/common/entities/DataElement"; import { titleVariant } from "../../../domain/common/entities/TitleVariant"; import { Maybe } from "../../../utils/ts-utils"; @@ -7,6 +7,8 @@ import { getFormulaByColumnName, Summary, TotalItem } from "./GridWithCatOptionC import { DataFormInfo } from "./AutogeneratedForm"; import { getDescription } from "../../../utils/viewTypes"; import { getIndicatorRelatedToDataElement, Indicator } from "../../../domain/common/entities/Indicator"; +import { Code } from "../../../domain/common/entities/Base"; +import { calculateFormula } from "./datatables/InputFormula"; export interface Grid { dataElements: Array }>; @@ -21,6 +23,10 @@ export interface Grid { titleVariant: titleVariant; summary: Summary[]; indicators: Indicator[]; + rowGroups: RowGroup[]; + hasGroups: boolean; + groupInfo: Record; + subGroupInfo: Record; } interface SubSectionGrid { @@ -29,17 +35,22 @@ interface SubSectionGrid { } interface Column { + code: Code; name: string; description?: string; isSourceType: boolean; + isVisible: boolean; } -interface Row { +export interface Row { indicator: Maybe; includePadding: number; name: string; htmlText: string; + group: Maybe; + subGroup: Maybe; items: Array<{ + isVisible: boolean; column: Column; columnTotal: Maybe; columnDataElements: DataElement[]; @@ -51,11 +62,32 @@ interface Row { const separator = " - "; +type ColumnScoreInput = { + columnName: string; + allDataElements: DataElement[]; + priorityByCode: ColumnOrder; +}; + +type RowNameParts = { + group: Maybe; + subGroup: Maybe; + rowName: string; + columnName: string; +}; + +type RowGroup = { + group: Maybe; + subGroup: Maybe; + rows: Row[]; +}; + export class GridViewModel { static get(section: SectionGrid, dataFormInfo: DataFormInfo): Grid { const dataElementsConfig = dataFormInfo.metadata.dataForm.options.dataElements; const dataElements = getDataElementsWithIndexProccessing(section); + const groupsByRow = section.enableGroups ? GridViewModel.groupsByRowName(dataElements) : undefined; + const subsections = _(dataElements) .groupBy(dataElement => getSubsectionName(dataElement)) .toPairs() @@ -70,16 +102,36 @@ export class GridViewModel { ) .value(); - const columns: Column[] = _(subsections) + const baseColumns = _(subsections) .flatMap(subsection => subsection.dataElements) .uniqBy(de => de.name) - .map(({ id, name }) => { + .map(({ id, code, name }) => { const columnDescription = getDescription(section.columnsDescriptions, dataFormInfo, name); const config = dataElementsConfig[id]; - return { isSourceType: isSourceTypeColumn(config?.widget), name: name, description: columnDescription }; + return { + code: code, + isVisible: true, + isSourceType: isSourceTypeColumn(config?.widget), + name: name, + description: columnDescription, + }; }) .value(); + const columnsOrders = section.columnsOrder + ? _(baseColumns) + .sortBy(column => { + return this.scoreColumnByLowestDEPriority({ + columnName: column.name, + allDataElements: dataElements, + priorityByCode: section.columnsOrder ?? {}, + }); + }) + .value() + : baseColumns; + + const columns = this.addVisibleToColumns({ columns: columnsOrders, dataFormInfo, section }); + const dataElementsByTotal = _(section.calculateTotals) .groupBy(item => item?.totalDeCode) .map((group, totalColumn) => ({ @@ -108,11 +160,12 @@ export class GridViewModel { ); return { + isVisible: column.isVisible, column: column, columnTotal: parentTotal, columnDataElements: columnDataElements, dataElement: dataElement, - disabled: deCalculateTotal?.disabled ?? false, + disabled: deCalculateTotal?.disabled || dataElement?.disabled || false, disableComments: section.disableComments || dataElement?.disabledComments || false, }; }); @@ -124,12 +177,18 @@ export class GridViewModel { const firstItemWithHtmlText = _(itemsWithHtmlText).first() || ""; + const lastPartSubSection = groupsByRow ? _(subsection.name).split(separator).last() ?? "" : undefined; + const groupMeta = lastPartSubSection && groupsByRow ? groupsByRow[lastPartSubSection] : undefined; + const rowName = groupsByRow ? lastPartSubSection ?? "" : subsection.name; + return { includePadding: 0, indicator: indicator, - name: subsection.name, + name: rowName, htmlText: firstItemWithHtmlText, items: items, + group: groupMeta ? groupMeta.group : undefined, + subGroup: groupMeta ? groupMeta.subGroup : undefined, }; }); @@ -172,6 +231,11 @@ export class GridViewModel { .compact() .value(); + const rowGroups = GridViewModel.buildRowGroups(rows); + + const groupInfo = this.buildGroupInfo(rows, "group"); + const subGroupInfo = this.buildGroupInfo(rows, "subGroup"); + return { id: section.id, indicators: @@ -191,9 +255,42 @@ export class GridViewModel { const indicator = getIndicatorRelatedToDataElement(section.indicators, dataElement.code); return { ...dataElement, indicator }; }), + rowGroups, + hasGroups: section.enableGroups && rowGroups.length > 0, + groupInfo: groupInfo, + subGroupInfo: subGroupInfo, }; } + private static buildRowGroups(rows: Row[]): RowGroup[] { + const groupSeparator = "||"; + + const rowsWithGroup = rows.filter(row => row.group && row.subGroup); + if (rowsWithGroup.length === 0) return []; + + return _(rowsWithGroup) + .groupBy(row => `${row.group}${groupSeparator}${row.subGroup}`) + .toPairs() + .map(([key, groupedRows]): Maybe => { + const [group, subGroup] = key.split(groupSeparator); + if (!group || !subGroup) return undefined; + return { group: group, subGroup: subGroup, rows: groupedRows }; + }) + .compact() + .value(); + } + + private static groupsByRowName( + dataElements: DataElement[] + ): Record; subGroup: Maybe }> { + return _(dataElements) + .map(dataElement => this.parseNameParts(dataElement.name)) + .filter(group => Boolean(group.rowName)) + .keyBy(group => group.rowName) + .mapValues(item => ({ group: item.group, subGroup: item.subGroup })) + .value(); + } + static getColumnWithDataElements(selectedDataElements: DataElement[], columnName: string): TotalItem[] { const hasInvalidDataElement = selectedDataElements.some(dataElement => { if (dataElement.type !== "NUMBER") return true; @@ -218,8 +315,108 @@ export class GridViewModel { .compact() .value(); } + + private static scoreColumnByLowestDEPriority({ + columnName, + allDataElements, + priorityByCode, + }: ColumnScoreInput): number { + const candidates = allDataElements.filter(de => { + const last = _.last(_.split(de.name, separator)); + return last === columnName; + }); + + const scores = candidates.map(de => priorityByCode[de.code]); + + return scores.length > 0 ? (_.min(scores) as number) : Number.MAX_SAFE_INTEGER; + } + + private static parseNameParts(fullName: string): RowNameParts { + // Supported Pattern + // - [Row, Column] + // - [Group, SubGroup, Row, Column] + + const parts = _(fullName) + .split(separator) + .map(s => s.trim()) + .value(); + + if (parts.length === 4) { + const [group, subGroup, rowName, columnName] = parts; + return { group, subGroup: subGroup, rowName: rowName ?? "", columnName: columnName ?? "" }; + } + + if (parts.length === 2) { + const [rowName, columnName] = parts; + return { rowName: rowName ?? "", columnName: columnName ?? "", group: undefined, subGroup: undefined }; + } + + // Fallback to original pattern [Row - Column] + const rowName = parts.join(separator).trim(); + return { rowName, columnName: "", group: undefined, subGroup: undefined }; + } + + private static buildGroupInfo(rows: Row[], propertyName: "group" | "subGroup"): Record { + const grouped = _(rows) + .groupBy(row => row[propertyName]) + .value(); + + const spanByProperty = _(rows) + .groupBy(row => row[propertyName]) + .map((item, key) => { + return [key, item.length]; + }) + .fromPairs() + .value(); + + return _(grouped) + .mapValues((items): GroupInfo => { + const first = items[0]; + const groupName = first[propertyName] ?? ""; + return { + rowSpan: spanByProperty[groupName] ?? 0, + rowName: first.name, + }; + }) + .value(); + } + + private static addVisibleToColumns(options: { + section: SectionGrid; + columns: Column[]; + dataFormInfo: DataFormInfo; + }): Column[] { + const { section, columns, dataFormInfo } = options; + if (!section.columnsConfig) return columns; + + return _(columns) + .map(column => { + const config = section.columnsConfig ? section.columnsConfig[column.code] : undefined; + if (!config) return { ...column, isVisible: true }; + + const dataElementCodes = _(config.rules?.visible?.dataElements) + .map(dataElement => dataElement.code) + .compact() + .value(); + + const value = calculateFormula({ + dataElementCodes: dataElementCodes, + dataFormInfo, + formula: config.rules?.visible?.formula.value ?? "", + period: dataFormInfo.period, + }); + + return { ...column, isVisible: value === config.rules?.visible?.formula.condition }; + }) + .value(); + } } +type GroupInfo = { + rowSpan: number; + rowName: string; +}; + /** Move the data element index to the row name, so indexed data elements are automatically grouped Input: diff --git a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx new file mode 100644 index 0000000..799dcfe --- /dev/null +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx @@ -0,0 +1,202 @@ +import _ from "lodash"; +import React from "react"; +import { + DataTable, + TableHead, + DataTableRow, + TableBody, + // @ts-ignore +} from "@dhis2/ui"; +import { DataFormInfo } from "./AutogeneratedForm"; +import { SectionWithCategoryColumns } from "../../../domain/common/entities/DataForm"; +import { DataElementItem } from "./DataElementItem"; +import { makeStyles } from "@material-ui/core"; +import DataTableSection from "./DataTableSection"; +import { CustomDataTableCell, CustomDataTableColumnHeader, fixHeaderClasses } from "./datatables/CustomDataTables"; +import { GridWithCategoryColumnsViewModel } from "./GridWithCategoryColumnsViewModel"; +import i18n from "../../../locales"; +import { CustomInput } from "./widgets/NumberWidget"; +import { useSyncedScroll } from "./hooks/Scroll"; + +export interface GridWithCategoryColumnsProps { + dataFormInfo: DataFormInfo; + section: SectionWithCategoryColumns; +} + +const GridWithCategoryColumns: React.FC = props => { + const { section, dataFormInfo } = props; + const grid = React.useMemo( + () => GridWithCategoryColumnsViewModel.get(section, dataFormInfo), + [section, dataFormInfo] + ); + + const { wrapper1Ref, wrapper2Ref, wrapper2Width } = useSyncedScroll({ enable: section.enableTopScroll }); + + const fixColumns = section.fixedHeaders; + const fixRows = section.fixedRowNames; + + const totalCategories = _(grid.columns).sumBy(col => col.totalCategories); + + const classes = useStyles(); + + const tableClasses = fixColumns ? `${classes.table} ${classes.fixedHeaders}` : classes.table; + + return ( + + {section.enableTopScroll && ( +
+
+
+ )} +
+ + + + {grid.parentColumns.length > 0 && ( + + )} + {grid.parentColumns.map(column => { + return ( + + {column.name} + + ); + })} + + + + + + {grid.columns.map(column => ( + + {column.name} + + ))} + + + {totalCategories > 0 && ( + + + + {grid.columns.map(column => + column.categories?.map((category, idx) => ( + + {category} + + )) + )} + + )} + + + + {grid.rows.map(row => ( + + + {row.name} + + + {row.items.map((item, idx) => + item.dataElement?.cocId && row.cellsVisible ? ( + + + + ) : ( + + ) + )} + + ))} + + {section.showCalculatedTotals && ( + + + {i18n.t("Totals")} + + + {grid.calculateTotals.map((total, idx) => ( + + + + ))} + + )} + + +
+
+ ); +}; + +const useStyles = makeStyles({ + wrapper: { margin: 10 }, + header: { fontSize: "1.4em", fontWeight: "bold" as const }, + table: { borderWidth: "3px !important", minWidth: "100%" }, + td: { minWidth: "400px !important" }, + columnWidth: { minWidth: "6.2em !important" }, + source: { maxWidth: "35% !important", width: "33% !important", minWidth: "15% !important" }, + centerSpan: { + "& span": { + alignItems: "center", + }, + }, + tableHeader: { position: "sticky", top: 0, zIndex: 3 }, + fixedHeaders: fixHeaderClasses.fixedHeaders, +}); + +export default React.memo(GridWithCategoryColumns); diff --git a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts new file mode 100644 index 0000000..0a70739 --- /dev/null +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts @@ -0,0 +1,227 @@ +import _ from "lodash"; +import { Section, SectionWithCategoryColumns, Texts } from "../../../domain/common/entities/DataForm"; +import { DataElement } from "../../../domain/common/entities/DataElement"; +import { makeCocOrderArray } from "../../../data/common/Dhis2DataElement"; +import { Maybe } from "../../../utils/ts-utils"; +import { DataFormInfo } from "./AutogeneratedForm"; + +export interface Grid { + id: string; + name: string; + columns: Column[]; + rows: Row[]; + toggle: Section["toggle"]; + toggleMultiple: Section["toggleMultiple"]; + useIndexes: boolean; + texts: Texts; + parentColumns: ParentColumn[]; + calculateTotals: number[]; +} + +interface Column { + name: string; + categories?: string[]; + totalCategories: number; + parentColumnName?: string; +} + +interface Row { + id: string; + name: string; + cellsVisible: boolean; + items: Array<{ dataElement: Maybe }>; +} + +type ParentColumn = { + name: string; + colSpan: number; +}; + +const separator = " - "; + +export class GridWithCategoryColumnsViewModel { + static get(section: SectionWithCategoryColumns, dataFormInfo: DataFormInfo): Grid { + const { parentColumns, columns } = this.getColumns(section); + const rows = this.buildRows(section); + const calculateTotals = this.calculateTotals(rows, dataFormInfo); + + return { + id: section.id, + name: section.name, + columns: columns, + rows: rows, + toggle: section.toggle, + texts: section.texts, + useIndexes: false, + parentColumns: parentColumns, + toggleMultiple: section.toggleMultiple, + calculateTotals: calculateTotals, + }; + } + + private static getColumns(section: SectionWithCategoryColumns): { + columns: Column[]; + parentColumns: ParentColumn[]; + } { + const columns = section.dataElements.map((dataElement): Column => { + const category = this.getCategoryColumn(dataElement, section.categoriesColumns); + + const columnName = _(dataElement.name).split(separator).last() || ""; + const parentColumnName = _(dataElement.name).split(separator).first() || ""; + + return { + name: columnName, + parentColumnName: parentColumnName, + categories: section.singleCategoryInColumns ? [] : category?.categoryOptions.map(c => c.name) ?? [], + totalCategories: section.singleCategoryInColumns ? 0 : category?.categoryOptions.length || 0, + }; + }); + + const parentColumns = _(columns) + .groupBy(column => column.parentColumnName) + .map((group, name) => { + return { + name: name, + colSpan: _.sumBy(group, c => c.totalCategories || 1), + }; + }) + .value(); + + return { columns: columns, parentColumns: parentColumns }; + } + + private static buildRows(section: SectionWithCategoryColumns): Row[] { + const { dataElements, categoriesColumns } = section; + const items = dataElements.flatMap(dataElement => { + if (dataElement.categoryCombos.categories.length === 1) { + return this.buildItemsForOneCategory(dataElement, section.singleCategoryInColumns); + } + + const allOptions = _(dataElement.categoryCombos.categories) + .flatMap(c => c.categoryOptions.flatMap(co => co)) + .keyBy(x => x.name) + .value(); + + const category = this.getCategoryColumn(dataElement, categoriesColumns); + // Options for the category used for columns + const columnOptions = category?.categoryOptions.map(co => co.name) ?? []; + + // Other categories without the one used for columns + const restCategories = _(dataElement.categoryCombos.categories) + .filter(c => c.code !== category?.code) + .value(); + + const restCategoryOptions = restCategories.map(c => c.categoryOptions.flatMap(co => co.name)); + const combinations = restCategoryOptions.length > 0 ? makeCocOrderArray(restCategoryOptions) : []; + if (combinations.length === 0) return []; + + const dataElementsWithCocId = columnOptions.flatMap(columnOption => { + const columnOptionCode = allOptions[columnOption]?.code; + + return combinations.map(combination => { + const combinationOptionCode = allOptions[combination]?.code; + const cocName = [columnOption].concat(combination).join(", "); + const cocDetails = dataElement.categoryCombos.categoryOptionCombos.find( + coc => coc.name === cocName + ); + + if (!cocDetails) { + console.warn( + `Category option combo with name ${cocName} not found for data element ${dataElement.name}` + ); + } + + return { + ...dataElement, + cocId: cocDetails?.id, + fullName: dataElement.name, + cocName: combination, + disabled: !cocDetails, + cocCodes: _([columnOptionCode, combinationOptionCode]).compact().value(), + }; + }); + }); + return dataElementsWithCocId; + }); + + return _(items) + .groupBy(item => item.cocName) + .map((group, name): Row => { + const id = _(group) + .flatMap(x => x.cocCodes) + .uniq() + .join("-"); + + const rowConfig = section.rowsConfig?.[id]; + + return { + id: id, + name: rowConfig?.rowName ?? name, + cellsVisible: rowConfig?.cellsVisible ?? true, + items: group.map(de => ({ dataElement: de })), + }; + }) + .value(); + } + + private static buildItemsForOneCategory(dataElement: DataElement, inRows: boolean) { + const allOptions = dataElement.categoryCombos.categories.flatMap(c => c.categoryOptions.flatMap(co => co)); + + return dataElement.categoryCombos.categoryOptionCombos.map(coc => ({ + ...dataElement, + cocId: coc.id, + fullName: dataElement.name, + cocName: inRows ? coc.name : "", + disabled: Boolean(coc.id), + cocCodes: [allOptions.find(opt => opt.name === coc.name)?.code ?? ""], + })); + } + + private static getCategoryColumn( + dataElement: DataElement, + categoriesColumnsConfig: SectionWithCategoryColumns["categoriesColumns"] + ) { + const categoryColumnConfig = categoriesColumnsConfig.find( + config => config.dataElementCode === dataElement.code + ); + + return ( + dataElement.categoryCombos.categories.find( + category => category.code === categoryColumnConfig?.categoryCode + ) ?? dataElement.categoryCombos.categories[0] + ); + } + + private static calculateTotals(rows: Row[], dataFormInfo: DataFormInfo): number[] { + const firstRow = rows[0]; + if (!firstRow) return []; + + const columnCount = firstRow.items.length; + + return Array.from({ length: columnCount }, (_value, colIndex) => { + const columnValues = rows.map(row => + this.getDataElementValue(row.items[colIndex]?.dataElement, dataFormInfo) + ); + + if (columnValues.length > 0) { + const firstValue = this.getDataElementValue(firstRow.items[colIndex]?.dataElement, dataFormInfo); + return firstValue ?? _(columnValues).sum(); + } + return 0; + }); + } + + private static getDataElementValue(dataElement: Maybe, dataFormInfo: DataFormInfo): Maybe { + if (!dataElement) return 0; + + const dataValue = dataFormInfo.data.values.get(dataElement, { + categoryOptionComboId: dataElement.cocId ?? dataFormInfo.categoryOptionComboId, + orgUnitId: dataFormInfo.orgUnitId, + period: dataFormInfo.period, + }); + + if (dataValue?.type !== "NUMBER" || dataValue.isMultiple) return 0; + + return dataValue.value ? Number(dataValue.value) : undefined; + } +} diff --git a/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx b/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx index d5fc79d..d075a5e 100644 --- a/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx +++ b/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx @@ -1,3 +1,4 @@ +import _ from "lodash"; import React, { useCallback, useEffect, useState } from "react"; import { Tabs, Tab, Box, makeStyles } from "@material-ui/core"; import { @@ -9,6 +10,7 @@ import { SectionSimple, SectionGrid, SectionWithIndicatorsCalculated, + SectionWithCategoryColumns, } from "../../../domain/common/entities/DataForm"; import TableForm from "./TableForm"; import GridForm from "./GridForm"; @@ -25,6 +27,8 @@ import styled from "styled-components"; import { IconButton } from "material-ui"; import { ChevronLeft, ChevronRight } from "@material-ui/icons"; import GridIndicatorsCalculated from "./GridIndicatorsCalculated"; +import { calculateFormula } from "./datatables/InputFormula"; +import GridWithCategoryColumns from "./GridWithCategoryColumns"; export interface TabPanelProps { sections: Section[]; @@ -110,6 +114,14 @@ function TypeSwitch(props: TypeSwitchProps) { section={section as SectionWithIndicatorsCalculated} /> ); + case "grid-category-columns": + return ( + + ); default: assertUnreachable(viewType); } @@ -236,13 +248,34 @@ const SectionsTabs: React.FC = React.memo(props => { > {sections.flatMap(section => { const order = section.tabs.order; + if (isTabHeader(order)) { + const visibleRule = section.tabs.rules?.visible; + const dataElementCodes = visibleRule?.dataElements.map(de => de.code) || []; + const value = + visibleRule && dataElementCodes.length > 0 + ? calculateFormula({ + dataElementCodes: dataElementCodes, + dataFormInfo: dataFormInfo, + formula: visibleRule.formula.value, + }) + : undefined; + + const isConditionMet = value === visibleRule?.formula.condition; + + if (visibleRule && !isConditionMet) { + return null; + } + + const primaryValue = _(order).split(".").first() ?? "0"; + return ( ); } else { @@ -277,7 +310,6 @@ const useStyles = makeStyles({ }); const StyledAppBar = styled(AppBar)` - top: 48px !important; z-index: 100 !important; `; diff --git a/src/webapp/reports/autogenerated-forms/WidgetFeedback.tsx b/src/webapp/reports/autogenerated-forms/WidgetFeedback.tsx index e1105e5..b540365 100644 --- a/src/webapp/reports/autogenerated-forms/WidgetFeedback.tsx +++ b/src/webapp/reports/autogenerated-forms/WidgetFeedback.tsx @@ -1,7 +1,7 @@ import React from "react"; import { CSSProperties } from "react"; -export type WidgetState = "original" | "saving" | "saveSuccessful" | "saveError"; +export type WidgetState = "original" | "saving" | "saveSuccessful" | "saveError" | "required"; const baseStyles: CSSProperties = { transition: "background-color 0.5s", @@ -13,6 +13,7 @@ export const widgetFeedbackStylesByState: Record = { saving: { ...baseStyles, backgroundColor: "yellow" }, saveSuccessful: { ...baseStyles, backgroundColor: "rgb(185, 255, 185)" }, saveError: { ...baseStyles, backgroundColor: "red" }, + required: { ...baseStyles, backgroundColor: "orange" }, }; export const WidgetFeedback: React.FC<{ state: WidgetState }> = React.memo(props => { diff --git a/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts b/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts index af8c69f..cd1f593 100644 --- a/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts +++ b/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts @@ -4,13 +4,32 @@ import { DataTableColumnHeader, DataTableCell, // @ts-ignore } from "@dhis2/ui"; +import React from "react"; -export const CustomDataTableColumnHeader = styled(DataTableColumnHeader)<{ backgroundColor: string }>` +export const CustomDataTableColumnHeader = styled(DataTableColumnHeader)<{ + backgroundColor: string; + position?: React.CSSProperties["position"]; + left?: React.CSSProperties["left"]; +}>` background-color: ${props => props.backgroundColor} !important; + ${props => props.position && `position: ${props.position} !important;`} + ${props => props.left !== undefined && `left: ${props.left} !important;`} `; -export const CustomDataTableCell = styled(DataTableCell)<{ backgroundColor: string }>` +export const CustomDataTableCell = styled(DataTableCell)<{ + textAlign?: React.CSSProperties["textAlign"]; + writingMode?: React.CSSProperties["writingMode"]; + backgroundColor: string; + position?: React.CSSProperties["position"]; + left?: React.CSSProperties["left"]; + zIndex?: React.CSSProperties["zIndex"]; +}>` background-color: ${props => props.backgroundColor} !important; + writing-mode: ${props => props.writingMode ?? "initial"} !important; + text-align: ${props => props.textAlign ?? "initial"} !important; + position: ${props => (props.position ? props.position : "static")} !important; + left: ${props => (props.left ? props.left : "auto")} !important; + z-index: ${props => (props.zIndex ? props.zIndex : "0")} !important; `; export const fixHeaderClasses = { diff --git a/src/webapp/reports/autogenerated-forms/datatables/InputFormula.tsx b/src/webapp/reports/autogenerated-forms/datatables/InputFormula.tsx index 33c1df8..54cd860 100644 --- a/src/webapp/reports/autogenerated-forms/datatables/InputFormula.tsx +++ b/src/webapp/reports/autogenerated-forms/datatables/InputFormula.tsx @@ -15,7 +15,16 @@ export type InputFormulaProps = { const EMPTY_VALUE = ""; export const InputFormula: React.FC = props => { - const { dataFormInfo, formula, dataElementCodes, period } = props; + const formulaValue = calculateFormula(props); + + const isNaN = window.isNaN(Number(formulaValue)); + const value = !isNaN ? formulaValue : ""; + + return ; +}; + +export function calculateFormula(options: InputFormulaProps): string { + const { dataFormInfo, formula, dataElementCodes, period } = options; const objWithValues = _(dataElementCodes) .map(dataElementCode => { @@ -56,12 +65,9 @@ export const InputFormula: React.FC = props => { const compiled = _.template(formula, { imports: defaultHelpers, }); - const totalValue = compiled(_.merge({}, objWithValues)); - - const isNaN = window.isNaN(Number(totalValue)); - - return ; -}; + const value = compiled(_.merge({}, objWithValues)); + return value; +} type Flag = string | number | null | undefined; diff --git a/src/webapp/reports/autogenerated-forms/hooks/Scroll.ts b/src/webapp/reports/autogenerated-forms/hooks/Scroll.ts new file mode 100644 index 0000000..e186342 --- /dev/null +++ b/src/webapp/reports/autogenerated-forms/hooks/Scroll.ts @@ -0,0 +1,62 @@ +import React from "react"; + +type UseSyncedScrollReturn = { + wrapper1Ref: React.RefObject; + wrapper2Ref: React.RefObject; + wrapper2Width: number; +}; + +export const useSyncedScroll = (props: { enable: boolean }): UseSyncedScrollReturn => { + const { enable } = props; + const wrapper1Ref = React.useRef(null); + const wrapper2Ref = React.useRef(null); + const [width2, setTableWidth2] = React.useState(0); + + React.useEffect(() => { + if (!enable) return; + + const wrapper1 = wrapper1Ref.current; + const wrapper2 = wrapper2Ref.current; + + if (!wrapper1 || !wrapper2) return; + + const handleScroll1 = () => { + if (wrapper2.scrollLeft !== wrapper1.scrollLeft) { + wrapper2.scrollLeft = wrapper1.scrollLeft; + } + }; + + const handleScroll2 = () => { + if (wrapper1.scrollLeft !== wrapper2.scrollLeft) { + wrapper1.scrollLeft = wrapper2.scrollLeft; + } + }; + + wrapper1.addEventListener("scroll", handleScroll1); + wrapper2.addEventListener("scroll", handleScroll2); + + return () => { + wrapper1.removeEventListener("scroll", handleScroll1); + wrapper2.removeEventListener("scroll", handleScroll2); + }; + }, [enable]); + + React.useEffect(() => { + if (!wrapper2Ref.current || !enable) return; + + const el = wrapper2Ref.current.firstElementChild as HTMLElement | null; + if (!el) return; + + const observer = new ResizeObserver(entries => { + for (const entry of entries) { + setTableWidth2(entry.contentRect.width); + } + }); + + observer.observe(el); + + return () => observer.disconnect(); + }, [enable]); + + return { wrapper2Width: width2, wrapper1Ref, wrapper2Ref }; +}; diff --git a/src/webapp/reports/autogenerated-forms/useDataEntrySelector.ts b/src/webapp/reports/autogenerated-forms/useDataEntrySelector.ts index 5775e59..5a8432f 100644 --- a/src/webapp/reports/autogenerated-forms/useDataEntrySelector.ts +++ b/src/webapp/reports/autogenerated-forms/useDataEntrySelector.ts @@ -71,6 +71,7 @@ declare global { getSelectedPeriod(): Maybe; addEventListeners(): void; event: { dataValuesLoaded: string }; + displayValidationDialog: (html: string, width: number) => void; }; util: { on: (event: string, action: () => void) => void; diff --git a/src/webapp/reports/autogenerated-forms/widgets/MultipleSelectWidget.tsx b/src/webapp/reports/autogenerated-forms/widgets/MultipleSelectWidget.tsx index 68a0751..808977a 100644 --- a/src/webapp/reports/autogenerated-forms/widgets/MultipleSelectWidget.tsx +++ b/src/webapp/reports/autogenerated-forms/widgets/MultipleSelectWidget.tsx @@ -4,10 +4,14 @@ import React from "react"; import { MultiSelect, MultiSelectOption } from "@dhis2/ui"; import { Option } from "../../../../domain/common/entities/DataElement"; import { WidgetFeedback } from "../WidgetFeedback"; -import { DataValueNumberMultiple, DataValueTextMultiple } from "../../../../domain/common/entities/DataValue"; +import { + DataValueMultiText, + DataValueNumberMultiple, + DataValueTextMultiple, +} from "../../../../domain/common/entities/DataValue"; import { WidgetProps } from "./WidgetBase"; -type DataValueMultiple = DataValueNumberMultiple | DataValueTextMultiple; +type DataValueMultiple = DataValueNumberMultiple | DataValueTextMultiple | DataValueMultiText; export interface MultipleSelectWidgetProps extends WidgetProps { dataValue: DataValueMultiple;