diff --git a/i18n/en.pot b/i18n/en.pot index 2d0dd273..dfa46fae 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-11-12T16:45:33.366Z\n" -"PO-Revision-Date: 2025-11-12T16:45:33.366Z\n" +"POT-Creation-Date: 2026-03-18T04:23:33.926Z\n" +"PO-Revision-Date: 2026-03-18T04:23:33.926Z\n" msgid "Events - Create/update" msgstr "" @@ -38,9 +38,24 @@ msgstr "" msgid "Data values - Create/update" msgstr "" +msgid "No data values to import" +msgstr "" + msgid "Failed to import data values" msgstr "" +msgid "" +"{{count}} chunk(s) returned no summary — import result unknown for those " +"records." +msgid_plural "" +"{{count}} chunk(s) returned no summary — import result unknown for those " +"records." +msgstr[0] "" +msgstr[1] "" + +msgid "Chunk (unknown outcome)" +msgstr "" + msgid "Service" msgstr "" @@ -188,7 +203,7 @@ msgstr "" msgid "Default" msgstr "" -msgid "Delete and import" +msgid "Delete and Import" msgstr "" msgid "Import despite duplicates" @@ -1310,9 +1325,6 @@ msgid "" "existing values before importing the data?" msgstr "" -msgid "Delete and Import" -msgstr "" - msgid "" "All data values in the spreadsheet will be imported to the system, but any " "data that was existing for such organisation unit and periods in the system " diff --git a/i18n/es.po b/i18n/es.po index 2d105d8f..968b8f1d 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2025-11-12T16:45:33.366Z\n" +"POT-Creation-Date: 2026-03-18T04:23:33.926Z\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -41,9 +41,25 @@ msgstr "Valores de los datos - Borrar" msgid "Data values - Create/update" msgstr "Valores de los datos - Crear/actualizar" +#, fuzzy +msgid "No data values to import" +msgstr "valores de datos" + msgid "Failed to import data values" msgstr "" +msgid "" +"{{count}} chunk(s) returned no summary — import result unknown for those " +"records." +msgid_plural "" +"{{count}} chunk(s) returned no summary — import result unknown for those " +"records." +msgstr[0] "" +msgstr[1] "" + +msgid "Chunk (unknown outcome)" +msgstr "" + msgid "Service" msgstr "" @@ -211,9 +227,8 @@ msgstr "" msgid "Default" msgstr "" -#, fuzzy -msgid "Delete and import" -msgstr "Importación de datos" +msgid "Delete and Import" +msgstr "Borrar e Importar" #, fuzzy msgid "Import despite duplicates" @@ -1291,9 +1306,9 @@ msgid "" "Samaritan’s Purse, Medecins Sans Frontières (MSF), the the Norwegian Refugee " "Council (NRC) and the Clinton Health Access Initiative (CHAI) to support " "countries in strengthening the collection and use of health data by using " -"DHIS2. The application has been developed by [EyeSeeTea SL](http://" -"eyeseetea.com). Source code, documentation and release notes can be found at " -"the [EyeSeetea GitHub Project Page](https://eyeseetea.github.io/Bulk-Load-" +"DHIS2. The application has been developed by [EyeSeeTea SL](http://eyeseetea." +"com). Source code, documentation and release notes can be found at the " +"[EyeSeetea GitHub Project Page](https://eyeseetea.github.io/Bulk-Load-" "blessed/)." msgstr "" @@ -1384,26 +1399,23 @@ msgstr "" "remplazarán por aquellos presentes en el archivo. ¿Está seguro que desea " "continuar?" -msgid "Delete and Import" -msgstr "" - msgid "" "All data values in the spreadsheet will be imported to the system, but any " "data that was existing for such organisation unit and periods in the system " "will be deleted first, so none will be kept before doing the import." -msgstr "" +msgstr "Todos los valores de la hoja de cálculo se importarán al sistema, pero cualquier dato existente para dicha unidad organizativa y periodos se eliminará primero, por lo que no se conservará nada antes de realizar la importación." msgid "" "Import only new data values, without updating nor deleting any existing one. " "Only values in the spreadsheet that do not currently exist in the system " "will be imported" -msgstr "" +msgstr "Importar solo valores de datos nuevos, sin actualizar ni eliminar los existentes. Solo se importarán los valores de la hoja de cálculo que no existan actualmente en el sistema." msgid "" "Import new data values and also update existing ones. All data values in the " "spreadsheet will be imported to the system, but other data values present in " "the system that are not provided in the spreadsheet will be kept." -msgstr "" +msgstr "Importar nuevos valores de datos y actualizar los existentes. Todos los valores de la hoja de cálculo se importarán al sistema, pero se mantendrán los demás valores presentes en el sistema que no figuren en la hoja de cálculo." msgid "Warning: Your upload may result in the generation of duplicates" msgstr "Alerta: Su importación puede generar duplicados" @@ -1469,7 +1481,3 @@ msgstr "" msgid "Settings saved" msgstr "Configuración guardada" - -#, fuzzy -#~ msgid "Pending" -#~ msgstr "Encabezamientos" diff --git a/i18n/fr.po b/i18n/fr.po index 7b362091..09085c43 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load App\n" -"POT-Creation-Date: 2025-11-12T16:45:33.366Z\n" +"POT-Creation-Date: 2026-03-18T04:23:33.926Z\n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -41,9 +41,25 @@ msgstr "" msgid "Data values - Create/update" msgstr "" +#, fuzzy +msgid "No data values to import" +msgstr "valeurs de données" + msgid "Failed to import data values" msgstr "" +msgid "" +"{{count}} chunk(s) returned no summary — import result unknown for those " +"records." +msgid_plural "" +"{{count}} chunk(s) returned no summary — import result unknown for those " +"records." +msgstr[0] "" +msgstr[1] "" + +msgid "Chunk (unknown outcome)" +msgstr "" + msgid "Service" msgstr "" @@ -205,9 +221,8 @@ msgstr "" msgid "Default" msgstr "" -#, fuzzy -msgid "Delete and import" -msgstr "Importation de données en masse" +msgid "Delete and Import" +msgstr "SUPPRIMER ET IMPORTER" #, fuzzy msgid "Import despite duplicates" @@ -1310,9 +1325,9 @@ msgid "" "Samaritan’s Purse, Medecins Sans Frontières (MSF), the the Norwegian Refugee " "Council (NRC) and the Clinton Health Access Initiative (CHAI) to support " "countries in strengthening the collection and use of health data by using " -"DHIS2. The application has been developed by [EyeSeeTea SL](http://" -"eyeseetea.com). Source code, documentation and release notes can be found at " -"the [EyeSeetea GitHub Project Page](https://eyeseetea.github.io/Bulk-Load-" +"DHIS2. The application has been developed by [EyeSeeTea SL](http://eyeseetea." +"com). Source code, documentation and release notes can be found at the " +"[EyeSeetea GitHub Project Page](https://eyeseetea.github.io/Bulk-Load-" "blessed/)." msgstr "" @@ -1406,26 +1421,31 @@ msgstr "" "valeurs de données seront supprimées et seules celles de la feuille de " "calcul seront enregistrées. Êtes-vous sur de vouloir continuer?" -msgid "Delete and Import" -msgstr "" - msgid "" "All data values in the spreadsheet will be imported to the system, but any " "data that was existing for such organisation unit and periods in the system " "will be deleted first, so none will be kept before doing the import." msgstr "" +"Toutes les données de la feuille de calcul seront importées dans le système," +"mais les données existantes pour ces unités d'organisation et ces périodes seront d'abord supprimées" +"; aucune donnée existante ne sera donc conservée avant l'importation." msgid "" "Import only new data values, without updating nor deleting any existing one. " "Only values in the spreadsheet that do not currently exist in the system " "will be imported" msgstr "" +"Importer uniquement les nouvelles valeurs, sans mettre à jour ni supprimer les données existantes." +"Seules les valeurs de la feuille de calcul qui n'existent pas encore dans le système seront importées." msgid "" "Import new data values and also update existing ones. All data values in the " "spreadsheet will be imported to the system, but other data values present in " "the system that are not provided in the spreadsheet will be kept." msgstr "" +"Importer les nouvelles valeurs et mettre à jour les valeurs existantes." +"Toutes les données de la feuille de calcul seront importées," +"mais les autres valeurs présentes dans le système et absentes de la feuille de calcul seront conservées." msgid "Warning: Your upload may result in the generation of duplicates" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index b3848033..39ff4dea 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2025-11-12T16:45:33.366Z\n" +"POT-Creation-Date: 2026-03-18T04:23:33.926Z\n" "Language: pt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -41,9 +41,25 @@ msgstr "Valores de dados - Eliminar" msgid "Data values - Create/update" msgstr "Valores dos dados - Criar/actualizar" +#, fuzzy +msgid "No data values to import" +msgstr "valores de dados" + msgid "Failed to import data values" msgstr "" +msgid "" +"{{count}} chunk(s) returned no summary — import result unknown for those " +"records." +msgid_plural "" +"{{count}} chunk(s) returned no summary — import result unknown for those " +"records." +msgstr[0] "" +msgstr[1] "" + +msgid "Chunk (unknown outcome)" +msgstr "" + msgid "Service" msgstr "" @@ -213,9 +229,8 @@ msgstr "" msgid "Default" msgstr "" -#, fuzzy -msgid "Delete and import" -msgstr "Importação de dados em massa" +msgid "Delete and Import" +msgstr "" #, fuzzy msgid "Import despite duplicates" @@ -1346,9 +1361,9 @@ msgid "" "Samaritan’s Purse, Medecins Sans Frontières (MSF), the the Norwegian Refugee " "Council (NRC) and the Clinton Health Access Initiative (CHAI) to support " "countries in strengthening the collection and use of health data by using " -"DHIS2. The application has been developed by [EyeSeeTea SL](http://" -"eyeseetea.com). Source code, documentation and release notes can be found at " -"the [EyeSeetea GitHub Project Page](https://eyeseetea.github.io/Bulk-Load-" +"DHIS2. The application has been developed by [EyeSeeTea SL](http://eyeseetea." +"com). Source code, documentation and release notes can be found at the " +"[EyeSeetea GitHub Project Page](https://eyeseetea.github.io/Bulk-Load-" "blessed/)." msgstr "" @@ -1441,9 +1456,6 @@ msgstr "" "dados serão excluídos e remplazados pelos dados da planilha. Tem certeza que " "deseja continuar?" -msgid "Delete and Import" -msgstr "" - msgid "" "All data values in the spreadsheet will be imported to the system, but any " "data that was existing for such organisation unit and periods in the system " diff --git a/i18n/ru.po b/i18n/ru.po index eb4bfe74..ce368f6d 100644 --- a/i18n/ru.po +++ b/i18n/ru.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2025-11-12T16:45:33.366Z\n" +"POT-Creation-Date: 2026-03-18T04:23:33.926Z\n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -42,9 +42,26 @@ msgstr "Значения данных - Удалить" msgid "Data values - Create/update" msgstr "Значения данных - создание/обновление" +#, fuzzy +msgid "No data values to import" +msgstr "значения данных" + msgid "Failed to import data values" msgstr "" +msgid "" +"{{count}} chunk(s) returned no summary — import result unknown for those " +"records." +msgid_plural "" +"{{count}} chunk(s) returned no summary — import result unknown for those " +"records." +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "Chunk (unknown outcome)" +msgstr "" + msgid "Service" msgstr "" @@ -214,9 +231,8 @@ msgstr "" msgid "Default" msgstr "" -#, fuzzy -msgid "Delete and import" -msgstr "Массовый импорт данных" +msgid "Delete and Import" +msgstr "" #, fuzzy msgid "Import despite duplicates" @@ -1350,9 +1366,9 @@ msgid "" "Samaritan’s Purse, Medecins Sans Frontières (MSF), the the Norwegian Refugee " "Council (NRC) and the Clinton Health Access Initiative (CHAI) to support " "countries in strengthening the collection and use of health data by using " -"DHIS2. The application has been developed by [EyeSeeTea SL](http://" -"eyeseetea.com). Source code, documentation and release notes can be found at " -"the [EyeSeetea GitHub Project Page](https://eyeseetea.github.io/Bulk-Load-" +"DHIS2. The application has been developed by [EyeSeeTea SL](http://eyeseetea." +"com). Source code, documentation and release notes can be found at the " +"[EyeSeetea GitHub Project Page](https://eyeseetea.github.io/Bulk-Load-" "blessed/)." msgstr "" @@ -1445,9 +1461,6 @@ msgstr "" "данных будут удалены, и будут сохранены только те, которые находятся в " "электронной таблице. Вы уверены?" -msgid "Delete and Import" -msgstr "" - msgid "" "All data values in the spreadsheet will be imported to the system, but any " "data that was existing for such organisation unit and periods in the system " diff --git a/package.json b/package.json index 49174880..430f5783 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bulk-load", "description": "Bulk importing made easy", - "version": "3.32.0", + "version": "3.33.0", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".", @@ -16,7 +16,7 @@ "@dhis2/ui-core": "6.24.0", "@dhis2/ui-widgets": "6.24.0", "@eyeseetea/d2-api": "1.20.0", - "@eyeseetea/d2-ui-components": "2.11.0-beta.1", + "@eyeseetea/d2-ui-components": "2.12.0", "@eyeseetea/feedback-component": "0.1.3-beta.3", "@eyeseetea/xlsx-populate": "4.3.2-beta.1", "@material-ui/core": "4.12.3", diff --git a/src/data/InstanceDhisRepository.ts b/src/data/InstanceDhisRepository.ts index 8b6b7b7f..9efadfe4 100644 --- a/src/data/InstanceDhisRepository.ts +++ b/src/data/InstanceDhisRepository.ts @@ -28,7 +28,11 @@ import { DhisInstance } from "../domain/entities/DhisInstance"; import { Locale } from "../domain/entities/Locale"; import { OrgUnit } from "../domain/entities/OrgUnit"; import { NamedRef, Ref } from "../domain/entities/ReferenceObject"; -import { SynchronizationResult } from "../domain/entities/SynchronizationResult"; +import { + SynchronizationResult, + SynchronizationStats, + SynchronizationStatus, +} from "../domain/entities/SynchronizationResult"; import { Program, TrackedEntityInstance } from "../domain/entities/TrackedEntityInstance"; import { BuilderMetadata, @@ -46,6 +50,7 @@ import { D2TrackedEntityType, DataStore, DataValueSetsGetResponse, + DataValueSetsPostResponse, Id, SelectedPick, D2SharingSchema, @@ -458,13 +463,28 @@ export class InstanceDhisRepository implements InstanceRepository { const title = importStrategy === "DELETE" ? i18n.t("Data values - Delete") : i18n.t("Data values - Create/update"); - const { response } = await this.api.dataValues - .postSetAsync({ importStrategy }, { dataSet: dataSetId, dataValues }) - .getData(); + if (dataValues.length === 0) { + return { + title, + status: "SUCCESS", + message: i18n.t("No data values to import"), + stats: [{ imported: 0, deleted: 0, updated: 0, ignored: 0 }], + errors: [], + rawResponse: {}, + }; + } - const importSummary = await this.api.system.waitFor(response.jobType, response.id).getData(); + const chunks = _.chunk(dataValues, 1000); - if (!importSummary) { + const chunkResults = await promiseMap(chunks, async chunk => { + const { response } = await this.api.dataValues + .postSetAsync({ importStrategy }, { dataSet: dataSetId, dataValues: chunk }) + .getData(); + + return this.api.system.waitFor(response.jobType, response.id).getData(); + }); + + if (chunkResults.every(r => !r)) { return { title, status: "ERROR", @@ -475,21 +495,71 @@ export class InstanceDhisRepository implements InstanceRepository { }; } - const { status, description, conflicts, importCount } = importSummary; - const { imported, deleted, updated, ignored } = importCount; - const errors = conflicts?.map(({ object, value }) => ({ id: object, message: value, details: "" })) ?? []; + const { mergedStatus, mergedDescription, mergedImportCount, nullChunkStats, summaries } = + this.mergeChunkResults(chunks, chunkResults); + + const allConflicts = _.flatMap(summaries, s => s.conflicts ?? []); + const errors = allConflicts.map(({ object, value }) => ({ id: object, message: value, details: "" })); const errorDetails = await getMetadataDetailsFromErrors(this.api, errors); return { title, - status, - message: description, - stats: [{ imported, deleted, updated, ignored }], + status: mergedStatus, + message: mergedDescription, + stats: [mergedImportCount, ...nullChunkStats], errors: errorDetails, - rawResponse: importSummary, + rawResponse: summaries, }; } + private mergeChunkResults(chunks: AggregatedDataValue[][], chunkResults: Array) { + const emptyChunkCount = chunkResults.filter(r => !r).length; + const hasEmptySummaries = emptyChunkCount > 0; + const summaries = _.compact(chunkResults); + + const uniqueStatuses = _.uniq(summaries.map(s => s.status)); + const mergedStatus: SynchronizationStatus = + hasEmptySummaries || uniqueStatuses.length !== 1 ? "WARNING" : uniqueStatuses[0] ?? "WARNING"; + + const mergedDescription = [ + ..._.uniq(summaries.map(s => s.description).filter(Boolean)), + ...(hasEmptySummaries + ? [ + i18n.t("{{count}} chunk(s) returned no summary — import result unknown for those records.", { + count: emptyChunkCount, + }), + ] + : []), + ].join(" / "); + + const mergedImportCount = summaries.reduce( + (acc, s) => ({ + imported: acc.imported + s.importCount.imported, + deleted: acc.deleted + s.importCount.deleted, + updated: acc.updated + s.importCount.updated, + ignored: acc.ignored + s.importCount.ignored, + }), + { imported: 0, deleted: 0, updated: 0, ignored: 0 } + ); + + // Add a dedicated stat row per null chunk with total to unknown outcomes are explicitly. + const nullChunkStats: SynchronizationStats[] = hasEmptySummaries + ? chunkResults + .map((result, i) => ({ result, chunk: chunks[i] })) + .filter(({ result }) => !result) + .map(({ chunk }) => ({ + type: i18n.t("Chunk (unknown outcome)"), + imported: 0, + deleted: 0, + updated: 0, + ignored: 0, + total: chunk?.length ?? 0, + })) + : []; + + return { mergedStatus, mergedDescription, mergedImportCount, nullChunkStats, summaries }; + } + // TODO: Review when data validation comes in private async validateAggregateImportPackage(dataValues: AggregatedDataValue[]) { const dataElements = _.uniq(dataValues.map(({ dataElement }) => dataElement)); diff --git a/src/domain/entities/Template.ts b/src/domain/entities/Template.ts index 04c4748c..8c2621e1 100644 --- a/src/domain/entities/Template.ts +++ b/src/domain/entities/Template.ts @@ -162,7 +162,7 @@ export interface Range { } type BaseDataProcessingRule = { - type: "coalesce"; + type: "coalesce" | "override"; condition: "onExport"; description?: string; }; @@ -170,11 +170,18 @@ type BaseDataProcessingRule = { export type DataProcessingRuleCoalesce = BaseDataProcessingRule & { type: "coalesce"; condition: "onExport"; - destination: ColumnRef; + destination: ColumnRef | RowRef; targetIds: Id[]; // attribute or data element id }; -type DataProcessingRule = DataProcessingRuleCoalesce; +export type DataProcessingRuleOverride = BaseDataProcessingRule & { + type: "override"; + condition: "onExport"; + destination: ColumnRef | RowRef; + target: ColumnRef | RowRef; +}; + +type DataProcessingRule = DataProcessingRuleCoalesce | DataProcessingRuleOverride; interface BaseDataSource { type: DataSourceType; @@ -217,6 +224,7 @@ export interface TrackerEventRowDataSource { sortBy?: string; onlyLastEvent?: boolean; dataElementProcessingRules?: DataProcessingRule[]; + multiTextDataElementDelimiter?: string; } export interface RowDataSource extends BaseDataSource { @@ -233,6 +241,8 @@ export interface RowDataSource extends BaseDataSource { longitude: ColumnRef | CellRef | ValueRef; }; geometry?: ColumnRef | CellRef | ValueRef; + multiTextDataElementDelimiter?: string; + dataElementProcessingRules?: DataProcessingRule[]; } export interface TeiRowDataSource { @@ -262,6 +272,8 @@ export interface ColumnDataSource extends BaseDataSource { categoryOption?: ColumnRef; attribute?: RowRef | CellRef; eventId?: RowRef | CellRef; + multiTextDataElementDelimiter?: string; + dataElementProcessingRules?: DataProcessingRule[]; } export interface CellDataSource extends BaseDataSource { @@ -273,6 +285,8 @@ export interface CellDataSource extends BaseDataSource { categoryOption?: CellRef | ValueRef; attribute?: CellRef | ValueRef; eventId?: CellRef | ValueRef; + multiTextDataElementDelimiter?: string; + dataElementProcessingRules?: DataProcessingRule[]; } interface DataFormRef { @@ -576,6 +590,24 @@ function mapFromProgramData(entry: ProgramPackageData): TemplateDataPackageData }; } +type DataSourceWithMultiTextDelimiter = RowDataSource | TrackerEventRowDataSource | ColumnDataSource | CellDataSource; + +export function hasMultiTextDataElementDelimiter( + dataSource: DataSource +): dataSource is DataSourceWithMultiTextDelimiter { + return ( + typeof dataSource !== "function" && + (dataSource.type === "row" || + dataSource.type === "rowTrackedEvent" || + dataSource.type === "column" || + dataSource.type === "cell") + ); +} + export function isDataProcessingRuleCoalesce(rule: DataProcessingRule): rule is DataProcessingRuleCoalesce { return rule.type === "coalesce"; } + +export function isDataProcessingRuleOverride(rule: DataProcessingRule): rule is DataProcessingRuleOverride { + return rule.type === "override"; +} diff --git a/src/domain/helpers/DataProcessingService.ts b/src/domain/helpers/DataProcessingService.ts index 8e0aca23..f3dea41d 100644 --- a/src/domain/helpers/DataProcessingService.ts +++ b/src/domain/helpers/DataProcessingService.ts @@ -1,4 +1,13 @@ -import { CellRef, DataProcessingRuleCoalesce, TemplateDataValue } from "../entities/Template"; +import { + CellRef, + ColumnRef, + DataProcessingRuleCoalesce, + DataProcessingRuleOverride, + isDataProcessingRuleCoalesce, + isDataProcessingRuleOverride, + RowRef, + TemplateDataValue, +} from "../entities/Template"; import { Id } from "../entities/ReferenceObject"; import _ from "lodash"; @@ -9,8 +18,23 @@ export type DataToProcess = { optionId?: TemplateDataValue["optionId"]; }; +type DataProcessingRule = DataProcessingRuleCoalesce | DataProcessingRuleOverride; + export class DataProcessingService { - static coalesceValues(props: { + static applyRules(props: { + dataDetails: DataToProcess[]; + dataProcessingRules?: DataProcessingRule[]; + }): DataToProcess[] { + const { dataDetails, dataProcessingRules } = props; + + const coalesceRules = dataProcessingRules?.filter(isDataProcessingRuleCoalesce); + const overrideRules = dataProcessingRules?.filter(isDataProcessingRuleOverride); + + const coalesced = this.coalesceValues({ dataDetails, dataProcessingRules: coalesceRules }); + return this.overrideValues({ dataDetails: coalesced, dataProcessingRules: overrideRules }); + } + + private static coalesceValues(props: { dataDetails: DataToProcess[]; dataProcessingRules?: DataProcessingRuleCoalesce[]; }): DataToProcess[] { @@ -33,18 +57,57 @@ export class DataProcessingService { if (!firstValidDetail) return undefined; return { ...firstValidDetail, - cell: this.replaceColumn(firstValidDetail.cell, rule.destination.ref), + cell: this.replaceRef(firstValidDetail.cell, rule.destination), }; }); return [...otherDataElements, ..._.compact(coalescedEntries)]; } - private static replaceColumn(cellRef: CellRef, newColumn: string): CellRef { + private static overrideValues(props: { + dataDetails: DataToProcess[]; + dataProcessingRules?: DataProcessingRuleOverride[]; + }): DataToProcess[] { + const { dataProcessingRules, dataDetails } = props; + + if (!dataProcessingRules || dataProcessingRules.length < 1) return dataDetails; + + return dataDetails.map(detail => { + const matchingRule = dataProcessingRules.find(rule => this.cellMatchesRef(detail.cell, rule.target)); + + if (!matchingRule) return detail; + + return { + ...detail, + cell: this.replaceRef(detail.cell, matchingRule.destination), + }; + }); + } + + private static cellMatchesRef(targetCell: CellRef, targetRef: ColumnRef | RowRef): boolean { + switch (targetRef.type) { + case "column": + return targetCell.ref.replace(/[0-9]+/, "") === targetRef.ref; + case "row": + return targetCell.ref.replace(/[A-Z]+/, "") === String(targetRef.ref); + } + } + + private static replaceRef(cellRef: CellRef, destinationRef: ColumnRef | RowRef): CellRef { + const colLetter = cellRef.ref.replace(/[0-9]+/, ""); const rowNumber = cellRef.ref.replace(/[A-Z]+/, ""); - return { - ...cellRef, - ref: `${newColumn}${rowNumber}`, - }; + + switch (destinationRef.type) { + case "column": + return { + ...cellRef, + ref: `${destinationRef.ref}${rowNumber}`, + }; + case "row": + return { + ...cellRef, + ref: `${colLetter}${destinationRef.ref}`, + }; + } } } diff --git a/src/domain/helpers/ExcelBuilder.ts b/src/domain/helpers/ExcelBuilder.ts index 38488ce1..d5708e79 100644 --- a/src/domain/helpers/ExcelBuilder.ts +++ b/src/domain/helpers/ExcelBuilder.ts @@ -12,7 +12,6 @@ import { DataSource, DataSourceValue, DownloadCustomizationOptions, - isDataProcessingRuleCoalesce, RowDataSource, setDataEntrySheet, setSheet, @@ -21,6 +20,7 @@ import { Template, TemplateDataPackage, TemplateDataPackageData, + TemplateDataValue, templateFromDataPackage, TemplateTrackerProgramPackage, TrackerEventRowDataSource, @@ -36,6 +36,9 @@ import { ModulesRepositories } from "../repositories/ModulesRepositories"; import { Maybe } from "../../types/utils"; import { DataElementDisaggregationsMappingRepository } from "../repositories/DataElementDisaggregationsMappingRepository"; import { DataProcessingService, DataToProcess } from "./DataProcessingService"; +import { readCellResolvingDefinedNames } from "./readCell"; +import { DataElement, DataForm } from "../entities/DataForm"; +import { Id } from "../entities/ReferenceObject"; const dateFormatPattern = "yyyy-MM-dd"; @@ -47,7 +50,12 @@ export class ExcelBuilder { private dataElementDisaggregationsMappingRepository: DataElementDisaggregationsMappingRepository ) {} - public async populateTemplate(template: Template, payload: DataPackage, settings: Settings): Promise { + public async populateTemplate( + template: Template, + payload: DataPackage, + settings: Settings, + dataForm?: DataForm + ): Promise { const { dataSources = [] } = template; const dataSourceValues = await this.getDataSourceValues(template, dataSources); const metadata = @@ -55,21 +63,33 @@ export class ExcelBuilder { ? await this.instanceRepository.getBuilderMetadata(payload.trackedEntityInstances) : emptyBuilderMetadata; const templatePayload = templateFromDataPackage(payload); + const dataElementById = _.keyBy(dataForm?.dataElements ?? [], de => de.id); + const multiTextLookup: MultiTextLookup = { + dataElementById, + optionByCodeByDe: _.mapValues(dataElementById, de => _.keyBy(de.options, o => o.code)), + }; for (const dataSource of dataSourceValues) { if (!dataSource.skipPopulate) { switch (dataSource.type) { case "cell": - await this.fillCells(template, dataSource, templatePayload); + await this.fillCells(template, dataSource, templatePayload, multiTextLookup); break; case "row": - await this.fillRows(template, dataSource, templatePayload); + await this.fillRows(template, dataSource, templatePayload, multiTextLookup); break; case "rowTei": await this.fillTeiRows(template, dataSource, templatePayload); break; case "rowTrackedEvent": - await this.fillTrackerEventRows(template, dataSource, templatePayload, metadata, settings); + await this.fillTrackerEventRows( + template, + dataSource, + templatePayload, + metadata, + settings, + multiTextLookup + ); break; case "rowTeiRelationship": await this.fillTrackerRelationshipRows(template, dataSource, payload); @@ -103,7 +123,12 @@ export class ExcelBuilder { }); } - private async fillCells(template: Template, dataSource: CellDataSource, payload: TemplateDataPackage) { + private async fillCells( + template: Template, + dataSource: CellDataSource, + payload: TemplateDataPackage, + multiTextLookup: MultiTextLookup + ) { const orgUnit = await this.readCellValue(template, dataSource.orgUnit); const dataElement = await this.readCellValue(template, dataSource.dataElement); const period = await this.readCellValue(template, dataSource.period); @@ -116,7 +141,13 @@ export class ExcelBuilder { .find(dv => dv.dataElement === dataElement && (!dv.category || dv.category === categoryOption)) ?? {}; if (value) { - await this.excelRepository.writeCell(template.id, dataSource.ref, value); + const writeValue = this.formatDataElementValue({ + value, + dataElementId: String(dataElement), + delimiter: dataSource.multiTextDataElementDelimiter, + multiTextLookup, + }); + await this.excelRepository.writeCell(template.id, dataSource.ref, writeValue); } } @@ -125,7 +156,13 @@ export class ExcelBuilder { ref?: CellRef | ValueRef, options: { isFormula: boolean } = { isFormula: false } ): Promise { - return removeCharacters(await this.excelRepository.readCell(template.id, ref, { formula: options.isFormula })); + if (options.isFormula || !ref || ref.type === "value") { + return removeCharacters( + await this.excelRepository.readCell(template.id, ref, { formula: options.isFormula }) + ); + } + + return removeCharacters(await readCellResolvingDefinedNames(this.excelRepository, template.id, ref)); } private async fillTeiRows(template: Template, dataSource: TeiRowDataSource, payload: TemplateDataPackage) { @@ -220,12 +257,11 @@ export class ExcelBuilder { }) ); - const coalesceDataProcessRules = - dataSource.attributeDataProcessingRules?.filter(isDataProcessingRuleCoalesce); - - const attributeDetails = DataProcessingService.coalesceValues({ + const attributeDetails = DataProcessingService.applyRules({ dataDetails: _.compact(allAttributeDetails), - dataProcessingRules: coalesceDataProcessRules, + dataProcessingRules: dataSource.attributeDataProcessingRules?.filter( + rule => rule.condition === "onExport" + ), }); await Promise.all( @@ -249,6 +285,21 @@ export class ExcelBuilder { return options.map(option => option.name).join(multiTextTeiDelimiter); } + private formatDataElementValue(options: { + value: TemplateDataValue["value"]; + dataElementId: Id; + delimiter: Maybe; + multiTextLookup: MultiTextLookup; + }): TemplateDataValue["value"] { + const { value, dataElementId, delimiter, multiTextLookup } = options; + const isMultiText = multiTextLookup.dataElementById[dataElementId]?.valueType === "MULTI_TEXT"; + if (!isMultiText || !delimiter) return value; + + const codes = String(value).split(MULTI_TEXT_OPTION_DELIMITER); + const optionByCode = multiTextLookup.optionByCodeByDe[dataElementId] ?? {}; + return codes.map(code => optionByCode[code]?.name ?? code).join(delimiter); + } + private async fillCell(template: Template, cellRef: CellRef, sheetRef: SheetRef, value: string | number | boolean) { const cell = await this.excelRepository.findRelativeCell(template.id, sheetRef, cellRef); @@ -296,7 +347,8 @@ export class ExcelBuilder { dataSource: TrackerEventRowDataSource, payload: TemplateDataPackage, metadata: BuilderMetadata, - settings: Settings + settings: Settings, + multiTextLookup: MultiTextLookup ) { if (payload.type !== "trackerPrograms") return; @@ -376,19 +428,26 @@ export class ExcelBuilder { }) ); - const coalesceDataProcessRules = - dataSource.dataElementProcessingRules?.filter(isDataProcessingRuleCoalesce); - - const dataElementDetails = DataProcessingService.coalesceValues({ + const dataElementDetails = DataProcessingService.applyRules({ dataDetails: dataElementsToProcess, - dataProcessingRules: coalesceDataProcessRules, + dataProcessingRules: dataSource.dataElementProcessingRules?.filter( + rule => rule.condition === "onExport" + ), }); //TODO extract "_ - this.excelRepository.writeCell(template.id, cell, optionId ? `_${optionId}` : value) - ) + dataElementDetails.map(({ cell, id, optionId, value }) => { + const writeValue = optionId + ? `_${optionId}` + : this.formatDataElementValue({ + value, + dataElementId: id, + delimiter: dataSource.multiTextDataElementDelimiter, + multiTextLookup, + }); + return this.excelRepository.writeCell(template.id, cell, writeValue); + }) ); rowStart += 1; @@ -482,7 +541,67 @@ export class ExcelBuilder { return dataEntriesTeisWithEvents; } - private async fillRows(template: Template, dataSource: RowDataSource, payload: TemplateDataPackage) { + private async fillRows( + template: Template, + dataSource: RowDataSource, + payload: TemplateDataPackage, + multiTextLookup: MultiTextLookup + ) { + const isFixedOrgUnitPeriod = + template.type === "custom" && Boolean(template.fixedOrgUnit) && Boolean(template.fixedPeriod); + + if (isFixedOrgUnitPeriod) { + return this.fillRowsByKeyLookup(template, dataSource, payload, multiTextLookup); + } + + return this.fillRowsByDataEntry(template, dataSource, payload, multiTextLookup); + } + + // For templates with fixedOrgUnit/fixedPeriod: one data entry with many data values, + // each row may map to a different DE/COC. Uses exact key lookup. + private async fillRowsByKeyLookup( + template: Template, + dataSource: RowDataSource, + payload: TemplateDataPackage, + multiTextLookup: MultiTextLookup + ) { + const { rowStart, rowEnd = rowStart } = dataSource.range; + const allDataValues = payload.dataEntries.flatMap(e => e.dataValues); + const dataValueByDECOC = _.keyBy(allDataValues, dv => `${dv.dataElement}:${dv.category ?? ""}`); + + for (let row = rowStart; row <= rowEnd; row++) { + const cells = await this.excelRepository.getCellsInRange(template.id, { + ...dataSource.range, + rowStart: row, + rowEnd: row, + }); + + const dataElementsToProcess: DataToProcess[] = []; + for (const cell of cells) { + const resolved = await this.resolveCellDataElement(template, dataSource, cell); + if (!resolved) continue; + + const { dataElement, category } = resolved; + const exactKey = `${dataElement}:${category ?? ""}`; + const defaultKey = `${dataElement}:`; + const value = dataValueByDECOC[exactKey]?.value ?? dataValueByDECOC[defaultKey]?.value; + + if (value !== undefined) { + dataElementsToProcess.push({ cell, id: dataElement, value }); + } + } + + await this.applyRulesAndWrite(template, dataSource, dataElementsToProcess, multiTextLookup); + } + } + + // For templates where each row corresponds to a different data entry (orgUnit/period/event). + private async fillRowsByDataEntry( + template: Template, + dataSource: RowDataSource, + payload: TemplateDataPackage, + multiTextLookup: MultiTextLookup + ) { let { rowStart } = dataSource.range; for (const { id, orgUnit, period, attribute, dataValues, coordinate, geometry } of payload.dataEntries) { const cells = await this.excelRepository.getCellsInRange(template.id, { @@ -533,29 +652,76 @@ export class ExcelBuilder { } } - for (const cell of cells) { - const dataElementCell = await this.findRelative(template, dataSource.dataElement, cell); + const dataElementsToProcess = await this.resolveDataElementValues(template, dataSource, cells, dataValues); + + await this.applyRulesAndWrite(template, dataSource, dataElementsToProcess, multiTextLookup); + + rowStart += 1; + } + } - const categoryCell = await this.findRelative(template, dataSource.categoryOption, cell); + private async resolveCellDataElement( + template: Template, + dataSource: RowDataSource, + cell: CellRef + ): Promise }>> { + const dataElementCell = await this.findRelative(template, dataSource.dataElement, cell); + if (!dataElementCell) return undefined; + + const dataElement = await readCellResolvingDefinedNames(this.excelRepository, template.id, dataElementCell); + if (!dataElement) return undefined; + + const categoryCell = await this.findRelative(template, dataSource.categoryOption, cell); + const category = categoryCell + ? await readCellResolvingDefinedNames(this.excelRepository, template.id, categoryCell) + : undefined; - const dataElement = dataElementCell - ? removeCharacters(await this.excelRepository.readCell(template.id, dataElementCell)) - : undefined; + return { dataElement: String(dataElement), category: category ? String(category) : undefined }; + } - const category = categoryCell - ? removeCharacters(await this.excelRepository.readCell(template.id, categoryCell)) - : undefined; + private async resolveDataElementValues( + template: Template, + dataSource: RowDataSource, + cells: CellRef[], + dataValues: TemplateDataPackageData["dataValues"] + ): Promise { + const results = await promiseMap(cells, async (cell): Promise> => { + const resolved = await this.resolveCellDataElement(template, dataSource, cell); + if (!resolved) return undefined; + + const { dataElement, category } = resolved; + const { value } = + dataValues.find(dv => dv.dataElement === dataElement && (!dv.category || dv.category === category)) ?? + {}; + + return value ? { cell, id: dataElement, value } : undefined; + }); - const { value } = - dataValues.find( - dv => dv.dataElement === dataElement && (!dv.category || dv.category === category) - ) ?? {}; + return _.compact(results); + } - if (value) await this.excelRepository.writeCell(template.id, cell, value); - } + private async applyRulesAndWrite( + template: Template, + dataSource: RowDataSource, + dataElementsToProcess: DataToProcess[], + multiTextLookup: MultiTextLookup + ): Promise { + const dataElementDetails = DataProcessingService.applyRules({ + dataDetails: dataElementsToProcess, + dataProcessingRules: dataSource.dataElementProcessingRules?.filter(rule => rule.condition === "onExport"), + }); - rowStart += 1; - } + await Promise.all( + dataElementDetails.map(({ cell, id, value }) => { + const writeValue = this.formatDataElementValue({ + value, + dataElementId: id, + delimiter: dataSource.multiTextDataElementDelimiter, + multiTextLookup, + }); + return this.excelRepository.writeCell(template.id, cell, writeValue); + }) + ); } private async findRelative(template: Template, ref?: SheetRef | ValueRef, relative?: CellRef) { @@ -602,4 +768,9 @@ export class ExcelBuilder { } } +type MultiTextLookup = { + dataElementById: Record; + optionByCodeByDe: Record>; +}; + export const MULTI_TEXT_OPTION_DELIMITER = ","; diff --git a/src/domain/helpers/ExcelReader.ts b/src/domain/helpers/ExcelReader.ts index c6a28ef3..f0ace651 100644 --- a/src/domain/helpers/ExcelReader.ts +++ b/src/domain/helpers/ExcelReader.ts @@ -5,6 +5,7 @@ import moment from "moment"; import { isDefined } from "../../utils"; import { promiseMap } from "../../utils/promises"; import { removeCharacters } from "../../utils/string"; +import { readCellResolvingDefinedNames } from "./readCell"; import { DataForm, dataFormTypeMap, DataFormFeatureType } from "../entities/DataForm"; import { buildGeometry, getGeometryFromString } from "../entities/Geometry"; import { Relationship } from "../entities/Relationship"; @@ -515,17 +516,7 @@ export class ExcelReader { const cell = await this.excelRepository.findRelativeCell(template.id, ref, relative); if (cell) { - const value = await this.excelRepository.readCell(template.id, cell); - const formula = await this.excelRepository.readCell(template.id, cell, { - formula: true, - }); - - const definedNames = await this.excelRepository.listDefinedNames(template.id); - if (typeof formula === "string" && definedNames.includes(formula.replace(/^=/, ""))) { - return removeCharacters(formula); - } - - return value; + return readCellResolvingDefinedNames(this.excelRepository, template.id, cell); } } diff --git a/src/domain/helpers/readCell.ts b/src/domain/helpers/readCell.ts new file mode 100644 index 00000000..08aa21a4 --- /dev/null +++ b/src/domain/helpers/readCell.ts @@ -0,0 +1,30 @@ +import { removeCharacters } from "../../utils/string"; +import { CellRef } from "../entities/Template"; +import { ExcelRepository, ExcelValue } from "../repositories/ExcelRepository"; + +/** + * Read a cell's value, resolving defined-name formulas to their IDs. + * + * Some templates store data-element / category-option IDs as Excel defined-name + * references (e.g. `=_aP6qePO2WG6`). When the workbook has been saved in Excel + * the cached text value of the formula (a human-readable label) takes precedence + * over the formula itself in a plain `readCell` call. + * + * This helper detects that case: if the cell's formula matches a defined name, + * it returns the cleaned formula (the ID) instead of the cached display value. + */ +export async function readCellResolvingDefinedNames( + excelRepository: ExcelRepository, + templateId: string, + cell: CellRef +): Promise { + const value = await excelRepository.readCell(templateId, cell); + const formula = await excelRepository.readCell(templateId, cell, { formula: true }); + + const definedNames = await excelRepository.listDefinedNames(templateId); + if (typeof formula === "string" && definedNames.includes(formula.replace(/^=/, ""))) { + return removeCharacters(formula); + } + + return value; +} diff --git a/src/domain/usecases/DownloadTemplateUseCase.ts b/src/domain/usecases/DownloadTemplateUseCase.ts index b4f06e86..baf48eea 100644 --- a/src/domain/usecases/DownloadTemplateUseCase.ts +++ b/src/domain/usecases/DownloadTemplateUseCase.ts @@ -9,9 +9,15 @@ import { getExtensionFile, XLSX_EXTENSION } from "../../utils/files"; import { promiseMap } from "../../utils/promises"; import Settings from "../../webapp/logic/settings"; import { getGeneratedTemplateId, SheetBuilder } from "../../webapp/logic/sheetBuilder"; -import { DataFormType, dataFormTypeMap } from "../entities/DataForm"; +import { DataForm, DataFormType, dataFormTypeMap } from "../entities/DataForm"; import { Id, Ref } from "../entities/ReferenceObject"; -import { templateFromDataPackage, TemplateType } from "../entities/Template"; +import { + getDataFormRef, + hasMultiTextDataElementDelimiter, + Template, + templateFromDataPackage, + TemplateType, +} from "../entities/Template"; import { ExcelBuilder } from "../helpers/ExcelBuilder"; import { ExcelRepository } from "../repositories/ExcelRepository"; import { InstanceRepository } from "../repositories/InstanceRepository"; @@ -196,26 +202,27 @@ export class DownloadTemplateUseCase implements UseCase { if (theme) await builder.applyTheme(template, theme); - if (enablePopulate) { - if (template.type === "custom" && template.fixedOrgUnit) { - await this.excelRepository.writeCell( - template.id, - template.fixedOrgUnit, - dataPackage?.dataEntries[0]?.orgUnit ?? this.getFirstValueOrEmpty(orgUnits) - ); - } + if (template.type === "custom" && template.fixedOrgUnit) { + await this.excelRepository.writeCell( + template.id, + template.fixedOrgUnit, + dataPackage?.dataEntries[0]?.orgUnit ?? this.getFirstValueOrEmpty(orgUnits) + ); + } - if (template.type === "custom" && template.fixedPeriod) { - const periods = buildAllPossiblePeriods(element.periodType, populateStartDate, populateEndDate); - await this.excelRepository.writeCell( - template.id, - template.fixedPeriod, - dataPackage?.dataEntries[0]?.period ?? this.getFirstValueOrEmpty(periods) - ); - } + if (template.type === "custom" && template.fixedPeriod) { + const periods = buildAllPossiblePeriods(element.periodType, populateStartDate, populateEndDate); + await this.excelRepository.writeCell( + template.id, + template.fixedPeriod, + dataPackage?.dataEntries[0]?.period ?? this.getFirstValueOrEmpty(periods) + ); + } + if (enablePopulate) { if (dataPackage) { - await builder.populateTemplate(template, dataPackage, settings); + const dataForm = await this.getDataForm(template); + await builder.populateTemplate(template, dataPackage, settings, dataForm); } } @@ -231,6 +238,20 @@ export class DownloadTemplateUseCase implements UseCase { } } + private async getDataForm(template: Template): Promise { + if (template.type !== "custom") return undefined; + + const hasMultiText = template.dataSources?.some( + ds => hasMultiTextDataElementDelimiter(ds) && ds.multiTextDataElementDelimiter + ); + if (!hasMultiText) return undefined; + + const dataFormRef = getDataFormRef(template); + if (!dataFormRef.id) return undefined; + + return (await this.instanceRepository.getDataForms({ ids: [dataFormRef.id] }))[0]; + } + private getFirstValueOrEmpty(model: string[]): string { return _(model).first() || ""; } diff --git a/src/domain/usecases/ImportTemplateUseCase.ts b/src/domain/usecases/ImportTemplateUseCase.ts index 76fbc904..6e433870 100644 --- a/src/domain/usecases/ImportTemplateUseCase.ts +++ b/src/domain/usecases/ImportTemplateUseCase.ts @@ -11,6 +11,7 @@ import { Either } from "../entities/Either"; import { OrgUnit } from "../entities/OrgUnit"; import { ErrorMessage, SynchronizationResult } from "../entities/SynchronizationResult"; import { + hasMultiTextDataElementDelimiter, Template, TemplateDataPackage, TemplateDataPackageData, @@ -20,6 +21,7 @@ import { TemplateTrackerProgramPackage, } from "../entities/Template"; import { ExcelReader } from "../helpers/ExcelReader"; +import { MULTI_TEXT_OPTION_DELIMITER } from "../helpers/ExcelBuilder"; import { ExcelRepository } from "../repositories/ExcelRepository"; import { InstanceRepository } from "../repositories/InstanceRepository"; import { TemplateRepository } from "../repositories/TemplateRepository"; @@ -315,6 +317,16 @@ export class ImportTemplateUseCase implements UseCase { .first(); } + private getMultiTextDataElementDelimiter(template: Template): Maybe { + if (template.type !== "custom") return undefined; + + return _(template.dataSources) + .filter(hasMultiTextDataElementDelimiter) + .map(ds => ds.multiTextDataElementDelimiter) + .compact() + .first(); + } + private shouldDeleteAggregatedData(strategy: DuplicateImportStrategy): boolean { return strategy === "IMPORT"; } @@ -377,13 +389,17 @@ export class ImportTemplateUseCase implements UseCase { const trackedEntityPackage = dataPackage.type === "trackerPrograms" ? this.formatTrackedEntityInstances(dataPackage, dataForm) : {}; + const multiTextDataElementDelimiter = this.getMultiTextDataElementDelimiter(template); + return { ...dataPackage, ...trackedEntityPackage, dataEntries: dataPackage.dataEntries.map(({ dataValues, ...dataEntry }) => { return { ...dataEntry, - dataValues: _.compact(dataValues.map(value => formatDataValue(value, dataForm))), + dataValues: _.compact( + dataValues.map(value => formatDataValue(value, dataForm, multiTextDataElementDelimiter)) + ), }; }), }; @@ -725,7 +741,11 @@ function getOptionValue(originalValue: string, options?: DataOption[]): Maybe originalValue === id || originalValue === name); } -function formatDataValue(item: TemplateDataPackageDataValue, dataForm: DataForm): Maybe { +function formatDataValue( + item: TemplateDataPackageDataValue, + dataForm: DataForm, + multiTextDataElementDelimiter: Maybe +): Maybe { const dataElement = dataForm.dataElements.find(({ id }) => item.dataElement === id); const booleanValue = getBooleanValue(item); @@ -737,6 +757,17 @@ function formatDataValue(item: TemplateDataPackageDataValue, dataForm: DataForm) return booleanValue ? { ...item, value: true } : undefined; } + if (dataElement?.valueType === "MULTI_TEXT" && multiTextDataElementDelimiter && isString(item.value)) { + const optionNames = item.value.split(multiTextDataElementDelimiter).map(name => name.trim()); + const value = optionNames + .map(name => { + const option = getOptionValue(name, dataElement.options); + return option?.code ?? name; + }) + .join(MULTI_TEXT_OPTION_DELIMITER); + return { ...item, value }; + } + const optionValue = isString(item.value) ? getOptionValue(item.value, dataElement?.options) : undefined; const value = optionValue?.code ?? item.value; diff --git a/src/webapp/components/history/HistoryImportSummary.tsx b/src/webapp/components/history/HistoryImportSummary.tsx index c960826e..046cae75 100644 --- a/src/webapp/components/history/HistoryImportSummary.tsx +++ b/src/webapp/components/history/HistoryImportSummary.tsx @@ -88,7 +88,7 @@ function getImportStrategyLabel(entry: HistoryEntryDetails, dataFormType: Maybe< } switch (strategy) { case "IMPORT": - return dataFormType === "dataSets" ? i18n.t("Delete and import") : i18n.t("Import despite duplicates"); + return dataFormType === "dataSets" ? i18n.t("Delete and Import") : i18n.t("Import despite duplicates"); case "IGNORE": return dataFormType === "dataSets" ? i18n.t("Import only new data values") diff --git a/yarn.lock b/yarn.lock index 9d92550c..11d8faa8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4642,10 +4642,10 @@ qs "6.9.7" react "^16.12.0" -"@eyeseetea/d2-ui-components@2.11.0-beta.1": - version "2.11.0-beta.1" - resolved "https://registry.yarnpkg.com/@eyeseetea/d2-ui-components/-/d2-ui-components-2.11.0-beta.1.tgz#b4b4c76285a03f4f99d2645d8c7476122da8f289" - integrity sha512-OLBphENbEUQjVe94rqKqaw9vClPLXeYSIAD0Zpx7etnY2Out2lh5DOl97muff13mKJmvN6u35BApp7DLnX0X0g== +"@eyeseetea/d2-ui-components@2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@eyeseetea/d2-ui-components/-/d2-ui-components-2.12.0.tgz#dabef61e90f42796deefe891393646fc917abb2e" + integrity sha512-lH+kuDsxU/N4MlDrBJcTBECyQF0YQkWrShu+zICzlSm4AAqq6FEqq20xrloJr1fHoB35/OmYVyry35kMwWfwdA== dependencies: "@date-io/core" "1.3.6" "@date-io/moment" "1.0.2" @@ -4659,7 +4659,6 @@ moment "2.29.4" nano-memoize "1.2.1" react-linkify "1.0.0-alpha" - rxjs-compat "6.6.3" throttle-debounce "2.1.0" "@eyeseetea/feedback-component@0.1.3-beta.3": @@ -17608,11 +17607,6 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs-compat@6.6.3: - version "6.6.3" - resolved "https://registry.npmjs.org/rxjs-compat/-/rxjs-compat-6.6.3.tgz#141405fcee11f48718d428b99c8f01826f594e5c" - integrity sha512-y+wUqq7bS2dG+7rH2fNMoxsDiJ32RQzFxZQE/JdtpnmEZmwLQrb1tCiItyHxdXJHXjmHnnzFscn3b6PEmORGKw== - rxjs-compat@6.6.7: version "6.6.7" resolved "https://registry.yarnpkg.com/rxjs-compat/-/rxjs-compat-6.6.7.tgz#6eb4ef75c0a58ea672854a701ccc8d49f41e69cb"