From 531158446a881160724823c1a2e4c05fd29d8754 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Wed, 27 Aug 2025 15:26:41 -0500 Subject: [PATCH 01/24] add multi text support --- src/data/common/Dhis2DataElement.ts | 10 +++++++++- src/data/common/Dhis2DataValueRepository.ts | 19 ++++++++++++++++--- src/domain/common/entities/DataElement.ts | 7 ++++++- src/domain/common/entities/DataValue.ts | 15 ++++++++++++++- .../autogenerated-forms/DataEntryItem.tsx | 16 +++++++++++++++- .../autogenerated-forms/DataTableSection.tsx | 2 +- .../widgets/MultipleSelectWidget.tsx | 8 ++++++-- 7 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/data/common/Dhis2DataElement.ts b/src/data/common/Dhis2DataElement.ts index 83a41f3..2b8c825 100644 --- a/src/data/common/Dhis2DataElement.ts +++ b/src/data/common/Dhis2DataElement.ts @@ -75,6 +75,12 @@ type D2DataElement = MetadataPick<{ dataElements: { fields: typeof dataElementFields }; }>["dataElements"][number]; +type D2DataElementTypes = D2DataElement["valueType"] | "MULTI_TEXT"; + +type D2DataElementNewType = Omit & { + valueType: D2DataElementTypes; +}; + function makeCocOrderArray(namesArray: string[][]): string[] { return namesArray.reduce((prev, current) => { return prev @@ -140,7 +146,7 @@ function getCocOrdered(categoryCombo: D2DataElement["categoryCombo"], config: Dh 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 @@ -179,6 +185,8 @@ function getDataElement(dataElement: D2DataElement, config: Dhis2DataStoreDataFo 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/Dhis2DataValueRepository.ts b/src/data/common/Dhis2DataValueRepository.ts index a3f1855..7db219d 100644 --- a/src/data/common/Dhis2DataValueRepository.ts +++ b/src/data/common/Dhis2DataValueRepository.ts @@ -7,6 +7,7 @@ import { DataValueTextMultiple, DateObj, FileResource, + MULTI_TEXT_SEPARATOR, Period, } from "../../domain/common/entities/DataValue"; import { DataValueRepository, DataElementRefType } from "../../domain/common/repositories/DataValueRepository"; @@ -55,9 +56,11 @@ export class Dhis2DataValueRepository implements DataValueRepository { categoryOptionComboId: dv.categoryOptionCombo, }; - 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 +72,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 ? { @@ -295,6 +306,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 +324,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/domain/common/entities/DataElement.ts b/src/domain/common/entities/DataElement.ts index 8652118..8884776 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; @@ -49,6 +50,10 @@ export interface DataElementText extends DataElementBase { type: "TEXT"; } +export interface DataElementMultiText extends DataElementBase { + type: "MULTI_TEXT"; +} + export interface DataElementFile extends DataElementBase { type: "FILE"; } diff --git a/src/domain/common/entities/DataValue.ts b/src/domain/common/entities/DataValue.ts index e4ed4fa..a726f26 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, @@ -59,6 +60,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 +103,8 @@ export type DataValue = | DataValueTextSingle | DataValueTextMultiple | DataValueFile - | DataValueDate; + | DataValueDate + | DataValueMultiText; export type Period = string; @@ -157,6 +166,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 +190,5 @@ function getStoreKey(options: { .compact() .join("."); } + +export const MULTI_TEXT_SEPARATOR = ","; diff --git a/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx b/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx index cea7f64..130b870 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); } @@ -260,6 +262,16 @@ const DataEntryItem: React.FC = props => { disabled={disabled} /> ); + case "MULTI_TEXT": + return ( + + ); case "TEXT": if (config?.widget === "sourceType" && dataValue.isMultiple) { return ( @@ -370,6 +382,8 @@ const DataEntryItem: React.FC = props => { disabled={isDisabled || disabled} /> ); + case "MULTI_TEXT": + return null; default: return assertUnreachable(type); diff --git a/src/webapp/reports/autogenerated-forms/DataTableSection.tsx b/src/webapp/reports/autogenerated-forms/DataTableSection.tsx index 56fad82..3f27c5a 100644 --- a/src/webapp/reports/autogenerated-forms/DataTableSection.tsx +++ b/src/webapp/reports/autogenerated-forms/DataTableSection.tsx @@ -97,7 +97,7 @@ const DataTableSection: React.FC = React.memo(props => { }); const useStyles = makeStyles({ - wrapper: { margin: 10, border: "1px solid black" }, + wrapper: { margin: 10, border: "1px solid black", overflow: "auto" }, toggleWrapper: { margin: 10 }, toggleTitle: { marginBottom: 10 }, title: { textAlign: "center", margin: 0, padding: "1em" }, 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; From 98eb6e0b3a2420f4d72541947bfb98503e0fff1f Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Thu, 28 Aug 2025 18:36:31 -0500 Subject: [PATCH 02/24] add option to disable dataElements and fixed columns --- src/data/common/Dhis2DataElement.ts | 1 + src/data/common/Dhis2DataFormRepository.ts | 3 +- src/data/common/Dhis2DataStoreDataForm.ts | 5 + src/domain/common/entities/DataElement.ts | 1 + src/domain/common/entities/DataForm.ts | 2 + .../autogenerated-forms/DataTableSection.tsx | 2 +- .../reports/autogenerated-forms/GridForm.tsx | 206 +++++++++--------- .../autogenerated-forms/GridFormViewModel.ts | 39 +++- 8 files changed, 155 insertions(+), 104 deletions(-) diff --git a/src/data/common/Dhis2DataElement.ts b/src/data/common/Dhis2DataElement.ts index 2b8c825..4364635 100644 --- a/src/data/common/Dhis2DataElement.ts +++ b/src/data/common/Dhis2DataElement.ts @@ -179,6 +179,7 @@ function getDataElement(dataElement: D2DataElementNewType, config: Dhis2DataStor : undefined, rules: [], htmlText: undefined, + disabled: deConfig?.disabled || false, }; switch (valueType) { diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 96c6f62..26a802f 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -151,9 +151,10 @@ export class Dhis2DataFormRepository implements DataFormRepository { toggleMultiple: config?.toggleMultiple ? buildToggleMultiple(config.toggleMultiple, dataElements) : undefined, + fixedHeaders: config?.fixedHeaders || false, }; - if (!config) return { viewType: "table", calculateTotals: undefined, ...base }; + if (!config) return { viewType: "table", calculateTotals: undefined, ...base, fixedHeaders: false }; const base2 = getSectionBaseWithToggle(config, base, dataElements); diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index e10934c..e3d5016 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -57,6 +57,7 @@ interface BaseSectionConfig { totals?: Record; toggleMultiple: Maybe; indicators?: Record; + fixedHeaders: boolean; } interface BasicSectionConfig extends BaseSectionConfig { @@ -181,6 +182,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( @@ -205,6 +207,7 @@ const DataStoreConfigCodec = Codec.interface({ texts: optional(textsCodec), sections: optional( sectionConfig({ + fixedHeaders: optional(boolean), disableComments: optional(boolean), subNationalDataset: optional(string), sortRowsBy: optional(string), @@ -274,6 +277,7 @@ const DataStoreConfigCodec = Codec.interface({ export interface DataElementConfig { rules?: DataElementRuleOptions; + disabled?: boolean; disableComments?: boolean; texts?: Texts; selection?: { @@ -588,6 +592,7 @@ export class Dhis2DataStoreDataForm { totals: this.getSectionTotals(sectionConfig, constantsByCode), toggleMultiple: sectionConfig.toggleMultiple, indicators: sectionConfig.indicators, + fixedHeaders: sectionConfig.fixedHeaders || false, }; const baseConfig = { ...base, viewType }; diff --git a/src/domain/common/entities/DataElement.ts b/src/domain/common/entities/DataElement.ts index 8884776..426e583 100644 --- a/src/domain/common/entities/DataElement.ts +++ b/src/domain/common/entities/DataElement.ts @@ -29,6 +29,7 @@ interface DataElementBase { disabledComments?: boolean; rules: Rule[]; htmlText: Maybe; + disabled: boolean; } export interface DataElementBoolean extends DataElementBase { diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index fb658ae..a9ff4f7 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -81,6 +81,7 @@ export interface SectionBase { showRowTotals: boolean; toggleMultiple?: DataElementToggle; indicators: Indicator[]; + fixedHeaders: boolean; } export interface SectionSimple extends SectionBase { @@ -94,6 +95,7 @@ export interface SectionWithPeriods extends SectionBase { export interface SectionGrid extends SectionBase { viewType: "table" | "grid"; + fixedHeaders: boolean; calculateTotals: CalculateTotalType; } diff --git a/src/webapp/reports/autogenerated-forms/DataTableSection.tsx b/src/webapp/reports/autogenerated-forms/DataTableSection.tsx index 3f27c5a..56fad82 100644 --- a/src/webapp/reports/autogenerated-forms/DataTableSection.tsx +++ b/src/webapp/reports/autogenerated-forms/DataTableSection.tsx @@ -97,7 +97,7 @@ const DataTableSection: React.FC = React.memo(props => { }); const useStyles = makeStyles({ - wrapper: { margin: 10, border: "1px solid black", overflow: "auto" }, + wrapper: { margin: 10, border: "1px solid black" }, toggleWrapper: { margin: 10 }, toggleTitle: { marginBottom: 10 }, title: { textAlign: "center", margin: 0, padding: "1em" }, diff --git a/src/webapp/reports/autogenerated-forms/GridForm.tsx b/src/webapp/reports/autogenerated-forms/GridForm.tsx index ffddbdd..f6b6b39 100644 --- a/src/webapp/reports/autogenerated-forms/GridForm.tsx +++ b/src/webapp/reports/autogenerated-forms/GridForm.tsx @@ -12,7 +12,7 @@ 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"; @@ -43,115 +43,120 @@ const GridForm: React.FC = props => { const grid = React.useMemo(() => GridViewModel.get(section, dataFormInfo), [section, dataFormInfo]); const classes = useStyles(); + const fixColumns = section.fixedHeaders; + const mainContentStyles = fixColumns ? fixHeaderClasses.fixedHeaders : {}; + return ( - - - - {grid.useIndexes ? ( - - #{" "} - - ) : ( - !_.isEmpty(grid.rows) && ( +
+ + + + {grid.useIndexes ? ( - ) - )} + width="30px" + > + #{" "} + + ) : ( + !_.isEmpty(grid.rows) && ( + + ) + )} - {grid.columns.map(column => ( - -
- {column.name} - -
-
- ))} -
-
+ {grid.columns.map(column => ( + +
+ {column.name} + +
+
+ ))} + + - - {grid.rows.map((row, idx) => ( - - - - + + {grid.rows.map((row, idx) => ( + + + + - {row.items.map((item, idx) => - item.dataElement ? ( - - + item.dataElement ? ( + + + + ) : ( + + ) + )} + + ))} + {grid.summary.map(summary => ( + + + {summary.cellName} + + {summary.cells.map(itemTotal => { + return ( + - - ) : ( - - ) - )} - - ))} - {grid.summary.map(summary => ( - - - {summary.cellName} - - {summary.cells.map(itemTotal => { - return ( - - ); - })} - - ))} + ); + })} + + ))} - {section.indicators.map(indicator => { - return ( - - ); - })} - -
+ {section.indicators.map(indicator => { + return ( + + ); + })} + + +
); }; @@ -169,6 +174,7 @@ const useStyles = makeStyles({ description: { fontWeight: "normal", fontSize: "0.8em" }, table: { borderWidth: "3px !important" }, columnWidth: { minWidth: "14.25em !important" }, + tableHeader: { position: "sticky", top: 0, zIndex: 2 }, }); 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..fa9bdd9 100644 --- a/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts +++ b/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts @@ -51,7 +51,32 @@ interface Row { const separator = " - "; +const columnPriorityByDataElementCode = { + "52_Integrated_Skin_Strategy_NDSR": 0, +}; + +type ColumnScoreInput = { + columnName: string; + allDataElements: ReadonlyArray; + priorityByCode: Readonly>; +}; + export class GridViewModel { + private static scoreColumnByLowestDEPriority({ + columnName, + allDataElements, + priorityByCode, + }: ColumnScoreInput): number { + const candidates = allDataElements.filter(de => { + const last = _.last(_.split(de.name, " - ")); + return last === columnName; + }); + + const scores = candidates.map(de => priorityByCode[de.code]).filter((v): v is number => typeof v === "number"); + + return scores.length > 0 ? (_.min(scores) as number) : Number.MAX_SAFE_INTEGER; + } + static get(section: SectionGrid, dataFormInfo: DataFormInfo): Grid { const dataElementsConfig = dataFormInfo.metadata.dataForm.options.dataElements; const dataElements = getDataElementsWithIndexProccessing(section); @@ -70,7 +95,7 @@ export class GridViewModel { ) .value(); - const columns: Column[] = _(subsections) + const baseColumns: Column[] = _(subsections) .flatMap(subsection => subsection.dataElements) .uniqBy(de => de.name) .map(({ id, name }) => { @@ -80,6 +105,16 @@ export class GridViewModel { }) .value(); + const priorityByCode = columnPriorityByDataElementCode; + + const columns = _.sortBy(baseColumns, c => + this.scoreColumnByLowestDEPriority({ + columnName: c.name, + allDataElements: dataElements, + priorityByCode, + }) + ); + const dataElementsByTotal = _(section.calculateTotals) .groupBy(item => item?.totalDeCode) .map((group, totalColumn) => ({ @@ -112,7 +147,7 @@ export class GridViewModel { columnTotal: parentTotal, columnDataElements: columnDataElements, dataElement: dataElement, - disabled: deCalculateTotal?.disabled ?? false, + disabled: deCalculateTotal?.disabled || dataElement?.disabled || false, disableComments: section.disableComments || dataElement?.disabledComments || false, }; }); From fe47aee902a625162be681d02de4a5c8da94a920 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Thu, 28 Aug 2025 19:14:42 -0500 Subject: [PATCH 03/24] add configuration for columns positions --- src/data/common/Dhis2DataFormRepository.ts | 16 ++++- src/data/common/Dhis2DataStoreDataForm.ts | 6 +- src/domain/common/entities/DataForm.ts | 4 ++ .../autogenerated-forms/GridFormViewModel.ts | 60 +++++++++---------- 4 files changed, 52 insertions(+), 34 deletions(-) diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 26a802f..56bd927 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -154,7 +154,14 @@ export class Dhis2DataFormRepository implements DataFormRepository { fixedHeaders: config?.fixedHeaders || false, }; - if (!config) return { viewType: "table", calculateTotals: undefined, ...base, fixedHeaders: false }; + if (!config) + return { + viewType: "table", + calculateTotals: undefined, + ...base, + fixedHeaders: false, + columnsOrder: undefined, + }; const base2 = getSectionBaseWithToggle(config, base, dataElements); @@ -164,7 +171,12 @@ 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, + ...base2, + }; case "grid-with-subnational-ous": return { viewType: config.viewType, diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index e3d5016..6719f3d 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -6,7 +6,7 @@ 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 { 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"; @@ -67,6 +67,7 @@ interface BasicSectionConfig extends BaseSectionConfig { interface GridSectionConfig extends BaseSectionConfig { viewType: "table" | "grid"; calculateTotals: CalculateTotalType; + columnsOrder: Maybe; } interface GridWithPeriodsSectionConfig extends BaseSectionConfig { @@ -77,6 +78,7 @@ interface GridWithPeriodsSectionConfig extends BaseSectionConfig { interface GridWithTotalsSectionConfig extends BaseSectionConfig { viewType: "grid-with-totals"; calculateTotals: CalculateTotalType; + columnsOrder: Maybe; } interface GridWithSubnationalSectionConfig extends BaseSectionConfig { @@ -207,6 +209,7 @@ const DataStoreConfigCodec = Codec.interface({ texts: optional(textsCodec), sections: optional( sectionConfig({ + columnsOrder: optional(record(string, number)), fixedHeaders: optional(boolean), disableComments: optional(boolean), subNationalDataset: optional(string), @@ -613,6 +616,7 @@ export class Dhis2DataStoreDataForm { ...baseConfig, viewType, calculateTotals: sectionConfig.calculateTotals, + columnsOrder: sectionConfig.columnsOrder, }; return [section.id, config] as [typeof section.id, typeof config]; } diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index a9ff4f7..fcd286f 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -97,11 +97,13 @@ export interface SectionGrid extends SectionBase { viewType: "table" | "grid"; fixedHeaders: boolean; calculateTotals: CalculateTotalType; + columnsOrder: Maybe; } export interface SectionWithTotals extends SectionBase { viewType: "grid-with-totals"; calculateTotals: CalculateTotalType; + columnsOrder: Maybe; } export interface SectionWithSubnationals extends SectionBase { @@ -112,6 +114,8 @@ export interface SectionWithSubnationals extends SectionBase { export type Section = SectionSimple | SectionGrid | SectionWithPeriods | SectionWithTotals | SectionWithSubnationals; +export type ColumnOrder = Record; + export class DataFormM { static viewTypes = viewTypes; diff --git a/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts b/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts index fa9bdd9..b9a9c43 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"; @@ -51,32 +51,13 @@ interface Row { const separator = " - "; -const columnPriorityByDataElementCode = { - "52_Integrated_Skin_Strategy_NDSR": 0, -}; - type ColumnScoreInput = { columnName: string; - allDataElements: ReadonlyArray; - priorityByCode: Readonly>; + allDataElements: DataElement[]; + priorityByCode: ColumnOrder; }; export class GridViewModel { - private static scoreColumnByLowestDEPriority({ - columnName, - allDataElements, - priorityByCode, - }: ColumnScoreInput): number { - const candidates = allDataElements.filter(de => { - const last = _.last(_.split(de.name, " - ")); - return last === columnName; - }); - - const scores = candidates.map(de => priorityByCode[de.code]).filter((v): v is number => typeof v === "number"); - - return scores.length > 0 ? (_.min(scores) as number) : Number.MAX_SAFE_INTEGER; - } - static get(section: SectionGrid, dataFormInfo: DataFormInfo): Grid { const dataElementsConfig = dataFormInfo.metadata.dataForm.options.dataElements; const dataElements = getDataElementsWithIndexProccessing(section); @@ -105,15 +86,17 @@ export class GridViewModel { }) .value(); - const priorityByCode = columnPriorityByDataElementCode; - - const columns = _.sortBy(baseColumns, c => - this.scoreColumnByLowestDEPriority({ - columnName: c.name, - allDataElements: dataElements, - priorityByCode, - }) - ); + const columns = section.columnsOrder + ? _(baseColumns) + .sortBy(column => { + return this.scoreColumnByLowestDEPriority({ + columnName: column.name, + allDataElements: dataElements, + priorityByCode: section.columnsOrder ?? {}, + }); + }) + .value() + : baseColumns; const dataElementsByTotal = _(section.calculateTotals) .groupBy(item => item?.totalDeCode) @@ -253,6 +236,21 @@ export class GridViewModel { .compact() .value(); } + + private static scoreColumnByLowestDEPriority({ + columnName, + allDataElements, + priorityByCode, + }: ColumnScoreInput): number { + const candidates = allDataElements.filter(de => { + const last = _.last(_.split(de.name, " - ")); + return last === columnName; + }); + + const scores = candidates.map(de => priorityByCode[de.code]).filter((v): v is number => typeof v === "number"); + + return scores.length > 0 ? (_.min(scores) as number) : Number.MAX_SAFE_INTEGER; + } } /** Move the data element index to the row name, so indexed data elements are automatically grouped From 4f7cb492ffabc9ddb266bf59c576ec33bace7f98 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Tue, 2 Sep 2025 22:40:59 -0500 Subject: [PATCH 04/24] add support for groups and subgroups in grid view --- src/data/common/Dhis2DataFormRepository.ts | 4 + src/data/common/Dhis2DataStoreDataForm.ts | 8 ++ src/domain/common/entities/DataForm.ts | 2 + .../autogenerated-forms/DataEntryItem.tsx | 14 +- .../reports/autogenerated-forms/GridForm.tsx | 85 +++++++++++- .../autogenerated-forms/GridFormViewModel.ts | 127 +++++++++++++++++- .../datatables/CustomDataTables.ts | 15 ++- 7 files changed, 240 insertions(+), 15 deletions(-) diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 56bd927..0e2a07e 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -161,6 +161,8 @@ export class Dhis2DataFormRepository implements DataFormRepository { ...base, fixedHeaders: false, columnsOrder: undefined, + enableGroups: false, + fixedRowNames: false, }; const base2 = getSectionBaseWithToggle(config, base, dataElements); @@ -175,6 +177,8 @@ export class Dhis2DataFormRepository implements DataFormRepository { viewType: config.viewType, calculateTotals: config.calculateTotals, columnsOrder: config.columnsOrder, + fixedRowNames: config.fixedRowNames || false, + enableGroups: config.enableGroups || false, ...base2, }; case "grid-with-subnational-ous": diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index 6719f3d..2338986 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -68,6 +68,8 @@ interface GridSectionConfig extends BaseSectionConfig { viewType: "table" | "grid"; calculateTotals: CalculateTotalType; columnsOrder: Maybe; + fixedRowNames: boolean; + enableGroups: boolean; } interface GridWithPeriodsSectionConfig extends BaseSectionConfig { @@ -79,6 +81,8 @@ interface GridWithTotalsSectionConfig extends BaseSectionConfig { viewType: "grid-with-totals"; calculateTotals: CalculateTotalType; columnsOrder: Maybe; + fixedRowNames: boolean; + enableGroups: boolean; } interface GridWithSubnationalSectionConfig extends BaseSectionConfig { @@ -211,6 +215,8 @@ const DataStoreConfigCodec = Codec.interface({ sectionConfig({ columnsOrder: optional(record(string, number)), fixedHeaders: optional(boolean), + fixedRowNames: optional(boolean), + enableGroups: optional(boolean), disableComments: optional(boolean), subNationalDataset: optional(string), sortRowsBy: optional(string), @@ -617,6 +623,8 @@ export class Dhis2DataStoreDataForm { viewType, calculateTotals: sectionConfig.calculateTotals, columnsOrder: sectionConfig.columnsOrder, + fixedRowNames: sectionConfig.fixedRowNames || false, + enableGroups: sectionConfig.enableGroups || false, }; return [section.id, config] as [typeof section.id, typeof config]; } diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index fcd286f..6edce36 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -96,6 +96,8 @@ export interface SectionWithPeriods extends SectionBase { export interface SectionGrid extends SectionBase { viewType: "table" | "grid"; fixedHeaders: boolean; + fixedRowNames: boolean; + enableGroups: boolean; calculateTotals: CalculateTotalType; columnsOrder: Maybe; } diff --git a/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx b/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx index 130b870..f0a5867 100644 --- a/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx +++ b/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx @@ -259,7 +259,7 @@ const DataEntryItem: React.FC = props => { options={options.items} onValueChange={notifyChange} state={state} - disabled={disabled} + disabled={isDisabled || disabled} /> ); case "MULTI_TEXT": @@ -269,7 +269,7 @@ const DataEntryItem: React.FC = props => { options={options.items} onValueChange={notifyChange} state={state} - disabled={disabled} + disabled={isDisabled || disabled} /> ); case "TEXT": @@ -281,7 +281,7 @@ const DataEntryItem: React.FC = props => { options={options.items} onValueChange={notifyChange} state={state} - disabled={disabled} + disabled={isDisabled || disabled} sourceTypeDEs={[]} rows={rows} /> @@ -293,7 +293,7 @@ const DataEntryItem: React.FC = props => { options={options.items} onValueChange={notifyChange} state={state} - disabled={disabled} + disabled={isDisabled || disabled} /> ) : ( = props => { options={options.items} onValueChange={notifyChange} state={state} - disabled={disabled} + disabled={isDisabled || disabled} /> ); } @@ -312,7 +312,7 @@ const DataEntryItem: React.FC = props => { options={options.items} onValueChange={notifyChange} state={state} - disabled={disabled} + disabled={isDisabled || disabled} /> ) : ( = props => { options={options.items} onValueChange={notifyChange} state={state} - disabled={disabled} + disabled={isDisabled || disabled} /> ); default: diff --git a/src/webapp/reports/autogenerated-forms/GridForm.tsx b/src/webapp/reports/autogenerated-forms/GridForm.tsx index f6b6b39..fdf23e6 100644 --- a/src/webapp/reports/autogenerated-forms/GridForm.tsx +++ b/src/webapp/reports/autogenerated-forms/GridForm.tsx @@ -6,7 +6,7 @@ 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"; @@ -17,6 +17,7 @@ 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"; /* * Convert data forms into table, using "-" as a separator. An example for section ITNs: @@ -44,6 +45,7 @@ const GridForm: React.FC = props => { const classes = useStyles(); const fixColumns = section.fixedHeaders; + const fixRows = section.fixedRowNames; const mainContentStyles = fixColumns ? fixHeaderClasses.fixedHeaders : {}; return ( @@ -52,6 +54,16 @@ const GridForm: React.FC = props => { + {grid.hasGroups && ( + <> + + + + )} {grid.useIndexes ? ( = props => { {grid.rows.map((row, idx) => ( - - + + + + + + = props => { ); }; +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: { diff --git a/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts b/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts index b9a9c43..ab6b344 100644 --- a/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts +++ b/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts @@ -21,6 +21,10 @@ export interface Grid { titleVariant: titleVariant; summary: Summary[]; indicators: Indicator[]; + rowGroups: RowGroup[]; + hasGroups: boolean; + groupInfo: Record; + subGroupInfo: Record; } interface SubSectionGrid { @@ -34,11 +38,13 @@ interface Column { isSourceType: boolean; } -interface Row { +export interface Row { indicator: Maybe; includePadding: number; name: string; htmlText: string; + group: Maybe; + subGroup: Maybe; items: Array<{ column: Column; columnTotal: Maybe; @@ -57,11 +63,26 @@ type ColumnScoreInput = { 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() @@ -142,12 +163,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, }; }); @@ -190,6 +217,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: @@ -209,9 +241,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; @@ -243,16 +308,70 @@ export class GridViewModel { priorityByCode, }: ColumnScoreInput): number { const candidates = allDataElements.filter(de => { - const last = _.last(_.split(de.name, " - ")); + const last = _.last(_.split(de.name, separator)); return last === columnName; }); - const scores = candidates.map(de => priorityByCode[de.code]).filter((v): v is number => typeof v === "number"); + 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 { + const parts = _(fullName) + .split(separator) + .map(s => s.trim()) + .value(); + + // Supported Pattern + // - [Row, Column] + // - [Group, SubGroup, Row, Column] + 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(); + } } +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/datatables/CustomDataTables.ts b/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts index af8c69f..0e4f72d 100644 --- a/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts +++ b/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts @@ -4,13 +4,26 @@ import { DataTableColumnHeader, DataTableCell, // @ts-ignore } from "@dhis2/ui"; +import React from "react"; export const CustomDataTableColumnHeader = styled(DataTableColumnHeader)<{ backgroundColor: string }>` background-color: ${props => props.backgroundColor} !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 : "initial")} !important; + left: ${props => (props.left ? props.left : "initial")} !important; + z-index: ${props => (props.zIndex ? props.zIndex : "initial")} !important; `; export const fixHeaderClasses = { From d3664c3b8201c1a976d95ba9717afb4210551bfc Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Tue, 2 Sep 2025 22:53:20 -0500 Subject: [PATCH 05/24] remove zIndex to avoid unfreeze group/subgroup columns --- src/webapp/reports/autogenerated-forms/GridForm.tsx | 3 --- .../reports/autogenerated-forms/datatables/CustomDataTables.ts | 2 -- 2 files changed, 5 deletions(-) diff --git a/src/webapp/reports/autogenerated-forms/GridForm.tsx b/src/webapp/reports/autogenerated-forms/GridForm.tsx index fdf23e6..9918192 100644 --- a/src/webapp/reports/autogenerated-forms/GridForm.tsx +++ b/src/webapp/reports/autogenerated-forms/GridForm.tsx @@ -118,7 +118,6 @@ const GridForm: React.FC = props => { @@ -233,7 +231,6 @@ const GridSubGroups: React.FC<{ grid: Grid; row: Row; backgroundColor: Maybe diff --git a/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts b/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts index 0e4f72d..e62252c 100644 --- a/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts +++ b/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts @@ -16,14 +16,12 @@ export const CustomDataTableCell = styled(DataTableCell)<{ 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 : "initial")} !important; left: ${props => (props.left ? props.left : "initial")} !important; - z-index: ${props => (props.zIndex ? props.zIndex : "initial")} !important; `; export const fixHeaderClasses = { From 57bf4f9ee52d5cf1a897518c9d05043eb356983a Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Wed, 3 Sep 2025 19:48:30 -0500 Subject: [PATCH 06/24] show scrollbars for the table at both the top and bottom --- src/data/common/Dhis2DataFormRepository.ts | 2 + src/data/common/Dhis2DataStoreDataForm.ts | 4 ++ src/domain/common/entities/DataForm.ts | 2 + .../reports/autogenerated-forms/GridForm.tsx | 11 +++- .../autogenerated-forms/hooks/Scroll.ts | 62 +++++++++++++++++++ 5 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/webapp/reports/autogenerated-forms/hooks/Scroll.ts diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 0e2a07e..e373ee0 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -163,6 +163,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { columnsOrder: undefined, enableGroups: false, fixedRowNames: false, + enableTopScroll: false, }; const base2 = getSectionBaseWithToggle(config, base, dataElements); @@ -179,6 +180,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { columnsOrder: config.columnsOrder, fixedRowNames: config.fixedRowNames || false, enableGroups: config.enableGroups || false, + enableTopScroll: config.enableTopScroll || false, ...base2, }; case "grid-with-subnational-ous": diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index 2338986..87ea4db 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -70,6 +70,7 @@ interface GridSectionConfig extends BaseSectionConfig { columnsOrder: Maybe; fixedRowNames: boolean; enableGroups: boolean; + enableTopScroll: boolean; } interface GridWithPeriodsSectionConfig extends BaseSectionConfig { @@ -83,6 +84,7 @@ interface GridWithTotalsSectionConfig extends BaseSectionConfig { columnsOrder: Maybe; fixedRowNames: boolean; enableGroups: boolean; + enableTopScroll: boolean; } interface GridWithSubnationalSectionConfig extends BaseSectionConfig { @@ -217,6 +219,7 @@ const DataStoreConfigCodec = Codec.interface({ fixedHeaders: optional(boolean), fixedRowNames: optional(boolean), enableGroups: optional(boolean), + enableTopScroll: optional(boolean), disableComments: optional(boolean), subNationalDataset: optional(string), sortRowsBy: optional(string), @@ -625,6 +628,7 @@ export class Dhis2DataStoreDataForm { columnsOrder: sectionConfig.columnsOrder, fixedRowNames: sectionConfig.fixedRowNames || false, enableGroups: sectionConfig.enableGroups || false, + enableTopScroll: sectionConfig.enableTopScroll || false, }; return [section.id, config] as [typeof section.id, typeof config]; } diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index 6edce36..09e67b0 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -98,6 +98,7 @@ export interface SectionGrid extends SectionBase { fixedHeaders: boolean; fixedRowNames: boolean; enableGroups: boolean; + enableTopScroll: boolean; calculateTotals: CalculateTotalType; columnsOrder: Maybe; } @@ -106,6 +107,7 @@ export interface SectionWithTotals extends SectionBase { viewType: "grid-with-totals"; calculateTotals: CalculateTotalType; columnsOrder: Maybe; + enableTopScroll: boolean; } export interface SectionWithSubnationals extends SectionBase { diff --git a/src/webapp/reports/autogenerated-forms/GridForm.tsx b/src/webapp/reports/autogenerated-forms/GridForm.tsx index 9918192..db88505 100644 --- a/src/webapp/reports/autogenerated-forms/GridForm.tsx +++ b/src/webapp/reports/autogenerated-forms/GridForm.tsx @@ -18,6 +18,7 @@ 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: @@ -44,13 +45,21 @@ 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 : {}; return ( -
+ {section.enableTopScroll && ( +
+
+
+ )} + +
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 }; +}; From 3f9b14a2aa436d6261f033c4cd6f6d67afc0bb45 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Thu, 4 Sep 2025 13:26:33 -0500 Subject: [PATCH 07/24] fix scrollbar bug and add custom color for section title --- .../autogenerated-forms/DataTableSection.tsx | 2 +- .../reports/autogenerated-forms/GridForm.tsx | 15 ++++++++++++--- .../autogenerated-forms/GridFormViewModel.ts | 13 ++++++++++--- .../datatables/CustomDataTables.ts | 14 +++++++++++--- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/webapp/reports/autogenerated-forms/DataTableSection.tsx b/src/webapp/reports/autogenerated-forms/DataTableSection.tsx index 56fad82..be6bf8d 100644 --- a/src/webapp/reports/autogenerated-forms/DataTableSection.tsx +++ b/src/webapp/reports/autogenerated-forms/DataTableSection.tsx @@ -73,7 +73,7 @@ const DataTableSection: React.FC = React.memo(props => { return (
-
+

{section.name}

diff --git a/src/webapp/reports/autogenerated-forms/GridForm.tsx b/src/webapp/reports/autogenerated-forms/GridForm.tsx index db88505..263bfe8 100644 --- a/src/webapp/reports/autogenerated-forms/GridForm.tsx +++ b/src/webapp/reports/autogenerated-forms/GridForm.tsx @@ -54,8 +54,8 @@ const GridForm: React.FC = props => { return ( {section.enableTopScroll && ( -
-
+
+
)} @@ -67,9 +67,13 @@ const GridForm: React.FC = props => { <> )} @@ -85,6 +89,8 @@ const GridForm: React.FC = props => { ) )} @@ -128,6 +134,7 @@ const GridForm: React.FC = props => { position={fixRows ? "sticky" : undefined} left={fixRows ? "162px" : undefined} backgroundColor={props.section.styles.rows.backgroundColor} + zIndex={fixRows ? 2 : undefined} > @@ -240,6 +248,7 @@ const GridSubGroups: React.FC<{ grid: Grid; row: Row; backgroundColor: Maybe @@ -259,7 +268,7 @@ const useStyles = makeStyles({ description: { fontWeight: "normal", fontSize: "0.8em" }, table: { borderWidth: "3px !important" }, columnWidth: { minWidth: "14.25em !important" }, - tableHeader: { position: "sticky", top: 0, zIndex: 2 }, + 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 ab6b344..1f61c8f 100644 --- a/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts +++ b/src/webapp/reports/autogenerated-forms/GridFormViewModel.ts @@ -7,6 +7,7 @@ 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"; export interface Grid { dataElements: Array }>; @@ -33,6 +34,7 @@ interface SubSectionGrid { } interface Column { + code: Code; name: string; description?: string; isSourceType: boolean; @@ -97,13 +99,18 @@ export class GridViewModel { ) .value(); - const baseColumns: 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, + isSourceType: isSourceTypeColumn(config?.widget), + name: name, + description: columnDescription, + }; }) .value(); diff --git a/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts b/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts index e62252c..cd1f593 100644 --- a/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts +++ b/src/webapp/reports/autogenerated-forms/datatables/CustomDataTables.ts @@ -6,8 +6,14 @@ import { } 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)<{ @@ -16,12 +22,14 @@ export const CustomDataTableCell = styled(DataTableCell)<{ 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 : "initial")} !important; - left: ${props => (props.left ? props.left : "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 = { From 2aaa88f5b18bf2660723473be33e51dd10acdced Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Sun, 7 Sep 2025 15:58:16 -0500 Subject: [PATCH 08/24] add support to hide/show columns in viewType grid --- src/data/common/Dhis2DataFormRepository.ts | 3 + src/data/common/Dhis2DataStoreDataForm.ts | 13 ++ src/data/common/RulesFormula.ts | 23 +++ src/domain/common/entities/DataForm.ts | 2 + .../reports/autogenerated-forms/GridForm.tsx | 139 ++++++++++-------- .../autogenerated-forms/GridFormViewModel.ts | 46 +++++- .../datatables/InputFormula.tsx | 20 ++- 7 files changed, 170 insertions(+), 76 deletions(-) create mode 100644 src/data/common/RulesFormula.ts diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index c03f5dc..3b07979 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -164,6 +164,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { enableGroups: false, fixedRowNames: false, enableTopScroll: false, + columnsConfig: undefined, }; const base2 = getSectionBaseWithToggle(config, base, dataElements); @@ -174,6 +175,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { case "table": case "grid": case "grid-with-totals": + // const columns = this.getSectionColumnsRules(config); return { viewType: config.viewType, calculateTotals: config.calculateTotals, @@ -181,6 +183,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { fixedRowNames: config.fixedRowNames || false, enableGroups: config.enableGroups || false, enableTopScroll: config.enableTopScroll || false, + columnsConfig: config.columnsConfig, ...base2, }; case "grid-with-subnational-ous": diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index ad869ce..c49ca34 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -11,6 +11,7 @@ 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; @@ -72,6 +73,12 @@ interface GridSectionConfig extends BaseSectionConfig { fixedRowNames: boolean; enableGroups: boolean; enableTopScroll: boolean; + columnsConfig?: Record< + string, + { + rules?: FromRulesFormulaCodec; + } + >; } interface GridWithPeriodsSectionConfig extends BaseSectionConfig { @@ -86,6 +93,7 @@ interface GridWithTotalsSectionConfig extends BaseSectionConfig { fixedRowNames: boolean; enableGroups: boolean; enableTopScroll: boolean; + columnsConfig?: GridColumnsConfig; } interface GridIndicatorsCalculated extends BaseSectionConfig { @@ -110,6 +118,8 @@ export type GridIndicatorsCalculatedRow = { }>; }; +type GridColumnsConfig = Record; + interface GridWithSubnationalSectionConfig extends BaseSectionConfig { viewType: "grid-with-subnational-ous"; calculateTotals: CalculateTotalType; @@ -260,6 +270,7 @@ const DataStoreConfigCodec = Codec.interface({ texts: optional(textsCodec), sections: optional( sectionConfig({ + columnsConfig: optional(record(string, Codec.interface({ rules: optional(rulesFormulaCodec) }))), columnsOrder: optional(record(string, number)), fixedHeaders: optional(boolean), fixedRowNames: optional(boolean), @@ -763,6 +774,7 @@ export class Dhis2DataStoreDataForm { fixedRowNames: sectionConfig.fixedRowNames || false, enableGroups: sectionConfig.enableGroups || false, enableTopScroll: sectionConfig.enableTopScroll || false, + columnsConfig: sectionConfig.columnsConfig, }; return [section.id, config] as [typeof section.id, typeof config]; } @@ -772,6 +784,7 @@ export class Dhis2DataStoreDataForm { viewType, calculateTotals: sectionConfig.calculateTotals, subNationalDataset: sectionConfig.subNationalDataset || "", + columns: undefined, }; return [section.id, config] as [typeof section.id, typeof config]; } diff --git a/src/data/common/RulesFormula.ts b/src/data/common/RulesFormula.ts new file mode 100644 index 0000000..dd5ecf7 --- /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")]), + 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/DataForm.ts b/src/domain/common/entities/DataForm.ts index bad805f..c5c0911 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -14,6 +14,7 @@ import { SectionStyle } from "./SectionStyle"; import { titleVariant } from "./TitleVariant"; import { DataElementToggle } from "./ToggleMultiple"; import { DataElementRuleOptions, TotalRules } from "./DataElementRule"; +import { RulesFormula } from "../../../data/common/RulesFormula"; export interface DataForm { id: Id; @@ -107,6 +108,7 @@ export interface SectionGrid extends SectionBase { enableTopScroll: boolean; calculateTotals: CalculateTotalType; columnsOrder: Maybe; + columnsConfig?: Record; } export interface SectionWithTotals extends SectionBase { diff --git a/src/webapp/reports/autogenerated-forms/GridForm.tsx b/src/webapp/reports/autogenerated-forms/GridForm.tsx index 263bfe8..12ea31a 100644 --- a/src/webapp/reports/autogenerated-forms/GridForm.tsx +++ b/src/webapp/reports/autogenerated-forms/GridForm.tsx @@ -95,78 +95,87 @@ const GridForm: React.FC = props => { ) )} - {grid.columns.map(column => ( - -
- {column.name} - -
-
- ))} + {grid.columns.map(column => { + if (!column.isVisible) { + return null; + } + return ( + +
+ {column.name} + +
+
+ ); + })} - {grid.rows.map((row, idx) => ( - - - - + {grid.rows.map((row, idx) => { + return ( + + - - - - {row.items.map((item, idx) => - item.dataElement ? ( - - - - ) : ( - - ) - )} - - ))} + + + + + {row.items.map((item, idx) => { + if (!item.isVisible) return null; + + return item.dataElement ? ( + + + + ) : ( + + ); + })} + + ); + })} {grid.summary.map(summary => ( }>; @@ -38,6 +39,7 @@ interface Column { name: string; description?: string; isSourceType: boolean; + isVisible: boolean; } export interface Row { @@ -48,6 +50,7 @@ export interface Row { group: Maybe; subGroup: Maybe; items: Array<{ + isVisible: boolean; column: Column; columnTotal: Maybe; columnDataElements: DataElement[]; @@ -107,6 +110,7 @@ export class GridViewModel { const config = dataElementsConfig[id]; return { code: code, + isVisible: true, isSourceType: isSourceTypeColumn(config?.widget), name: name, description: columnDescription, @@ -114,7 +118,7 @@ export class GridViewModel { }) .value(); - const columns = section.columnsOrder + const columnsOrders = section.columnsOrder ? _(baseColumns) .sortBy(column => { return this.scoreColumnByLowestDEPriority({ @@ -126,6 +130,8 @@ export class GridViewModel { .value() : baseColumns; + const columns = this.addVisibleToColumns({ columns: columnsOrders, dataFormInfo, section }); + const dataElementsByTotal = _(section.calculateTotals) .groupBy(item => item?.totalDeCode) .map((group, totalColumn) => ({ @@ -154,6 +160,7 @@ export class GridViewModel { ); return { + isVisible: column.isVisible, column: column, columnTotal: parentTotal, columnDataElements: columnDataElements, @@ -325,14 +332,15 @@ export class GridViewModel { } private static parseNameParts(fullName: string): RowNameParts { + // Supported Pattern + // - [Row, Column] + // - [Group, SubGroup, Row, Column] + const parts = _(fullName) .split(separator) .map(s => s.trim()) .value(); - // Supported Pattern - // - [Row, Column] - // - [Group, SubGroup, Row, Column] if (parts.length === 4) { const [group, subGroup, rowName, columnName] = parts; return { group, subGroup: subGroup, rowName: rowName ?? "", columnName: columnName ?? "" }; @@ -372,6 +380,36 @@ export class GridViewModel { }) .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 = { 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; From b3e703d663a2fcbd5b10449a64c0d33c9136fcd3 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Sun, 7 Sep 2025 16:59:46 -0500 Subject: [PATCH 09/24] add support for hide/show tabs --- src/data/common/Dhis2DataFormRepository.ts | 2 +- src/data/common/Dhis2DataStoreDataForm.ts | 3 ++- src/data/common/RulesFormula.ts | 2 +- src/domain/common/entities/DataForm.ts | 2 +- .../autogenerated-forms/SectionsTabs.tsx | 19 +++++++++++++++++++ 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 3b07979..5cf1964 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -109,7 +109,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, diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index c49ca34..50f2201 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -49,7 +49,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; @@ -296,6 +296,7 @@ const DataStoreConfigCodec = Codec.interface({ Codec.interface({ active: exactly(true), order: oneOf([string, number]), + rules: optional(rulesFormulaCodec), }) ), periods: optional( diff --git a/src/data/common/RulesFormula.ts b/src/data/common/RulesFormula.ts index dd5ecf7..46971cf 100644 --- a/src/data/common/RulesFormula.ts +++ b/src/data/common/RulesFormula.ts @@ -2,7 +2,7 @@ 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")]), + oneOf([exactly("visible"), exactly("disabled")]), Codec.interface({ formula: Codec.interface({ value: string, condition: string }), dataElements: array(Codec.interface({ code: string })), diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index c5c0911..14bd92c 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -77,7 +77,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; diff --git a/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx b/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx index d5fc79d..7bade8f 100644 --- a/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx +++ b/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx @@ -25,6 +25,7 @@ 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"; export interface TabPanelProps { sections: Section[]; @@ -236,7 +237,25 @@ 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; + } + return ( Date: Sun, 7 Sep 2025 17:32:16 -0500 Subject: [PATCH 10/24] update default color for section title --- src/webapp/reports/autogenerated-forms/DataTableSection.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/webapp/reports/autogenerated-forms/DataTableSection.tsx b/src/webapp/reports/autogenerated-forms/DataTableSection.tsx index be6bf8d..31c4270 100644 --- a/src/webapp/reports/autogenerated-forms/DataTableSection.tsx +++ b/src/webapp/reports/autogenerated-forms/DataTableSection.tsx @@ -73,8 +73,10 @@ const DataTableSection: React.FC = React.memo(props => { return (
-
-

{section.name}

+
+

+ {section.name} +

From 68bb923ab516099d072ed2f7988e4da75820a460 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Sun, 7 Sep 2025 17:32:59 -0500 Subject: [PATCH 11/24] update default color for section title --- src/domain/common/entities/SectionStyle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }, From 4983c3fb47b18dddc6c047b0f49fd077e192e276 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Sun, 7 Sep 2025 19:06:59 -0500 Subject: [PATCH 12/24] update json schema with new values --- src/data/common/Dhis2DataStoreDataForm.ts | 21 ---------- .../schemas/dataSets/index.ts | 1 + .../schemas/dataSets/section.ts | 39 +++++++++++++++++++ 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index 50f2201..3b93e6c 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -380,26 +380,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, - }), - }) - ), - }) - ) - ), }) ), }), @@ -793,7 +773,6 @@ export class Dhis2DataStoreDataForm { const config = { ...baseConfig, periods: getPeriods(period, sectionConfig.periods), - rows: sectionConfig.rows ?? [], virtualColumns: sectionConfig.virtualColumns ?? [], virtualRows: sectionConfig.virtualRows ?? [], viewType, 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", }, From 9ee9906d9531b9552cd25eceb0c4496cb2fc79a3 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Sat, 13 Sep 2025 12:01:01 -0500 Subject: [PATCH 13/24] add new viewType to have dataElements and one category as columns --- src/data/common/Dhis2DataElement.ts | 47 ++++- src/data/common/Dhis2DataFormRepository.ts | 7 + src/data/common/Dhis2DataStoreDataForm.ts | 29 ++- src/domain/common/entities/DataElement.ts | 1 + src/domain/common/entities/DataForm.ts | 12 +- .../autogenerated-forms/AutogeneratedForm.tsx | 9 + .../GridWithCategoryColumns.tsx | 185 +++++++++++++++++ .../GridWithCategoryColumnsViewModel.ts | 194 ++++++++++++++++++ .../autogenerated-forms/SectionsTabs.tsx | 10 + 9 files changed, 486 insertions(+), 8 deletions(-) create mode 100644 src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx create mode 100644 src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts diff --git a/src/data/common/Dhis2DataElement.ts b/src/data/common/Dhis2DataElement.ts index 4364635..a215cde 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, @@ -81,7 +82,7 @@ type D2DataElementNewType = Omit & { valueType: D2DataElementTypes; }; -function makeCocOrderArray(namesArray: string[][]): string[] { +export function makeCocOrderArray(namesArray: string[][]): string[] { return namesArray.reduce((prev, current) => { return prev .map(prevValue => { @@ -96,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,15 +115,35 @@ function getCocOrdered(categoryCombo: D2DataElement["categoryCombo"], config: Dh return c.categoryOptions.flatMap(co => co.name); }); + // const categoryIndexMap = new Map(categoryCombo.categories.map((c, i) => [c.id, i])); + const cocOrderArray = makeCocOrderArray(categoryOptionsNamesArray); const result = cocOrderArray.flatMap(cocOrdered => { const match = categoryCombo.categoryOptionCombos.find(coc => { 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 = match?.categoryOptions.sort((a, b) => { + // const indexA = + // categoryIndexMap.get( + // categoryCombo.categories.find(c => c.categoryOptions.some(co => co.id === a.id))?.id || "" + // ) ?? 0; + + // const indexB = + // categoryIndexMap.get( + // categoryCombo.categories.find(c => c.categoryOptions.some(co => co.id === b.id))?.id || "" + // ) ?? 0; + + // return indexA - indexB; + // }); + + 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 @@ -142,7 +164,6 @@ 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 || "" })); } @@ -163,6 +184,22 @@ function getDataElement(dataElement: D2DataElementNewType, config: Dhis2DataStor 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, name: record[keyName] ?? record.name }; + }), + }; + }), categoryOptionCombos: getCocOrdered(dataElement.categoryCombo, config), }; const categoryOptionCombos = dataElement.categoryCombo.categoryOptionCombos; diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 5cf1964..18be65d 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -215,6 +215,13 @@ export class Dhis2DataFormRepository implements DataFormRepository { }), ...base2, }; + case "grid-category-columns": + return { + ...base2, + viewType: config.viewType, + showCalculatedTotals: config.showCalculatedTotals, + categoriesColumns: config.categoriesColumns, + }; default: return { viewType: config.viewType, ...base2 }; } diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index 3b93e6c..d28ea42 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -6,7 +6,13 @@ 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 { ColumnOrder, 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"; @@ -24,7 +30,8 @@ export type SectionConfig = | GridWithPeriodsSectionConfig | GridWithTotalsSectionConfig | GridWithSubnationalSectionConfig - | GridIndicatorsCalculated; + | GridIndicatorsCalculated + | GridCategoryColumnsConfig; export type TotalsRule = ( | { @@ -120,6 +127,12 @@ export type GridIndicatorsCalculatedRow = { type GridColumnsConfig = Record; +interface GridCategoryColumnsConfig extends BaseSectionConfig { + viewType: "grid-category-columns"; + showCalculatedTotals: boolean; + categoriesColumns: CategoryColumnConfig[]; +} + interface GridWithSubnationalSectionConfig extends BaseSectionConfig { viewType: "grid-with-subnational-ous"; calculateTotals: CalculateTotalType; @@ -168,6 +181,7 @@ const viewType = oneOf([ exactly("grid-with-periods"), exactly("grid-with-subnational-ous"), exactly("grid-indicators-calculated"), + exactly("grid-category-columns"), ]); const titleVariantType = oneOf([ @@ -270,6 +284,8 @@ const DataStoreConfigCodec = Codec.interface({ texts: optional(textsCodec), sections: optional( sectionConfig({ + 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), @@ -779,6 +795,15 @@ export class Dhis2DataStoreDataForm { }; return [section.id, config]; } + case "grid-category-columns": { + const config = { + ...baseConfig, + viewType, + categoriesColumns: sectionConfig.categoriesColumns || [], + showCalculatedTotals: sectionConfig.showCalculatedTotals || 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/domain/common/entities/DataElement.ts b/src/domain/common/entities/DataElement.ts index 426e583..7e15a17 100644 --- a/src/domain/common/entities/DataElement.ts +++ b/src/domain/common/entities/DataElement.ts @@ -68,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 }[] }>; categoryOptionCombos: { id: Id; name: string; diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index 14bd92c..77415d8 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -56,6 +56,7 @@ const viewTypes = [ "matrix-grid", "grid-with-subnational-ous", "grid-indicators-calculated", + "grid-category-columns", ] as const; export type ViewType = UnionFromValues; @@ -132,6 +133,14 @@ 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; +} + +export type CategoryColumnConfig = { dataElementCode: Code; categoryCode: Code }; + export type BaseVirtualColumn = { dataElementCode: string; columnName: string; @@ -157,7 +166,8 @@ export type Section = | SectionWithPeriods | SectionWithTotals | SectionWithSubnationals - | SectionWithIndicatorsCalculated; + | SectionWithIndicatorsCalculated + | SectionWithCategoryColumns; export type ColumnOrder = Record; diff --git a/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx b/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx index 5a4c62e..007dfb6 100644 --- a/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx +++ b/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx @@ -40,6 +40,7 @@ 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(); @@ -146,6 +147,14 @@ const AutogeneratedForm: React.FC = () => { section={section as SectionWithIndicatorsCalculated} /> ); + case "grid-category-columns": + return ( + + ); default: assertUnreachable(viewType); } diff --git a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx new file mode 100644 index 0000000..1897129 --- /dev/null +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx @@ -0,0 +1,185 @@ +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"; + +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 classes = useStyles(); + + return ( + +
+ + + + {grid.parentColumns.length > 0 && ( + + )} + {grid.parentColumns.map(column => { + return ( + + {column.name} + + ); + })} + + + + {grid.useIndexes ? ( + + #{" "} + + ) : ( + + )} + + {grid.columns.map(column => ( + + {column.name} + + ))} + + + + + {grid.columns.map(column => + column.categories?.map((category, idx) => ( + + {category} + + )) + )} + + + + + {grid.rows.map((row, idx) => ( + + + {grid.useIndexes ? (idx + 1).toString() : row.name} + + + {row.items.map((item, idx) => + item.dataElement ? ( + + + + ) : ( + + ) + )} + + ))} + + {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: 2 }, + 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..869d92e --- /dev/null +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts @@ -0,0 +1,194 @@ +import _ from "lodash"; +import { Section, 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; + deName?: string; + cocName?: string; + categories?: string[]; + totalCategories: number; + parentColumnName?: string; +} + +interface Row { + name: string; + items: Array<{ dataElement: Maybe }>; +} + +type ParentColumn = { + name: string; + colSpan: number; +}; + +const separator = " - "; + +const categoriesColumnsConfig: { dataElementCode: string; categoryCode: string }[] = []; + +export class GridWithCategoryColumnsViewModel { + static get(section: Section, dataFormInfo: DataFormInfo): Grid { + const { parentColumns, columns } = this.getColumns(section); + const rows = this.buildRows(section.dataElements); + + const sortedRows = section.sortRowsBy ? this.sortRows(rows, section.sortRowsBy as "name") : rows; + + return { + id: section.id, + name: section.name, + columns: columns, + rows: sortedRows, + toggle: section.toggle, + texts: section.texts, + useIndexes: false, + parentColumns: parentColumns.length === columns.length ? [] : parentColumns, + toggleMultiple: section.toggleMultiple, + calculateTotals: this.calculateTotals(sortedRows, dataFormInfo), + }; + } + + private static getColumns(section: Section): { columns: Column[]; parentColumns: ParentColumn[] } { + const columns = section.dataElements.map((dataElement): Column => { + const category = this.categoryColumn(dataElement); + + const columnName = _(dataElement.name).split(separator).last() || ""; + const parentColumnName = _(dataElement.name).split(separator).first() || ""; + + return { + name: columnName, + deName: "", + cocName: "", + parentColumnName: parentColumnName, + categories: category?.categoryOptions.map(c => c.name) ?? [], + totalCategories: 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(dataElements: DataElement[]): Row[] { + const items = dataElements.flatMap(dataElement => { + const category = this.categoryColumn(dataElement); + + // 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 => { + return combinations.map(combination => { + 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, + // passing an invalid string to avoid DataEntry using the default coc + cocId: cocDetails?.id ?? "", + fullName: dataElement.name, + cocName: combination, + }; + }); + }); + return dataElementsWithCocId; + }); + + return _(items) + .groupBy(item => item.cocName) + .map((group, name) => ({ name, items: group.map(de => ({ dataElement: de })) })) + .value(); + } + + private static categoryColumn(dataElement: DataElement) { + 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; + } + + private static sortRows = (rows: Row[], sortField: "name"): Row[] => { + return _.sortBy(rows, [group => !this.isLetter(group[sortField]), group => group[sortField].toLowerCase()]); + }; + + private static isLetter = (value: string): boolean => { + return /^[a-zA-Z]/.test(value); + }; +} diff --git a/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx b/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx index 7bade8f..a96da4f 100644 --- a/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx +++ b/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx @@ -9,6 +9,7 @@ import { SectionSimple, SectionGrid, SectionWithIndicatorsCalculated, + SectionWithCategoryColumns, } from "../../../domain/common/entities/DataForm"; import TableForm from "./TableForm"; import GridForm from "./GridForm"; @@ -26,6 +27,7 @@ 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[]; @@ -111,6 +113,14 @@ function TypeSwitch(props: TypeSwitchProps) { section={section as SectionWithIndicatorsCalculated} /> ); + case "grid-category-columns": + return ( + + ); default: assertUnreachable(viewType); } From 51f69f963724f4a63ad8ee36f6ecd9ab4d556ad5 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Sat, 13 Sep 2025 12:06:01 -0500 Subject: [PATCH 14/24] update locales --- i18n/en.pot | 7 +++++-- i18n/es.po | 5 ++++- i18n/fr.po | 5 ++++- .../autogenerated-forms/GridWithCategoryColumns.tsx | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) 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/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx index 1897129..e5796fe 100644 --- a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx @@ -154,7 +154,7 @@ const GridWithCategoryColumns: React.FC = props => backgroundColor={section.styles.rows.backgroundColor} className={classes.centerSpan} > - + ))} From 193d2ea138ec1b243caca55095746a879f7234e9 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Sat, 13 Sep 2025 16:35:16 -0500 Subject: [PATCH 15/24] add row id and configuration to hide cells --- src/data/common/Dhis2DataElement.ts | 18 +----- src/data/common/Dhis2DataFormRepository.ts | 1 + src/data/common/Dhis2DataStoreDataForm.ts | 4 ++ src/domain/common/entities/DataElement.ts | 2 +- src/domain/common/entities/DataForm.ts | 3 + .../GridWithCategoryColumns.tsx | 4 +- .../GridWithCategoryColumnsViewModel.ts | 61 +++++++++++++------ 7 files changed, 56 insertions(+), 37 deletions(-) diff --git a/src/data/common/Dhis2DataElement.ts b/src/data/common/Dhis2DataElement.ts index a215cde..a143e69 100644 --- a/src/data/common/Dhis2DataElement.ts +++ b/src/data/common/Dhis2DataElement.ts @@ -115,28 +115,12 @@ function getCocOrdered(categoryCombo: D2DataElement["categoryCombo"], config: Dh return c.categoryOptions.flatMap(co => co.name); }); - // const categoryIndexMap = new Map(categoryCombo.categories.map((c, i) => [c.id, i])); - const cocOrderArray = makeCocOrderArray(categoryOptionsNamesArray); const result = cocOrderArray.flatMap(cocOrdered => { const match = categoryCombo.categoryOptionCombos.find(coc => { return coc.name === cocOrdered; }); - // const orderedOptions = match?.categoryOptions.sort((a, b) => { - // const indexA = - // categoryIndexMap.get( - // categoryCombo.categories.find(c => c.categoryOptions.some(co => co.id === a.id))?.id || "" - // ) ?? 0; - - // const indexB = - // categoryIndexMap.get( - // categoryCombo.categories.find(c => c.categoryOptions.some(co => co.id === b.id))?.id || "" - // ) ?? 0; - - // return indexA - indexB; - // }); - const orderedOptions = categoryCombo.categories.map(category => { return match?.categoryOptions.find(co => category.categoryOptions.some(catOpt => catOpt.id === co.id)); }); @@ -196,7 +180,7 @@ function getDataElement(dataElement: D2DataElementNewType, config: Dhis2DataStor shortName: co.displayShortName, code: co.code, }; - return { id: record.id, name: record[keyName] ?? record.name }; + return { id: record.id, code: co.code, name: record[keyName] ?? record.name }; }), }; }), diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 18be65d..ac4d8b0 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -221,6 +221,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { viewType: config.viewType, showCalculatedTotals: config.showCalculatedTotals, categoriesColumns: config.categoriesColumns, + rowsConfig: config.rowsConfig ?? undefined, }; default: return { viewType: config.viewType, ...base2 }; diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index d28ea42..4e59ddb 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -10,6 +10,7 @@ import { CategoryColumnConfig, ColumnOrder, DescriptionText, + RowConfig, Texts, Totals, } from "../../domain/common/entities/DataForm"; @@ -131,6 +132,7 @@ interface GridCategoryColumnsConfig extends BaseSectionConfig { viewType: "grid-category-columns"; showCalculatedTotals: boolean; categoriesColumns: CategoryColumnConfig[]; + rowsConfig: Maybe; } interface GridWithSubnationalSectionConfig extends BaseSectionConfig { @@ -284,6 +286,7 @@ const DataStoreConfigCodec = Codec.interface({ texts: optional(textsCodec), sections: optional( sectionConfig({ + rowsConfig: optional(record(string, Codec.interface({ cellsVisible: boolean }))), showCalculatedTotals: optional(boolean), categoriesColumns: optional(array(Codec.interface({ dataElementCode: string, categoryCode: string }))), columnsConfig: optional(record(string, Codec.interface({ rules: optional(rulesFormulaCodec) }))), @@ -801,6 +804,7 @@ export class Dhis2DataStoreDataForm { viewType, categoriesColumns: sectionConfig.categoriesColumns || [], showCalculatedTotals: sectionConfig.showCalculatedTotals || false, + rowsConfig: sectionConfig.rowsConfig, }; return [section.id, config] as [typeof section.id, typeof config]; } diff --git a/src/domain/common/entities/DataElement.ts b/src/domain/common/entities/DataElement.ts index 7e15a17..8ec2d69 100644 --- a/src/domain/common/entities/DataElement.ts +++ b/src/domain/common/entities/DataElement.ts @@ -68,7 +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 }[] }>; + 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 77415d8..3c7c187 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -137,8 +137,11 @@ export interface SectionWithCategoryColumns extends SectionBase { viewType: "grid-category-columns"; categoriesColumns: CategoryColumnConfig[]; showCalculatedTotals: boolean; + rowsConfig: Maybe; } +export type RowConfig = Record; + export type CategoryColumnConfig = { dataElementCode: Code; categoryCode: Code }; export type BaseVirtualColumn = { diff --git a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx index e5796fe..2c9345a 100644 --- a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx @@ -109,7 +109,7 @@ const GridWithCategoryColumns: React.FC = props => {grid.rows.map((row, idx) => ( - + = props => {row.items.map((item, idx) => - item.dataElement ? ( + item.dataElement?.cocId && row.cellsVisible ? ( }>; } @@ -39,32 +41,31 @@ type ParentColumn = { const separator = " - "; -const categoriesColumnsConfig: { dataElementCode: string; categoryCode: string }[] = []; - export class GridWithCategoryColumnsViewModel { - static get(section: Section, dataFormInfo: DataFormInfo): Grid { + static get(section: SectionWithCategoryColumns, dataFormInfo: DataFormInfo): Grid { const { parentColumns, columns } = this.getColumns(section); - const rows = this.buildRows(section.dataElements); - - const sortedRows = section.sortRowsBy ? this.sortRows(rows, section.sortRowsBy as "name") : rows; + const rows = this.buildRows(section); return { id: section.id, name: section.name, columns: columns, - rows: sortedRows, + rows: rows, toggle: section.toggle, texts: section.texts, useIndexes: false, parentColumns: parentColumns.length === columns.length ? [] : parentColumns, toggleMultiple: section.toggleMultiple, - calculateTotals: this.calculateTotals(sortedRows, dataFormInfo), + calculateTotals: this.calculateTotals(rows, dataFormInfo), }; } - private static getColumns(section: Section): { columns: Column[]; parentColumns: ParentColumn[] } { + private static getColumns(section: SectionWithCategoryColumns): { + columns: Column[]; + parentColumns: ParentColumn[]; + } { const columns = section.dataElements.map((dataElement): Column => { - const category = this.categoryColumn(dataElement); + const category = this.categoryColumn(dataElement, section.categoriesColumns); const columnName = _(dataElement.name).split(separator).last() || ""; const parentColumnName = _(dataElement.name).split(separator).first() || ""; @@ -92,10 +93,15 @@ export class GridWithCategoryColumnsViewModel { return { columns: columns, parentColumns: parentColumns }; } - private static buildRows(dataElements: DataElement[]): Row[] { + private static buildRows(section: SectionWithCategoryColumns): Row[] { + const { dataElements, categoriesColumns } = section; const items = dataElements.flatMap(dataElement => { - const category = this.categoryColumn(dataElement); + const allOptions = _(dataElement.categoryCombos.categories) + .flatMap(c => c.categoryOptions.flatMap(co => co)) + .keyBy(x => x.name) + .value(); + const category = this.categoryColumn(dataElement, categoriesColumns); // Options for the category used for columns const columnOptions = category?.categoryOptions.map(co => co.name) ?? []; @@ -109,7 +115,10 @@ export class GridWithCategoryColumnsViewModel { 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 @@ -123,10 +132,11 @@ export class GridWithCategoryColumnsViewModel { return { ...dataElement, - // passing an invalid string to avoid DataEntry using the default coc - cocId: cocDetails?.id ?? "", + cocId: cocDetails?.id, fullName: dataElement.name, cocName: combination, + disabled: !cocDetails, + cocCodes: _([columnOptionCode, combinationOptionCode]).compact().value(), }; }); }); @@ -135,11 +145,28 @@ export class GridWithCategoryColumnsViewModel { return _(items) .groupBy(item => item.cocName) - .map((group, name) => ({ name, items: group.map(de => ({ dataElement: de })) })) + .map((group, name): Row => { + const id = _(group) + .flatMap(x => x.cocCodes) + .uniq() + .join("-"); + + const rowConfig = section.rowsConfig?.[id]; + + return { + id: id, + name, + cellsVisible: rowConfig?.cellsVisible ?? true, + items: group.map(de => ({ dataElement: de })), + }; + }) .value(); } - private static categoryColumn(dataElement: DataElement) { + private static categoryColumn( + dataElement: DataElement, + categoriesColumnsConfig: SectionWithCategoryColumns["categoriesColumns"] + ) { const categoryColumnConfig = categoriesColumnsConfig.find( config => config.dataElementCode === dataElement.code ); From a1e98c17098d299c1800ec35b59abc2cea6e1db8 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Tue, 16 Sep 2025 18:52:47 -0500 Subject: [PATCH 16/24] fixed parent columns --- .../autogenerated-forms/GridWithCategoryColumnsViewModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts index 887047c..225ffaa 100644 --- a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts @@ -54,7 +54,7 @@ export class GridWithCategoryColumnsViewModel { toggle: section.toggle, texts: section.texts, useIndexes: false, - parentColumns: parentColumns.length === columns.length ? [] : parentColumns, + parentColumns: parentColumns, toggleMultiple: section.toggleMultiple, calculateTotals: this.calculateTotals(rows, dataFormInfo), }; From 663d8b6f221683a776a88b9a6e1dca8e04b1b081 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Wed, 17 Sep 2025 19:48:10 -0500 Subject: [PATCH 17/24] add fixed rows and topscroll for grid with categories-columns --- src/data/common/Dhis2DataFormRepository.ts | 6 +- src/data/common/Dhis2DataStoreDataForm.ts | 11 ++- src/domain/common/entities/DataForm.ts | 7 +- .../GridWithCategoryColumns.tsx | 99 +++++++++++-------- .../GridWithCategoryColumnsViewModel.ts | 31 ++++-- .../autogenerated-forms/SectionsTabs.tsx | 5 +- 6 files changed, 97 insertions(+), 62 deletions(-) diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index ac4d8b0..2685e69 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -152,6 +152,8 @@ export class Dhis2DataFormRepository implements DataFormRepository { ? buildToggleMultiple(config.toggleMultiple, dataElements) : undefined, fixedHeaders: config?.fixedHeaders || false, + enableTopScroll: config?.enableTopScroll || false, + fixedRowNames: config?.fixedRowNames || false, }; if (!config) @@ -175,14 +177,11 @@ export class Dhis2DataFormRepository implements DataFormRepository { case "table": case "grid": case "grid-with-totals": - // const columns = this.getSectionColumnsRules(config); return { viewType: config.viewType, calculateTotals: config.calculateTotals, columnsOrder: config.columnsOrder, - fixedRowNames: config.fixedRowNames || false, enableGroups: config.enableGroups || false, - enableTopScroll: config.enableTopScroll || false, columnsConfig: config.columnsConfig, ...base2, }; @@ -222,6 +221,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { showCalculatedTotals: config.showCalculatedTotals, categoriesColumns: config.categoriesColumns, rowsConfig: config.rowsConfig ?? undefined, + singleCategoryInColumns: config.singleCategoryInColumns ?? false, }; default: return { viewType: config.viewType, ...base2 }; diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index 4e59ddb..db9847e 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -68,6 +68,8 @@ interface BaseSectionConfig { toggleMultiple: Maybe; indicators?: Record; fixedHeaders: boolean; + fixedRowNames: boolean; + enableTopScroll: boolean; } interface BasicSectionConfig extends BaseSectionConfig { @@ -78,9 +80,7 @@ interface GridSectionConfig extends BaseSectionConfig { viewType: "table" | "grid"; calculateTotals: CalculateTotalType; columnsOrder: Maybe; - fixedRowNames: boolean; enableGroups: boolean; - enableTopScroll: boolean; columnsConfig?: Record< string, { @@ -133,6 +133,7 @@ interface GridCategoryColumnsConfig extends BaseSectionConfig { showCalculatedTotals: boolean; categoriesColumns: CategoryColumnConfig[]; rowsConfig: Maybe; + singleCategoryInColumns: boolean; } interface GridWithSubnationalSectionConfig extends BaseSectionConfig { @@ -286,6 +287,7 @@ const DataStoreConfigCodec = Codec.interface({ texts: optional(textsCodec), sections: optional( sectionConfig({ + singleCategoryInColumns: optional(boolean), rowsConfig: optional(record(string, Codec.interface({ cellsVisible: boolean }))), showCalculatedTotals: optional(boolean), categoriesColumns: optional(array(Codec.interface({ dataElementCode: string, categoryCode: string }))), @@ -750,6 +752,8 @@ export class Dhis2DataStoreDataForm { toggleMultiple: sectionConfig.toggleMultiple, indicators: sectionConfig.indicators, fixedHeaders: sectionConfig.fixedHeaders || false, + fixedRowNames: sectionConfig.fixedRowNames || false, + enableTopScroll: sectionConfig.enableTopScroll || false, }; const baseConfig = { ...base, viewType }; @@ -771,9 +775,7 @@ export class Dhis2DataStoreDataForm { viewType, calculateTotals: sectionConfig.calculateTotals, columnsOrder: sectionConfig.columnsOrder, - fixedRowNames: sectionConfig.fixedRowNames || false, enableGroups: sectionConfig.enableGroups || false, - enableTopScroll: sectionConfig.enableTopScroll || false, columnsConfig: sectionConfig.columnsConfig, }; return [section.id, config] as [typeof section.id, typeof config]; @@ -805,6 +807,7 @@ export class Dhis2DataStoreDataForm { categoriesColumns: sectionConfig.categoriesColumns || [], showCalculatedTotals: sectionConfig.showCalculatedTotals || false, rowsConfig: sectionConfig.rowsConfig, + singleCategoryInColumns: sectionConfig.singleCategoryInColumns || false, }; return [section.id, config] as [typeof section.id, typeof config]; } diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index 3c7c187..4f7bcb0 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -90,6 +90,8 @@ export interface SectionBase { toggleMultiple?: DataElementToggle; indicators: Indicator[]; fixedHeaders: boolean; + enableTopScroll: boolean; + fixedRowNames: boolean; } export interface SectionSimple extends SectionBase { @@ -103,10 +105,7 @@ export interface SectionWithPeriods extends SectionBase { export interface SectionGrid extends SectionBase { viewType: "table" | "grid"; - fixedHeaders: boolean; - fixedRowNames: boolean; enableGroups: boolean; - enableTopScroll: boolean; calculateTotals: CalculateTotalType; columnsOrder: Maybe; columnsConfig?: Record; @@ -116,7 +115,6 @@ export interface SectionWithTotals extends SectionBase { viewType: "grid-with-totals"; calculateTotals: CalculateTotalType; columnsOrder: Maybe; - enableTopScroll: boolean; } export interface SectionWithSubnationals extends SectionBase { @@ -138,6 +136,7 @@ export interface SectionWithCategoryColumns extends SectionBase { categoriesColumns: CategoryColumnConfig[]; showCalculatedTotals: boolean; rowsConfig: Maybe; + singleCategoryInColumns: boolean; } export type RowConfig = Record; diff --git a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx index 2c9345a..5516bc4 100644 --- a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx @@ -1,3 +1,4 @@ +import _ from "lodash"; import React from "react"; import { DataTable, @@ -15,6 +16,7 @@ import { CustomDataTableCell, CustomDataTableColumnHeader, fixHeaderClasses } fr import { GridWithCategoryColumnsViewModel } from "./GridWithCategoryColumnsViewModel"; import i18n from "../../../locales"; import { CustomInput } from "./widgets/NumberWidget"; +import { useSyncedScroll } from "./hooks/Scroll"; export interface GridWithCategoryColumnsProps { dataFormInfo: DataFormInfo; @@ -27,17 +29,33 @@ const GridWithCategoryColumns: React.FC = props => () => 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 => { @@ -47,6 +65,7 @@ const GridWithCategoryColumns: React.FC = props => key={column.name} className={classes.centerSpan} colSpan={String(column.colSpan)} + fixed={section.fixedHeaders} > {column.name} @@ -55,26 +74,17 @@ const GridWithCategoryColumns: React.FC = props => - {grid.useIndexes ? ( - - #{" "} - - ) : ( - - )} + {grid.columns.map(column => ( = props => ))} - - - {grid.columns.map(column => - column.categories?.map((category, idx) => ( - - {category} - - )) - )} - + {totalCategories > 0 && ( + + + + {grid.columns.map(column => + column.categories?.map((category, idx) => ( + + {category} + + )) + )} + + )} - {grid.rows.map((row, idx) => ( + {grid.rows.map(row => ( - {grid.useIndexes ? (idx + 1).toString() : row.name} + {row.name} {row.items.map((item, idx) => @@ -144,6 +159,8 @@ const GridWithCategoryColumns: React.FC = props => {i18n.t("Totals")} @@ -178,7 +195,7 @@ const useStyles = makeStyles({ alignItems: "center", }, }, - tableHeader: { position: "sticky", top: 0, zIndex: 2 }, + tableHeader: { position: "sticky", top: 0, zIndex: 3 }, fixedHeaders: fixHeaderClasses.fixedHeaders, }); diff --git a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts index 225ffaa..81c5c0b 100644 --- a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts @@ -20,8 +20,6 @@ export interface Grid { interface Column { name: string; - deName?: string; - cocName?: string; categories?: string[]; totalCategories: number; parentColumnName?: string; @@ -65,18 +63,16 @@ export class GridWithCategoryColumnsViewModel { parentColumns: ParentColumn[]; } { const columns = section.dataElements.map((dataElement): Column => { - const category = this.categoryColumn(dataElement, section.categoriesColumns); + const category = this.getCategoryColumn(dataElement, section.categoriesColumns); const columnName = _(dataElement.name).split(separator).last() || ""; const parentColumnName = _(dataElement.name).split(separator).first() || ""; return { name: columnName, - deName: "", - cocName: "", parentColumnName: parentColumnName, - categories: category?.categoryOptions.map(c => c.name) ?? [], - totalCategories: category?.categoryOptions.length || 0, + categories: section.singleCategoryInColumns ? [] : category?.categoryOptions.map(c => c.name) ?? [], + totalCategories: section.singleCategoryInColumns ? 0 : category?.categoryOptions.length || 0, }; }); @@ -96,12 +92,16 @@ export class GridWithCategoryColumnsViewModel { 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.categoryColumn(dataElement, categoriesColumns); + const category = this.getCategoryColumn(dataElement, categoriesColumns); // Options for the category used for columns const columnOptions = category?.categoryOptions.map(co => co.name) ?? []; @@ -163,7 +163,20 @@ export class GridWithCategoryColumnsViewModel { .value(); } - private static categoryColumn( + 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"] ) { diff --git a/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx b/src/webapp/reports/autogenerated-forms/SectionsTabs.tsx index a96da4f..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 { @@ -266,12 +267,15 @@ const SectionsTabs: React.FC = React.memo(props => { return null; } + const primaryValue = _(order).split(".").first() ?? "0"; + return ( ); } else { @@ -306,7 +310,6 @@ const useStyles = makeStyles({ }); const StyledAppBar = styled(AppBar)` - top: 48px !important; z-index: 100 !important; `; From 042285fd3363c4bc5bacca13a880813ddbed96af Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Thu, 18 Sep 2025 10:15:38 -0500 Subject: [PATCH 18/24] allow using a constant for row names --- src/data/common/Dhis2DataFormRepository.ts | 24 ++++++++++++++++--- src/data/common/Dhis2DataStoreDataForm.ts | 20 ++++++++++++---- src/domain/common/entities/DataForm.ts | 3 ++- .../GridWithCategoryColumnsViewModel.ts | 6 +---- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 2685e69..a9d2512 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -11,7 +11,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"; @@ -214,15 +214,33 @@ export class Dhis2DataFormRepository implements DataFormRepository { }), ...base2, }; - case "grid-category-columns": + 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: config.rowsConfig ?? undefined, + rowsConfig: rowsConfigWithTexts ?? undefined, singleCategoryInColumns: config.singleCategoryInColumns ?? false, }; + } default: return { viewType: config.viewType, ...base2 }; } diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index db9847e..95db81e 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -10,7 +10,6 @@ import { CategoryColumnConfig, ColumnOrder, DescriptionText, - RowConfig, Texts, Totals, } from "../../domain/common/entities/DataForm"; @@ -132,7 +131,7 @@ interface GridCategoryColumnsConfig extends BaseSectionConfig { viewType: "grid-category-columns"; showCalculatedTotals: boolean; categoriesColumns: CategoryColumnConfig[]; - rowsConfig: Maybe; + rowsConfig: Maybe>; singleCategoryInColumns: boolean; } @@ -288,7 +287,12 @@ const DataStoreConfigCodec = Codec.interface({ sections: optional( sectionConfig({ singleCategoryInColumns: optional(boolean), - rowsConfig: optional(record(string, Codec.interface({ cellsVisible: 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) }))), @@ -633,7 +637,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 => [ diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index 4f7bcb0..7f44fb6 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -139,7 +139,8 @@ export interface SectionWithCategoryColumns extends SectionBase { singleCategoryInColumns: boolean; } -export type RowConfig = Record; +export type RowConfig = Record; +export type RowConfigDetails = { cellsVisible: boolean; rowName: string }; export type CategoryColumnConfig = { dataElementCode: Code; categoryCode: Code }; diff --git a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts index 81c5c0b..baca6e1 100644 --- a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts @@ -155,7 +155,7 @@ export class GridWithCategoryColumnsViewModel { return { id: id, - name, + name: rowConfig?.rowName ?? name, cellsVisible: rowConfig?.cellsVisible ?? true, items: group.map(de => ({ dataElement: de })), }; @@ -224,10 +224,6 @@ export class GridWithCategoryColumnsViewModel { return dataValue.value ? Number(dataValue.value) : undefined; } - private static sortRows = (rows: Row[], sortField: "name"): Row[] => { - return _.sortBy(rows, [group => !this.isLetter(group[sortField]), group => group[sortField].toLowerCase()]); - }; - private static isLetter = (value: string): boolean => { return /^[a-zA-Z]/.test(value); }; From 8b15d4b7006bc1fa0ff3ea7a172fe4c0bffa101f Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Fri, 19 Sep 2025 10:21:09 -0500 Subject: [PATCH 19/24] fixed condition for empty row names --- src/data/common/Dhis2DataFormRepository.ts | 2 +- src/domain/common/entities/DataForm.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index a9d2512..4034dfe 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -225,7 +225,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { key, { cellsVisible: rowConfig.cellsVisible ?? true, - rowName: constant?.displayDescription ?? "", + rowName: constant?.displayDescription, }, ]; }) diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index 7f44fb6..017d397 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -140,7 +140,7 @@ export interface SectionWithCategoryColumns extends SectionBase { } export type RowConfig = Record; -export type RowConfigDetails = { cellsVisible: boolean; rowName: string }; +export type RowConfigDetails = { cellsVisible: boolean; rowName: Maybe }; export type CategoryColumnConfig = { dataElementCode: Code; categoryCode: Code }; From 39c0dddc7e37bc1c0ade29ee902b2b3739edafaf Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Wed, 1 Oct 2025 12:29:05 -0500 Subject: [PATCH 20/24] custom width for first column in grid viewType --- src/data/common/Dhis2DataFormRepository.ts | 2 ++ src/data/common/Dhis2DataStoreDataForm.ts | 10 +++++++++- src/domain/common/entities/DataForm.ts | 3 +++ src/webapp/reports/autogenerated-forms/GridForm.tsx | 3 ++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 4034dfe..8f484e7 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -167,6 +167,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { fixedRowNames: false, enableTopScroll: false, columnsConfig: undefined, + firstColumnConfig: undefined, }; const base2 = getSectionBaseWithToggle(config, base, dataElements); @@ -183,6 +184,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { columnsOrder: config.columnsOrder, enableGroups: config.enableGroups || false, columnsConfig: config.columnsConfig, + firstColumnConfig: config.firstColumnConfig, ...base2, }; case "grid-with-subnational-ous": diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index 95db81e..6a4c121 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -1,4 +1,4 @@ -import _ from "lodash"; +import _, { first } from "lodash"; import { D2Api } from "@eyeseetea/d2-api/2.34"; import { boolean, Codec, exactly, GetType, oneOf, optional, record, string, number, array } from "purify-ts"; import { Namespaces } from "./clients/storage/Namespaces"; @@ -86,6 +86,9 @@ interface GridSectionConfig extends BaseSectionConfig { rules?: FromRulesFormulaCodec; } >; + firstColumnConfig?: { + width: number; + }; } interface GridWithPeriodsSectionConfig extends BaseSectionConfig { @@ -101,6 +104,9 @@ interface GridWithTotalsSectionConfig extends BaseSectionConfig { enableGroups: boolean; enableTopScroll: boolean; columnsConfig?: GridColumnsConfig; + firstColumnConfig?: { + width: number; + }; } interface GridIndicatorsCalculated extends BaseSectionConfig { @@ -286,6 +292,7 @@ const DataStoreConfigCodec = Codec.interface({ texts: optional(textsCodec), sections: optional( sectionConfig({ + firstColumnConfig: optional(Codec.interface({ width: number })), singleCategoryInColumns: optional(boolean), rowsConfig: optional( record( @@ -789,6 +796,7 @@ export class Dhis2DataStoreDataForm { columnsOrder: sectionConfig.columnsOrder, enableGroups: sectionConfig.enableGroups || false, columnsConfig: sectionConfig.columnsConfig, + firstColumnConfig: sectionConfig.firstColumnConfig, }; return [section.id, config] as [typeof section.id, typeof config]; } diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index 017d397..02cdf24 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -109,6 +109,9 @@ export interface SectionGrid extends SectionBase { calculateTotals: CalculateTotalType; columnsOrder: Maybe; columnsConfig?: Record; + firstColumnConfig: Maybe<{ + width: number; + }>; } export interface SectionWithTotals extends SectionBase { diff --git a/src/webapp/reports/autogenerated-forms/GridForm.tsx b/src/webapp/reports/autogenerated-forms/GridForm.tsx index 12ea31a..b8de641 100644 --- a/src/webapp/reports/autogenerated-forms/GridForm.tsx +++ b/src/webapp/reports/autogenerated-forms/GridForm.tsx @@ -50,6 +50,7 @@ const GridForm: React.FC = props => { const fixColumns = section.fixedHeaders; const fixRows = section.fixedRowNames; const mainContentStyles = fixColumns ? fixHeaderClasses.fixedHeaders : {}; + const firstColumnWidth = section.firstColumnConfig?.width || 800; return ( @@ -88,7 +89,7 @@ const GridForm: React.FC = props => { !_.isEmpty(grid.rows) && ( From 8ec759e843908b83bbaecec8e1391806ae1008bc Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Wed, 1 Oct 2025 12:29:27 -0500 Subject: [PATCH 21/24] custom width for first column in grid viewType --- src/data/common/Dhis2DataStoreDataForm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/common/Dhis2DataStoreDataForm.ts b/src/data/common/Dhis2DataStoreDataForm.ts index 6a4c121..ca0d394 100644 --- a/src/data/common/Dhis2DataStoreDataForm.ts +++ b/src/data/common/Dhis2DataStoreDataForm.ts @@ -1,4 +1,4 @@ -import _, { first } from "lodash"; +import _ from "lodash"; import { D2Api } from "@eyeseetea/d2-api/2.34"; import { boolean, Codec, exactly, GetType, oneOf, optional, record, string, number, array } from "purify-ts"; import { Namespaces } from "./clients/storage/Namespaces"; From 369c931d4ebabee3753527fb4d8e4fa14988e807 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Mon, 6 Oct 2025 12:48:52 -0500 Subject: [PATCH 22/24] fix totals for grid with category columns, increase zindex for single select --- .../reports/autogenerated-forms/AutogeneratedForm.tsx | 2 +- .../autogenerated-forms/GridWithCategoryColumns.tsx | 2 +- .../GridWithCategoryColumnsViewModel.ts | 7 ++----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx b/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx index 007dfb6..385c96c 100644 --- a/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx +++ b/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx @@ -189,7 +189,7 @@ function useDataFormInfo(): [ // 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"); diff --git a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx index 5516bc4..799dcfe 100644 --- a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumns.tsx @@ -171,7 +171,7 @@ const GridWithCategoryColumns: React.FC = props => backgroundColor={section.styles.rows.backgroundColor} className={classes.centerSpan} > - + ))} diff --git a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts index baca6e1..0a70739 100644 --- a/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts +++ b/src/webapp/reports/autogenerated-forms/GridWithCategoryColumnsViewModel.ts @@ -43,6 +43,7 @@ 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, @@ -54,7 +55,7 @@ export class GridWithCategoryColumnsViewModel { useIndexes: false, parentColumns: parentColumns, toggleMultiple: section.toggleMultiple, - calculateTotals: this.calculateTotals(rows, dataFormInfo), + calculateTotals: calculateTotals, }; } @@ -223,8 +224,4 @@ export class GridWithCategoryColumnsViewModel { return dataValue.value ? Number(dataValue.value) : undefined; } - - private static isLetter = (value: string): boolean => { - return /^[a-zA-Z]/.test(value); - }; } From f257087784e7dcf098eb52510c0b34b32bdf229c Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Mon, 6 Oct 2025 19:46:21 -0500 Subject: [PATCH 23/24] added support for compulsory data values --- src/data/common/Dhis2DataFormRepository.ts | 5 + src/data/common/Dhis2DataValueRepository.ts | 160 +++++++++++++++++- .../common/entities/CompulsoryDataValue.ts | 5 + src/domain/common/entities/DataForm.ts | 2 + src/domain/common/entities/DataValue.ts | 1 + .../usecases/GetDataFormValuesUseCase.ts | 9 +- .../common/usecases/SaveDataFormValue.ts | 20 ++- .../__tests__/SaveDataFormValue.spec.ts | 6 +- .../usecases/__tests__/data/dataValue.ts | 5 + .../autogenerated-forms/AutogeneratedForm.tsx | 89 +++++++--- .../autogenerated-forms/DataEntryItem.tsx | 55 ++++-- .../autogenerated-forms/WidgetFeedback.tsx | 3 +- .../useDataEntrySelector.ts | 1 + 13 files changed, 306 insertions(+), 55 deletions(-) create mode 100644 src/domain/common/entities/CompulsoryDataValue.ts diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 8f484e7..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, @@ -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) + ), }; } @@ -530,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/Dhis2DataValueRepository.ts b/src/data/common/Dhis2DataValueRepository.ts index 7db219d..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, @@ -38,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) { @@ -50,10 +62,13 @@ 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 { type } = dataElement; @@ -138,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( @@ -182,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 { 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/DataForm.ts b/src/domain/common/entities/DataForm.ts index 02cdf24..c506b11 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -15,6 +15,7 @@ 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; @@ -28,6 +29,7 @@ export interface DataForm { }; indicators: Indicator[]; totalRules: TotalRules; + compulsoryDataValues: CompulsoryDataValue[]; } export interface Texts { diff --git a/src/domain/common/entities/DataValue.ts b/src/domain/common/entities/DataValue.ts index a726f26..8de06da 100644 --- a/src/domain/common/entities/DataValue.ts +++ b/src/domain/common/entities/DataValue.ts @@ -16,6 +16,7 @@ export interface DataValueBase { orgUnitId: Id; period: Period; categoryOptionComboId: Id; + isRequired?: boolean; } export interface DataValueBoolean extends DataValueBase { 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/AutogeneratedForm.tsx b/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx index 385c96c..70cb103 100644 --- a/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx +++ b/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx @@ -43,7 +43,7 @@ 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; @@ -70,7 +70,7 @@ const AutogeneratedForm: React.FC = () => { return (
- {} + {} {rules.length > 0 && ( @@ -171,7 +171,8 @@ function useDataFormInfo(): [ boolean, ValidationResult[], IgnoreValidationRule[], - (ValidationResult: ValidationResult) => void + (ValidationResult: ValidationResult) => void, + boolean ] { const [key] = React.useState(0); const { compositionRoot, config } = useAppContext(); @@ -181,10 +182,51 @@ 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. @@ -219,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 @@ -231,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 => { @@ -252,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 }) @@ -348,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 a526911..f7fa598 100644 --- a/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx +++ b/src/webapp/reports/autogenerated-forms/DataEntryItem.tsx @@ -230,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 @@ -263,7 +265,7 @@ const DataEntryItem: React.FC = props => { dataValue={dataValue} options={options.items} onValueChange={notifyChange} - state={state} + state={hasCompulsoryError} disabled={isDisabled || disabled} /> ); @@ -273,7 +275,7 @@ const DataEntryItem: React.FC = props => { dataValue={dataValue} options={options.items} onValueChange={notifyChange} - state={state} + state={hasCompulsoryError} disabled={isDisabled || disabled} /> ); @@ -285,7 +287,7 @@ const DataEntryItem: React.FC = props => { dataFormInfo={dataFormInfo} options={options.items} onValueChange={notifyChange} - state={state} + state={hasCompulsoryError} disabled={isDisabled || disabled} sourceTypeDEs={[]} rows={rows} @@ -297,7 +299,7 @@ const DataEntryItem: React.FC = props => { dataValue={dataValue} options={options.items} onValueChange={notifyChange} - state={state} + state={hasCompulsoryError} disabled={isDisabled || disabled} /> ) : ( @@ -305,7 +307,7 @@ const DataEntryItem: React.FC = props => { dataValue={dataValue} options={options.items} onValueChange={notifyChange} - state={state} + state={hasCompulsoryError} disabled={isDisabled || disabled} /> ); @@ -316,7 +318,7 @@ const DataEntryItem: React.FC = props => { dataValue={dataValue} options={options.items} onValueChange={notifyChange} - state={state} + state={hasCompulsoryError} disabled={isDisabled || disabled} /> ) : ( @@ -324,7 +326,7 @@ const DataEntryItem: React.FC = props => { dataValue={dataValue} options={options.items} onValueChange={notifyChange} - state={state} + state={hasCompulsoryError} disabled={isDisabled || disabled} /> ); @@ -338,7 +340,7 @@ const DataEntryItem: React.FC = props => { ); @@ -347,7 +349,7 @@ const DataEntryItem: React.FC = props => { ); @@ -356,7 +358,7 @@ const DataEntryItem: React.FC = props => { ); @@ -365,7 +367,7 @@ const DataEntryItem: React.FC = props => { ); @@ -374,7 +376,7 @@ const DataEntryItem: React.FC = props => { ); @@ -383,7 +385,7 @@ const DataEntryItem: React.FC = props => { ); @@ -424,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/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/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; From b076443dbe99d818cd84baa88cb00533d5364bd3 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Fri, 10 Oct 2025 09:58:09 -0500 Subject: [PATCH 24/24] only show error message if complete only flag is active --- src/data/common/Dhis2DataFormRepository.ts | 2 ++ src/domain/common/entities/DataForm.ts | 1 + .../reports/autogenerated-forms/AutogeneratedForm.tsx | 10 ++++++++-- .../reports/autogenerated-forms/WidgetFeedback.tsx | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/data/common/Dhis2DataFormRepository.ts b/src/data/common/Dhis2DataFormRepository.ts index 324ab02..3dc4180 100644 --- a/src/data/common/Dhis2DataFormRepository.ts +++ b/src/data/common/Dhis2DataFormRepository.ts @@ -58,6 +58,7 @@ export class Dhis2DataFormRepository implements DataFormRepository { compulsoryDataValues: dataSet.compulsoryDataElementOperands.map( operand => new CompulsoryDataValue(operand.dataElement.id, operand.categoryOptionCombo.id) ), + showErrorOnCompulsory: dataSet.compulsoryFieldsCompleteOnly, }; } @@ -534,6 +535,7 @@ function getMetadataQuery(options: { dataSetId: Id }) { id: true, code: true, expiryDays: true, + compulsoryFieldsCompleteOnly: true, compulsoryDataElementOperands: { dataElement: { id: true }, categoryOptionCombo: { id: true } }, dataInputPeriods: { closingDate: true, diff --git a/src/domain/common/entities/DataForm.ts b/src/domain/common/entities/DataForm.ts index c506b11..238bae3 100644 --- a/src/domain/common/entities/DataForm.ts +++ b/src/domain/common/entities/DataForm.ts @@ -30,6 +30,7 @@ export interface DataForm { indicators: Indicator[]; totalRules: TotalRules; compulsoryDataValues: CompulsoryDataValue[]; + showErrorOnCompulsory: boolean; } export interface Texts { diff --git a/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx b/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx index 70cb103..521441e 100644 --- a/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx +++ b/src/webapp/reports/autogenerated-forms/AutogeneratedForm.tsx @@ -199,6 +199,7 @@ function useDataFormInfo(): [ if (!dataForm) return; // add event listener to button #completeButton const completeButton = document.getElementById("completeButton"); + const incompleteButton = document.getElementById("undoButton"); function handleCompleteButtonClick() { // Data Entry display a different popup error for custom forms after complete/incomplete action document.querySelector("#headerMessage")?.remove(); @@ -210,7 +211,7 @@ function useDataFormInfo(): [ }) .then(({ store, dataValues }) => { const requiredDataValues = dataValues.filter(dv => dv.isRequired); - if (requiredDataValues.length > 0) { + if (requiredDataValues.length > 0 && dataForm?.showErrorOnCompulsory) { window.dhis2?.de.displayValidationDialog( `

Validation Result  

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

`, 300 @@ -224,7 +225,12 @@ function useDataFormInfo(): [ }); } completeButton?.addEventListener("click", handleCompleteButtonClick); - return () => completeButton?.removeEventListener("click", handleCompleteButtonClick); + incompleteButton?.addEventListener("click", handleCompleteButtonClick); + + return () => { + completeButton?.removeEventListener("click", handleCompleteButtonClick); + incompleteButton?.removeEventListener("click", handleCompleteButtonClick); + }; }, [dataForm, orgUnits, compositionRoot.dataForms, dataSetId, orgUnitId, period]); React.useEffect(() => { diff --git a/src/webapp/reports/autogenerated-forms/WidgetFeedback.tsx b/src/webapp/reports/autogenerated-forms/WidgetFeedback.tsx index b540365..83a0cd6 100644 --- a/src/webapp/reports/autogenerated-forms/WidgetFeedback.tsx +++ b/src/webapp/reports/autogenerated-forms/WidgetFeedback.tsx @@ -13,7 +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" }, + required: { ...baseStyles, backgroundColor: "red" }, }; export const WidgetFeedback: React.FC<{ state: WidgetState }> = React.memo(props => {