From 5cf8e48384692d3133710ff9d6367578bba84171 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Fri, 14 Aug 2026 21:59:38 -0500 Subject: [PATCH 1/4] add org. units imported to history summary --- .eslintrc.js | 7 +- i18n/en.pot | 10 +- i18n/es.po | 8 +- i18n/fr.po | 8 +- i18n/pt.po | 8 +- i18n/ru.po | 9 +- src/CompositionRoot.ts | 5 + src/data/OrgUnitD2Repository.ts | 37 ++++++++ src/domain/entities/HistoryEntry.ts | 28 ++++++ src/domain/entities/OrgUnit.ts | 11 +++ src/domain/repositories/OrgUnitRepository.ts | 6 ++ .../usecases/GetOrgUnitsByIdsUseCase.ts | 12 +++ src/domain/usecases/ImportTemplateUseCase.ts | 53 +++++++++-- src/domain/usecases/utils/orgUnits.ts | 3 +- .../components/HistoryImportSummary.spec.tsx | 92 +++++++++++++++++++ src/test/data/OrgUnitD2Repository.spec.ts | 84 +++++++++++++++++ src/test/domain/HistoryEntryOrgUnits.spec.ts | 87 ++++++++++++++++++ .../history/HistoryDetailsDialog.tsx | 4 +- .../history/HistoryImportSummary.tsx | 53 ++++++++++- src/webapp/hooks/useHistoryDetails.ts | 25 ++++- 20 files changed, 524 insertions(+), 26 deletions(-) create mode 100644 src/data/OrgUnitD2Repository.ts create mode 100644 src/domain/repositories/OrgUnitRepository.ts create mode 100644 src/domain/usecases/GetOrgUnitsByIdsUseCase.ts create mode 100644 src/test/components/HistoryImportSummary.spec.tsx create mode 100644 src/test/data/OrgUnitD2Repository.spec.ts create mode 100644 src/test/domain/HistoryEntryOrgUnits.spec.ts diff --git a/.eslintrc.js b/.eslintrc.js index b6761a43..750beed8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,12 +1,7 @@ /** @format */ module.exports = { - extends: [ - "react-app", - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended", - ], + extends: ["react-app", "eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended"], parser: "@typescript-eslint/parser", rules: { "no-console": ["warn", { allow: ["debug", "warn", "error"] }], diff --git a/i18n/en.pot b/i18n/en.pot index 9df10efe..ffa4ddd6 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: 2026-07-29T11:52:52.301Z\n" -"PO-Revision-Date: 2026-07-29T11:52:52.301Z\n" +"POT-Creation-Date: 2026-08-16T18:08:38.823Z\n" +"PO-Revision-Date: 2026-08-16T18:08:38.823Z\n" msgid "Events - Create/update" msgstr "" @@ -219,6 +219,9 @@ msgstr "" msgid "Import strategy" msgstr "" +msgid "Org. Unit" +msgstr "" + msgid "File" msgstr "" @@ -228,6 +231,9 @@ msgstr "" msgid "The original file is not available for download" msgstr "" +msgid "and {{total}} more" +msgstr "" + msgid "Default" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 9abc99f6..f2c71e06 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2026-07-29T11:52:52.301Z\n" +"POT-Creation-Date: 2026-08-16T18:08:38.823Z\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -242,6 +242,9 @@ msgstr "Estado" msgid "Import strategy" msgstr "Importar datos" +msgid "Org. Unit" +msgstr "Unidad organizativa" + msgid "File" msgstr "" @@ -252,6 +255,9 @@ msgstr "" msgid "The original file is not available for download" msgstr "" +msgid "and {{total}} more" +msgstr "y {{count}} más" + msgid "Default" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index 4d03becf..5d55b1c4 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: 2026-07-29T11:52:52.301Z\n" +"POT-Creation-Date: 2026-08-16T18:08:38.823Z\n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -236,6 +236,9 @@ msgstr "" msgid "Import strategy" msgstr "Importer des données" +msgid "Org. Unit" +msgstr "" + msgid "File" msgstr "" @@ -246,6 +249,9 @@ msgstr "" msgid "The original file is not available for download" msgstr "" +msgid "and {{total}} more" +msgstr "" + msgid "Default" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index 757f562d..d1490cc5 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2026-07-29T11:52:52.301Z\n" +"POT-Creation-Date: 2026-08-16T18:08:38.823Z\n" "Language: pt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -244,6 +244,9 @@ msgstr "Estado" msgid "Import strategy" msgstr "Importar dados" +msgid "Org. Unit" +msgstr "" + msgid "File" msgstr "" @@ -254,6 +257,9 @@ msgstr "" msgid "The original file is not available for download" msgstr "" +msgid "and {{total}} more" +msgstr "" + msgid "Default" msgstr "" diff --git a/i18n/ru.po b/i18n/ru.po index a9838372..3957f0df 100644 --- a/i18n/ru.po +++ b/i18n/ru.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2026-07-29T11:52:52.301Z\n" +"POT-Creation-Date: 2026-08-16T18:08:38.823Z\n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -248,6 +248,10 @@ msgstr "Статус" msgid "Import strategy" msgstr "Импортные данные" +#, fuzzy +msgid "Org. Unit" +msgstr "Оргподразделение *" + msgid "File" msgstr "" @@ -258,6 +262,9 @@ msgstr "" msgid "The original file is not available for download" msgstr "" +msgid "and {{total}} more" +msgstr "" + msgid "Default" msgstr "" diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index b69ea572..43b2aafa 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -58,6 +58,9 @@ import { ModulesRepositories } from "./domain/repositories/ModulesRepositories"; import { ImportSourceNodeRepository } from "./data/ImportSourceNodeRepository"; import { DataElementDisaggregationsMappingD2Repository } from "./data/DataElementDisaggregationsMappingD2Repository"; import { GetDataElementDisaggregationsMappingUseCase } from "./domain/usecases/GetDataElementDisaggregationsMappingUseCase"; +import { OrgUnitD2Repository } from "./data/OrgUnitD2Repository"; +import { OrgUnitRepository } from "./domain/repositories/OrgUnitRepository"; +import { GetOrgUnitsByIdsUseCase } from "./domain/usecases/GetOrgUnitsByIdsUseCase"; export interface CompositionRootOptions { appConfig: JsonConfig; @@ -87,6 +90,7 @@ export function getCompositionRoot({ appConfig, dhisInstance, mockApi, importSou const importSourceRepository = importSource === "zip" ? new ImportSourceZipRepository() : new ImportSourceNodeRepository(); const historyRepository: HistoryRepository = new HistoryDataStoreRepository(dhisInstance, mockApi); + const orgUnitRepository: OrgUnitRepository = new OrgUnitD2Repository(dhisInstance, mockApi); const dataElementDisaggregationsMappingRepository = new DataElementDisaggregationsMappingD2Repository(api); @@ -94,6 +98,7 @@ export function getCompositionRoot({ appConfig, dhisInstance, mockApi, importSou orgUnits: getExecute({ getUserRoots: new GetOrgUnitRootsUseCase(instance), getRootsByForm: new GetFormOrgUnitRootsUseCase(instance), + getByIds: new GetOrgUnitsByIdsUseCase(orgUnitRepository), }), form: getExecute({ getDataPackage: new GetFormDataPackageUseCase(instance), diff --git a/src/data/OrgUnitD2Repository.ts b/src/data/OrgUnitD2Repository.ts new file mode 100644 index 00000000..b9ef847e --- /dev/null +++ b/src/data/OrgUnitD2Repository.ts @@ -0,0 +1,37 @@ +import _ from "lodash"; +import { DhisInstance } from "../domain/entities/DhisInstance"; +import { OrgUnit } from "../domain/entities/OrgUnit"; +import { Id } from "../domain/entities/ReferenceObject"; +import { OrgUnitRepository } from "../domain/repositories/OrgUnitRepository"; +import { D2Api } from "../types/d2-api"; +import { promiseMap } from "../utils/promises"; + +// Avoid 414-uri-too-large by splitting ids in chunks +const CHUNK_SIZE = 250; + +export class OrgUnitD2Repository implements OrgUnitRepository { + private api: D2Api; + + constructor({ url }: DhisInstance, mockApi?: D2Api) { + this.api = mockApi ?? new D2Api({ baseUrl: url }); + } + + /* Org units that are deleted or not accessible to the current user are simply not returned */ + public async getByIds(ids: Id[]): Promise { + if (_.isEmpty(ids)) return []; + + const orgUnitsByChunk = await promiseMap(_.chunk(ids, CHUNK_SIZE), async chunkIds => { + const { objects } = await this.api.models.organisationUnits + .get({ + paging: false, + fields: { id: true, displayName: true, level: true, path: true }, + filter: { id: { in: chunkIds } }, + }) + .getData(); + + return objects.map(({ displayName, ...orgUnit }) => ({ ...orgUnit, name: displayName })); + }); + + return _.flatten(orgUnitsByChunk); + } +} diff --git a/src/domain/entities/HistoryEntry.ts b/src/domain/entities/HistoryEntry.ts index 1e7d6f4b..8ce325b8 100644 --- a/src/domain/entities/HistoryEntry.ts +++ b/src/domain/entities/HistoryEntry.ts @@ -11,6 +11,7 @@ import { Document, UnsavedDocument } from "./Document"; import { defaultSharing, Sharing } from "./Sharing"; import Settings from "../../webapp/logic/settings"; import { ImportTemplateConfiguration } from "./ImportTemplateConfiguration"; +import { cleanOrgUnitPaths } from "../../utils/dhis"; export class HistoryEntry { public readonly id: Id; @@ -21,6 +22,7 @@ export class HistoryEntry { public readonly syncResults: Maybe; public readonly errorDetails: Maybe; public readonly importConfiguration: ImportTemplateConfiguration; + public readonly orgUnitsImported: ReadonlyArray; constructor({ id = generateUid(), @@ -31,6 +33,7 @@ export class HistoryEntry { syncResults, errorDetails, importConfiguration, + orgUnitsImported, }: Partial & { dataForm: Maybe; user: User; @@ -38,6 +41,7 @@ export class HistoryEntry { syncResults: Maybe; errorDetails: Maybe; importConfiguration: ImportTemplateConfiguration; + orgUnitsImported: ReadonlyArray; }) { this.id = id; this.dataForm = dataForm; @@ -47,6 +51,7 @@ export class HistoryEntry { this.syncResults = syncResults; this.errorDetails = errorDetails; this.importConfiguration = importConfiguration; + this.orgUnitsImported = orgUnitsImported; if (!this.syncResults && !this.errorDetails) { throw new Error("Either syncResults or errorDetails must be provided"); } @@ -59,6 +64,7 @@ export class HistoryEntry { syncResults: Maybe; errorDetails: Maybe; importConfiguration: ImportTemplateConfiguration; + orgUnitsImported: ReadonlyArray; }): HistoryEntry { return new HistoryEntry(data); } @@ -69,6 +75,7 @@ export class HistoryEntry { dataForm: Maybe; result: Either; importConfiguration: ImportTemplateConfiguration; + orgUnitsImported: ReadonlyArray; }): HistoryEntry { if (data.result.isError()) { return new HistoryEntry({ @@ -78,6 +85,7 @@ export class HistoryEntry { syncResults: undefined, errorDetails: data.result.value.error, importConfiguration: data.importConfiguration, + orgUnitsImported: data.orgUnitsImported, }); } else { return new HistoryEntry({ @@ -87,6 +95,7 @@ export class HistoryEntry { syncResults: data.result.value.data, errorDetails: undefined, importConfiguration: data.importConfiguration, + orgUnitsImported: data.orgUnitsImported, }); } } @@ -132,6 +141,7 @@ export class HistoryEntry { "duplicateStrategy", "organisationUnitStrategy", ]), + orgUnitsImported: [...this.orgUnitsImported], }; } @@ -166,6 +176,9 @@ export interface HistoryEntryDetails { results: Maybe; errorDetails: Maybe; configuration: Maybe; + /* Every org unit the data ended up in, whether it came from the file or from the override. + Optional: entries saved before this field existed do not have it. */ + orgUnitsImported?: Id[]; } interface UnhandledException { @@ -177,6 +190,21 @@ type ErrorDetails = ImportTemplateError | UnhandledException; export type HistoryEntryDocument = Document | UnsavedDocument; +export function getHistoryEntryOrgUnitIds(details: HistoryEntryDetails): Id[] { + return details.orgUnitsImported ?? getOverriddenOrgUnitIds(details); +} + +/* Fallback for entries saved before `orgUnitsImported` existed: back then the only org unit + recorded was the one selected to override the file, and only as a path. Entries that took + the org units from the file have nothing to show. */ +function getOverriddenOrgUnitIds(details: HistoryEntryDetails): Id[] { + const { configuration } = details; + if (!configuration?.useBuilderOrgUnits) { + return []; + } + return cleanOrgUnitPaths(configuration.selectedOrgUnits ?? []); +} + export function buildHistorySharing( templatePermissions: Settings["templatePermissions"], dataForm: Maybe diff --git a/src/domain/entities/OrgUnit.ts b/src/domain/entities/OrgUnit.ts index 17ac2128..34349645 100644 --- a/src/domain/entities/OrgUnit.ts +++ b/src/domain/entities/OrgUnit.ts @@ -1,3 +1,4 @@ +import { Maybe } from "../../types/utils"; import { Id } from "./ReferenceObject"; export interface OrgUnit { @@ -6,3 +7,13 @@ export interface OrgUnit { name: string; level: number; } + +/* An org unit referenced by id, whose name may not be resolvable (deleted or not accessible) */ +export interface OrgUnitReference { + id: Id; + name: Maybe; +} + +export function toOrgUnitReferences(ids: Id[], orgUnits: OrgUnit[]): OrgUnitReference[] { + return ids.map(id => ({ id, name: orgUnits.find(orgUnit => orgUnit.id === id)?.name })); +} diff --git a/src/domain/repositories/OrgUnitRepository.ts b/src/domain/repositories/OrgUnitRepository.ts new file mode 100644 index 00000000..2d9d6a18 --- /dev/null +++ b/src/domain/repositories/OrgUnitRepository.ts @@ -0,0 +1,6 @@ +import { OrgUnit } from "../entities/OrgUnit"; +import { Id } from "../entities/ReferenceObject"; + +export interface OrgUnitRepository { + getByIds(ids: Id[]): Promise; +} diff --git a/src/domain/usecases/GetOrgUnitsByIdsUseCase.ts b/src/domain/usecases/GetOrgUnitsByIdsUseCase.ts new file mode 100644 index 00000000..0378a3f2 --- /dev/null +++ b/src/domain/usecases/GetOrgUnitsByIdsUseCase.ts @@ -0,0 +1,12 @@ +import { UseCase } from "../../CompositionRoot"; +import { OrgUnit } from "../entities/OrgUnit"; +import { Id } from "../entities/ReferenceObject"; +import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; + +export class GetOrgUnitsByIdsUseCase implements UseCase { + constructor(private orgUnitRepository: OrgUnitRepository) {} + + public async execute(ids: Id[]): Promise { + return this.orgUnitRepository.getByIds(ids); + } +} diff --git a/src/domain/usecases/ImportTemplateUseCase.ts b/src/domain/usecases/ImportTemplateUseCase.ts index 08e2cc06..b0cd3a3e 100644 --- a/src/domain/usecases/ImportTemplateUseCase.ts +++ b/src/domain/usecases/ImportTemplateUseCase.ts @@ -36,6 +36,7 @@ import { buildHistorySharing, HistoryEntry, HistoryEntryDocument } from "../enti import { DocumentRepository } from "../repositories/DocumentRepository"; import { DataElementDisaggregationsMappingRepository } from "../repositories/DataElementDisaggregationsMappingRepository"; import { DuplicateImportStrategy, ImportTemplateConfiguration } from "../entities/ImportTemplateConfiguration"; +import { Id } from "../entities/ReferenceObject"; export type ImportTemplateError = | { @@ -54,6 +55,26 @@ export type ImportTemplateUseCaseParams = { settings: Settings; } & ImportTemplateConfiguration; +/* The import outcome plus the org units the data ended up in, which the history entry records + and the result returned to the caller does not carry. */ +type ImportRunResult = { + result: Either; + orgUnitsImported: Id[]; +}; + +function notImportedResult(error: ImportTemplateError): ImportRunResult { + return { result: Either.error(error), orgUnitsImported: [] }; +} + +function getOrgUnitIds(dataPackage: TemplateDataPackage): Id[] { + const teiOrgUnitIds = + dataPackage.type === dataFormTypeMap.trackerPrograms + ? dataPackage.trackedEntityInstances.map(({ orgUnit }) => orgUnit.id) + : []; + + return _.uniq([...dataPackage.dataEntries.map(({ orgUnit }) => orgUnit), ...teiOrgUnitIds]); +} + type CustomErrorMatch = { regex: RegExp; getErrorMessage: (value: string) => string; @@ -108,16 +129,23 @@ export class ImportTemplateUseCase implements UseCase { params, dataForm: undefined, result: errorResult, + orgUnitsImported: [], }); return errorResult; }, success: async retrievedDataForm => { dataForm = retrievedDataForm; - const importResult = await this.run(params, retrievedDataForm, spreadSheet, images); + const { result: importResult, orgUnitsImported } = await this.run( + params, + retrievedDataForm, + spreadSheet, + images + ); await this.saveHistoryIfNeeded({ params, dataForm: retrievedDataForm, result: importResult, + orgUnitsImported, }); return importResult; }, @@ -136,6 +164,7 @@ export class ImportTemplateUseCase implements UseCase { dataForm: errorDataForm, syncResults: undefined, importConfiguration: params, + orgUnitsImported: [], }); await this.historyRepository.save(historyEntry); throw error; @@ -146,10 +175,12 @@ export class ImportTemplateUseCase implements UseCase { params, dataForm, result, + orgUnitsImported, }: { params: ImportTemplateUseCaseParams; dataForm: DataForm | undefined; result: Either; + orgUnitsImported: ReadonlyArray; }): Promise { if (!HistoryEntry.shouldSaveImportResult(result)) { return; @@ -162,6 +193,7 @@ export class ImportTemplateUseCase implements UseCase { dataForm, result, importConfiguration: params, + orgUnitsImported, }); await this.historyRepository.save(historyEntry); } @@ -205,7 +237,7 @@ export class ImportTemplateUseCase implements UseCase { dataForm: DataForm, spreadSheet: Blob, images: FileResource[] - ): Promise> { + ): Promise { const templateId = await this.excelRepository.loadTemplate({ type: "file", file: spreadSheet }); const template = await this.templateRepository.getTemplate(templateId); @@ -213,7 +245,7 @@ export class ImportTemplateUseCase implements UseCase { const dataPackage = await this.readTemplate(template, dataForm); if (!dataPackage) { - return Either.error({ type: "MALFORMED_TEMPLATE" }); + return notImportedResult({ type: "MALFORMED_TEMPLATE" }); } const orgUnits = await this.instanceRepository.getDataFormOrgUnits(dataForm.type, dataFormId); @@ -235,11 +267,11 @@ export class ImportTemplateUseCase implements UseCase { ); if (organisationUnitStrategy === "ERROR" && invalidDataValues.dataEntries.length > 0) { - return Either.error({ type: "INVALID_ORG_UNITS", dataValues, invalidDataValues }); + return notImportedResult({ type: "INVALID_ORG_UNITS", dataValues, invalidDataValues }); } if (duplicateStrategy === "ERROR" && existingDataValues.dataEntries.length > 0) { - return Either.error({ + return notImportedResult({ type: "DUPLICATE_VALUES", dataValues, existingDataValues, @@ -247,6 +279,10 @@ export class ImportTemplateUseCase implements UseCase { }); } + /* Org units of the data actually sent to DHIS2: after the override has been applied and + after the invalid and duplicated rows have been removed. */ + const orgUnitsImported = getOrgUnitIds(dataValues); + const rowLookup = ImportRowLookup.fromTemplateDataPackage(dataValues); const shouldDeleteExistingData = @@ -273,9 +309,12 @@ export class ImportTemplateUseCase implements UseCase { } : undefined; - return Either.success(_.compact([deleteResultWithErrorsDetails, ...importResultWithErrorsDetails])); + return { + result: Either.success(_.compact([deleteResultWithErrorsDetails, ...importResultWithErrorsDetails])), + orgUnitsImported, + }; } else { - return Either.success(_.compact([deleteResult, ...importResult])); + return { result: Either.success(_.compact([deleteResult, ...importResult])), orgUnitsImported }; } } diff --git a/src/domain/usecases/utils/orgUnits.ts b/src/domain/usecases/utils/orgUnits.ts index aba4be59..3b3dc45e 100644 --- a/src/domain/usecases/utils/orgUnits.ts +++ b/src/domain/usecases/utils/orgUnits.ts @@ -11,8 +11,7 @@ export function getCaptureOrgUnitIdsForDataForm( .filter(dataFormOrgUnit => userOrgUnits.some( userOrgUnit => - dataFormOrgUnit.path === userOrgUnit.path || - dataFormOrgUnit.path.startsWith(`${userOrgUnit.path}/`) + dataFormOrgUnit.path === userOrgUnit.path || dataFormOrgUnit.path.startsWith(`${userOrgUnit.path}/`) ) ) .map(orgUnit => orgUnit.id); diff --git a/src/test/components/HistoryImportSummary.spec.tsx b/src/test/components/HistoryImportSummary.spec.tsx new file mode 100644 index 00000000..ea254b37 --- /dev/null +++ b/src/test/components/HistoryImportSummary.spec.tsx @@ -0,0 +1,92 @@ +import { SnackbarProvider } from "@eyeseetea/d2-ui-components"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { HistoryEntrySummary } from "../../domain/entities/HistoryEntry"; +import { OrgUnitReference } from "../../domain/entities/OrgUnit"; +import { AppContext, AppContextI } from "../../webapp/contexts/app-context"; +import { HistoryImportSummary } from "../../webapp/components/history/HistoryImportSummary"; + +const MAX_ORG_UNITS_SHOWN = 10; +const ORG_UNIT_LABEL = "Org. Unit:"; + +const summary: HistoryEntrySummary = { + id: "aB3cD4eF5gH", + name: "OCBA Monthly Data", + timestamp: "2026-08-12T09:14:03.221Z", + status: "SUCCESS", + username: "ocba.user", + fileName: "monthly-data-jul.xlsx", + documentId: "Zx9YwV8uT7s", +}; + +// No download is triggered in these tests, the context only has to be readable +const appContext = { compositionRoot: { history: {} } } as AppContextI; + +function buildOrgUnits(count: number): OrgUnitReference[] { + return Array.from({ length: count }, (_value, index) => ({ + id: `orgUnit${index.toString().padStart(4, "0")}`, + name: `Org Unit ${index}`, + })); +} + +function renderSummary(orgUnits: OrgUnitReference[]) { + return render( + + + + + + ); +} + +function getOrgUnitsValue(): string { + const label = screen.getByText(ORG_UNIT_LABEL); + return label.nextElementSibling?.textContent ?? ""; +} + +describe("HistoryImportSummary", () => { + describe("org units row", () => { + it("is not rendered when there are no org units", () => { + renderSummary([]); + + expect(screen.queryByText(ORG_UNIT_LABEL)).toBeNull(); + }); + + it("lists every org unit when they all fit", () => { + renderSummary(buildOrgUnits(3)); + + expect(getOrgUnitsValue()).toBe("Org Unit 0, Org Unit 1, Org Unit 2"); + }); + + it("falls back to the id of org units whose name could not be resolved", () => { + renderSummary([ + { id: "firstOrgUn", name: "First" }, + { id: "missingOrg", name: undefined }, + ]); + + expect(getOrgUnitsValue()).toBe("First, missingOrg"); + }); + + it("lists the first ten and counts the rest when there are too many", () => { + renderSummary(buildOrgUnits(MAX_ORG_UNITS_SHOWN + 2)); + + const expectedNames = buildOrgUnits(MAX_ORG_UNITS_SHOWN) + .map(orgUnit => orgUnit.name) + .join(", "); + expect(getOrgUnitsValue()).toBe(`${expectedNames} and 2 more`); + }); + + it("shows every org unit in a tooltip when the list is truncated", async () => { + renderSummary(buildOrgUnits(MAX_ORG_UNITS_SHOWN + 2)); + + fireEvent.mouseOver(screen.getByText(/and 2 more$/)); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip.textContent).toBe( + buildOrgUnits(MAX_ORG_UNITS_SHOWN + 2) + .map(orgUnit => orgUnit.name) + .join("") + ); + }); + }); +}); diff --git a/src/test/data/OrgUnitD2Repository.spec.ts b/src/test/data/OrgUnitD2Repository.spec.ts new file mode 100644 index 00000000..364bba9a --- /dev/null +++ b/src/test/data/OrgUnitD2Repository.spec.ts @@ -0,0 +1,84 @@ +import { vi } from "vitest"; +import { OrgUnitD2Repository } from "../../data/OrgUnitD2Repository"; +import { D2Api } from "../../types/d2-api"; + +const CHUNK_SIZE = 250; + +type OrgUnitsQuery = { filter: { id: { in: string[] } } }; + +function buildOrgUnitId(index: number): string { + return `orgUnit${index.toString().padStart(4, "0")}`; +} + +function buildOrgUnitIds(count: number): string[] { + return Array.from({ length: count }, (_value, index) => buildOrgUnitId(index)); +} + +function setupRepository() { + const get = vi.fn((query: OrgUnitsQuery) => ({ + getData: async () => ({ + objects: query.filter.id.in.map(id => ({ + id, + displayName: `Name of ${id}`, + level: 3, + path: `/root/${id}`, + })), + }), + })); + + const api = { models: { organisationUnits: { get } } } as unknown as D2Api; + const repository = new OrgUnitD2Repository({ type: "local", url: "http://localhost:8080" }, api); + + const requestedIdsPerCall = () => get.mock.calls.map(([query]) => query.filter.id.in); + + return { repository, get, requestedIdsPerCall }; +} + +describe("OrgUnitD2Repository", () => { + describe("getByIds", () => { + it("returns the org units with the name in the language of the current user", async () => { + const { repository } = setupRepository(); + + const orgUnits = await repository.getByIds(["orgUnitA", "orgUnitB"]); + + expect(orgUnits).toEqual([ + { id: "orgUnitA", name: "Name of orgUnitA", level: 3, path: "/root/orgUnitA" }, + { id: "orgUnitB", name: "Name of orgUnitB", level: 3, path: "/root/orgUnitB" }, + ]); + }); + + it("splits the request in chunks to avoid a 414 uri-too-large response", async () => { + const { repository, get, requestedIdsPerCall } = setupRepository(); + const ids = buildOrgUnitIds(600); + + const orgUnits = await repository.getByIds(ids); + + expect(get).toHaveBeenCalledTimes(3); + expect(requestedIdsPerCall()).toEqual([ + ids.slice(0, CHUNK_SIZE), + ids.slice(CHUNK_SIZE, 2 * CHUNK_SIZE), + ids.slice(2 * CHUNK_SIZE), + ]); + expect(orgUnits.map(orgUnit => orgUnit.id)).toEqual(ids); + }); + + it("performs a single request when the ids fit in one chunk", async () => { + const { repository, get, requestedIdsPerCall } = setupRepository(); + const ids = buildOrgUnitIds(CHUNK_SIZE); + + await repository.getByIds(ids); + + expect(get).toHaveBeenCalledTimes(1); + expect(requestedIdsPerCall()).toEqual([ids]); + }); + + it("returns an empty list without performing any request when there are no ids", async () => { + const { repository, get } = setupRepository(); + + const orgUnits = await repository.getByIds([]); + + expect(orgUnits).toEqual([]); + expect(get).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/test/domain/HistoryEntryOrgUnits.spec.ts b/src/test/domain/HistoryEntryOrgUnits.spec.ts new file mode 100644 index 00000000..51ff3dcb --- /dev/null +++ b/src/test/domain/HistoryEntryOrgUnits.spec.ts @@ -0,0 +1,87 @@ +import { getHistoryEntryOrgUnitIds, HistoryEntryDetails } from "../../domain/entities/HistoryEntry"; +import { ImportTemplateConfiguration } from "../../domain/entities/ImportTemplateConfiguration"; +import { OrgUnit, toOrgUnitReferences } from "../../domain/entities/OrgUnit"; + +const ORG_UNIT_ID = "YuQRtpLP10I"; +const ORG_UNIT_PATH = `/ImspTQPwCqd/O6uvpzGd5pu/${ORG_UNIT_ID}`; + +function buildDetails(details: Partial & { configuration?: ImportTemplateConfiguration }) { + return { results: [], errorDetails: undefined, configuration: undefined, ...details }; +} + +function buildOrgUnit(id: string, name: string): OrgUnit { + return { id: id, name: name, path: `/ImspTQPwCqd/O6uvpzGd5pu/${id}`, level: 3 }; +} + +describe("getHistoryEntryOrgUnitIds", () => { + describe("entries with the imported org units recorded", () => { + it("returns every org unit the data was imported into", () => { + const details = buildDetails({ orgUnitsImported: ["firstOrgUn", "secondOrgU", "thirdOrgUn"] }); + + expect(getHistoryEntryOrgUnitIds(details)).toEqual(["firstOrgUn", "secondOrgU", "thirdOrgUn"]); + }); + + it("takes precedence over the overridden org unit, which holds the same value", () => { + const details = buildDetails({ + orgUnitsImported: [ORG_UNIT_ID], + configuration: { useBuilderOrgUnits: true, selectedOrgUnits: [ORG_UNIT_PATH] }, + }); + + expect(getHistoryEntryOrgUnitIds(details)).toEqual([ORG_UNIT_ID]); + }); + + it("returns no org units for an import that never reached DHIS2", () => { + const details = buildDetails({ orgUnitsImported: [] }); + + expect(getHistoryEntryOrgUnitIds(details)).toEqual([]); + }); + }); + + describe("entries saved before the imported org units were recorded", () => { + it("falls back to the id of the org unit that overrode the file", () => { + const details = buildDetails({ + configuration: { useBuilderOrgUnits: true, selectedOrgUnits: [ORG_UNIT_PATH] }, + }); + + expect(getHistoryEntryOrgUnitIds(details)).toEqual([ORG_UNIT_ID]); + }); + + it("returns no org units when they came from the imported file", () => { + const details = buildDetails({ configuration: { useBuilderOrgUnits: false, selectedOrgUnits: [] } }); + + expect(getHistoryEntryOrgUnitIds(details)).toEqual([]); + }); + + it("ignores a selection that was not applied to the import", () => { + const details = buildDetails({ + configuration: { useBuilderOrgUnits: false, selectedOrgUnits: [ORG_UNIT_PATH] }, + }); + + expect(getHistoryEntryOrgUnitIds(details)).toEqual([]); + }); + + it("returns no org units for entries saved without import configuration", () => { + expect(getHistoryEntryOrgUnitIds(buildDetails({}))).toEqual([]); + }); + }); +}); + +describe("toOrgUnitReferences", () => { + it("keeps the requested order and pairs each id with its name", () => { + const orgUnits = [buildOrgUnit("secondOrgU", "Second"), buildOrgUnit("firstOrgUn", "First")]; + + expect(toOrgUnitReferences(["firstOrgUn", "secondOrgU"], orgUnits)).toEqual([ + { id: "firstOrgUn", name: "First" }, + { id: "secondOrgU", name: "Second" }, + ]); + }); + + it("leaves the name undefined for org units that are deleted or not accessible", () => { + const orgUnits = [buildOrgUnit("firstOrgUn", "First")]; + + expect(toOrgUnitReferences(["firstOrgUn", "missingOrg"], orgUnits)).toEqual([ + { id: "firstOrgUn", name: "First" }, + { id: "missingOrg", name: undefined }, + ]); + }); +}); diff --git a/src/webapp/components/history/HistoryDetailsDialog.tsx b/src/webapp/components/history/HistoryDetailsDialog.tsx index a86c41a9..858c214d 100644 --- a/src/webapp/components/history/HistoryDetailsDialog.tsx +++ b/src/webapp/components/history/HistoryDetailsDialog.tsx @@ -48,7 +48,7 @@ const useStyles = makeStyles({ export function HistoryDetailsDialog({ isOpen, entry, onClose }: HistoryDetailsDialogProps) { const classes = useStyles(); - const { details, loading } = useHistoryDetails({ + const { details, orgUnits, loading } = useHistoryDetails({ isOpen, entryId: entry.id, }); @@ -74,7 +74,7 @@ export function HistoryDetailsDialog({ isOpen, entry, onClose }: HistoryDetailsD return ( <> - + {results && results.length > 0 && (
diff --git a/src/webapp/components/history/HistoryImportSummary.tsx b/src/webapp/components/history/HistoryImportSummary.tsx index 046cae75..edca935b 100644 --- a/src/webapp/components/history/HistoryImportSummary.tsx +++ b/src/webapp/components/history/HistoryImportSummary.tsx @@ -7,14 +7,16 @@ import i18n from "../../../utils/i18n"; import { HistoryStatusIndicator } from "./HistoryStatusIndicator"; import { useDownloadDocument } from "../../hooks/useDownloadDocument"; import { DataFormType } from "../../../domain/entities/DataForm"; +import { OrgUnitReference } from "../../../domain/entities/OrgUnit"; import { Maybe } from "../../../types/utils"; interface HistoryImportSummaryProps { summary: HistoryEntrySummary; details?: HistoryEntryDetails; + orgUnits?: OrgUnitReference[]; } -export function HistoryImportSummary({ summary, details }: HistoryImportSummaryProps) { +export function HistoryImportSummary({ summary, details, orgUnits = [] }: HistoryImportSummaryProps) { const classes = useStyles(); const { downloadDocument } = useDownloadDocument(); @@ -41,6 +43,12 @@ export function HistoryImportSummary({ summary, details }: HistoryImportSummaryP {getImportStrategyLabel(details, summary.dataFormType)}
)} + {orgUnits.length > 0 && ( +
+ {i18n.t("Org. Unit")}: + +
+ )}
{i18n.t("File")}: {!summary.documentId || summary.documentDeleted ? ( @@ -81,6 +89,41 @@ export function HistoryImportSummary({ summary, details }: HistoryImportSummaryP ); } +/* An import can span hundreds of org units, so only the first ones are listed inline + and the rest are left to the tooltip */ +const MAX_ORG_UNITS_SHOWN = 10; + +function OrgUnitsValue({ orgUnits }: { orgUnits: OrgUnitReference[] }) { + const classes = useStyles(); + const hiddenCount = orgUnits.length - MAX_ORG_UNITS_SHOWN; + const getLabel = (orgUnit: OrgUnitReference) => orgUnit.name ?? orgUnit.id; + + if (hiddenCount <= 0) { + return {orgUnits.map(getLabel).join(", ")}; + } + + const shownNames = orgUnits.slice(0, MAX_ORG_UNITS_SHOWN).map(getLabel).join(", "); + + return ( + + {orgUnits.map(orgUnit => ( +
{getLabel(orgUnit)}
+ ))} +
+ } + interactive + arrow + placement="top" + > + + {`${shownNames} ${i18n.t("and {{total}} more", { total: hiddenCount })}`} + + + ); +} + function getImportStrategyLabel(entry: HistoryEntryDetails, dataFormType: Maybe): string { const strategy = entry.configuration?.duplicateStrategy; if (!strategy || strategy === "ERROR") { @@ -127,6 +170,14 @@ const useStyles = makeStyles({ padding: "4px 8px", fontSize: "0.75rem", }, + truncatedOrgUnits: { + cursor: "help", + textDecoration: "underline dotted", + }, + orgUnitList: { + maxHeight: 300, + overflowY: "auto", + }, deletedFile: { display: "flex", alignItems: "center", diff --git a/src/webapp/hooks/useHistoryDetails.ts b/src/webapp/hooks/useHistoryDetails.ts index 59814623..5345bdae 100644 --- a/src/webapp/hooks/useHistoryDetails.ts +++ b/src/webapp/hooks/useHistoryDetails.ts @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { useSnackbar } from "@eyeseetea/d2-ui-components"; -import { HistoryEntryDetails } from "../../domain/entities/HistoryEntry"; +import { getHistoryEntryOrgUnitIds, HistoryEntryDetails } from "../../domain/entities/HistoryEntry"; +import { OrgUnitReference, toOrgUnitReferences } from "../../domain/entities/OrgUnit"; import { Id } from "../../domain/entities/ReferenceObject"; import i18n from "../../utils/i18n"; import { useAppContext } from "../contexts/app-context"; @@ -13,6 +14,7 @@ interface UseHistoryDetailsOptions { interface UseHistoryDetailsReturn { details: HistoryEntryDetails | null; + orgUnits: OrgUnitReference[]; loading: boolean; } @@ -21,16 +23,34 @@ export function useHistoryDetails({ isOpen, entryId }: UseHistoryDetailsOptions) const snackbar = useSnackbar(); const [details, setDetails] = useState(null); + const [orgUnits, setOrgUnits] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { if (!isOpen) return; + /* Names are resolved on render instead of persisted so that they are shown in the + language of the current user and reflect any later rename of the org unit. */ + const resolveOrgUnits = async (entryDetails: HistoryEntryDetails): Promise => { + const orgUnitIds = getHistoryEntryOrgUnitIds(entryDetails); + if (orgUnitIds.length === 0) return []; + + try { + const resolved = await compositionRoot.orgUnits.getByIds(orgUnitIds); + return toOrgUnitReferences(orgUnitIds, resolved); + } catch (error) { + // Degrade to the ids: a failure here must not hide the import details + console.error("Error loading organisation units for history entry:", error); + return toOrgUnitReferences(orgUnitIds, []); + } + }; + const loadDetails = async () => { try { setLoading(true); const entryDetails = await compositionRoot.history.getDetails(entryId); setDetails(entryDetails); + setOrgUnits(entryDetails ? await resolveOrgUnits(entryDetails) : []); } catch (error) { console.error("Error loading history details:", error); snackbar.error(i18n.t("Error loading import details")); @@ -40,10 +60,11 @@ export function useHistoryDetails({ isOpen, entryId }: UseHistoryDetailsOptions) }; loadDetails(); - }, [isOpen, entryId, compositionRoot.history, snackbar]); + }, [isOpen, entryId, compositionRoot.history, compositionRoot.orgUnits, snackbar]); return { details, + orgUnits, loading, }; } From 02411fc90c50eb24c167e2c8f809c07a42dbcc48 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Wed, 19 Aug 2026 10:54:33 -0500 Subject: [PATCH 2/4] add warning message if user does not have the required permission to upload documents --- README.md | 1 + i18n/en.pot | 11 ++++-- i18n/es.po | 9 ++++- i18n/fr.po | 9 ++++- i18n/pt.po | 9 ++++- i18n/ru.po | 9 ++++- src/data/D2UsersRepository.ts | 6 +++- src/data/d2-authorities.ts | 8 +++++ src/domain/entities/User.ts | 2 ++ src/test/ImportTemplatePage.spec.tsx | 29 ++++++++++++++-- src/test/data/d2-authorities.spec.ts | 21 ++++++++++++ .../usecases/DocumentsCleanupUseCase.spec.ts | 1 + .../usecases/HistoryCleanupUseCase.spec.ts | 1 + .../components/warning-alert/WarningAlert.tsx | 34 +++++++++++++++++++ src/webapp/logic/settings.ts | 9 ++--- .../import-template/ImportTemplatePage.tsx | 11 ++++++ 16 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 src/data/d2-authorities.ts create mode 100644 src/test/data/d2-authorities.spec.ts create mode 100644 src/webapp/components/warning-alert/WarningAlert.tsx diff --git a/README.md b/README.md index eda49b2a..3f760b30 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ The Bulk Load application generates templates (an Excel sheet) and imports multi - Settings are only visible for superusers (`ALL` authority) or users that belong to the settings groups. - The generation box is only visible for users that belong to the configurable `Template Generation` groups (initial value: `HMIS Officers`). +- Imported files are stored as documents (and can be downloaded later from the history) only for users with the `ALL` or `F_DOCUMENT_PRIVATE_ADD` authority. Users without them see a warning in the import page: their data is imported as usual and the import is still recorded in the history, but without the file. ## Setup diff --git a/i18n/en.pot b/i18n/en.pot index ffa4ddd6..e3be1fe3 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: 2026-08-16T18:08:38.823Z\n" -"PO-Revision-Date: 2026-08-16T18:08:38.823Z\n" +"POT-Creation-Date: 2026-08-19T14:35:59.584Z\n" +"PO-Revision-Date: 2026-08-19T14:35:59.584Z\n" msgid "Events - Create/update" msgstr "" @@ -1471,6 +1471,13 @@ msgstr "" msgid "Bulk data import" msgstr "" +msgid "" +"The data will be imported as usual, but the file itself will not be saved " +"because you do not have any of the required authorities ({{authorities}}). " +"The import will still be recorded in the history, without a file available " +"for download." +msgstr "" + msgid "Drag and drop file to import" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index f2c71e06..5db93266 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2026-08-16T18:08:38.823Z\n" +"POT-Creation-Date: 2026-08-19T14:35:59.584Z\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1558,6 +1558,13 @@ msgstr "Descargar datos con unidades organizativas no válidas" msgid "Bulk data import" msgstr "Importación de datos" +msgid "" +"The data will be imported as usual, but the file itself will not be saved " +"because you do not have any of the required authorities ({{authorities}}). " +"The import will still be recorded in the history, without a file available " +"for download." +msgstr "" + msgid "Drag and drop file to import" msgstr "Mueva aquí los ficheros a importar" diff --git a/i18n/fr.po b/i18n/fr.po index 5d55b1c4..c2c8806f 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: 2026-08-16T18:08:38.823Z\n" +"POT-Creation-Date: 2026-08-19T14:35:59.584Z\n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1580,6 +1580,13 @@ msgstr "" msgid "Bulk data import" msgstr "Importation de données en masse" +msgid "" +"The data will be imported as usual, but the file itself will not be saved " +"because you do not have any of the required authorities ({{authorities}}). " +"The import will still be recorded in the history, without a file available " +"for download." +msgstr "" + msgid "Drag and drop file to import" msgstr "Faites glisser et déposez le fichier à importer ici" diff --git a/i18n/pt.po b/i18n/pt.po index d1490cc5..bbeba1b5 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2026-08-16T18:08:38.823Z\n" +"POT-Creation-Date: 2026-08-19T14:35:59.584Z\n" "Language: pt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1602,6 +1602,13 @@ msgstr "Descarregar dados com unidades organizacionais inválidas" msgid "Bulk data import" msgstr "Importação de dados em massa" +msgid "" +"The data will be imported as usual, but the file itself will not be saved " +"because you do not have any of the required authorities ({{authorities}}). " +"The import will still be recorded in the history, without a file available " +"for download." +msgstr "" + msgid "Drag and drop file to import" msgstr "Arraste e solte o arquivo para importar" diff --git a/i18n/ru.po b/i18n/ru.po index 3957f0df..75dc94c8 100644 --- a/i18n/ru.po +++ b/i18n/ru.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2026-08-16T18:08:38.823Z\n" +"POT-Creation-Date: 2026-08-19T14:35:59.584Z\n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1610,6 +1610,13 @@ msgstr "Загрузка значений данных с недопустимы msgid "Bulk data import" msgstr "Массовый импорт данных" +msgid "" +"The data will be imported as usual, but the file itself will not be saved " +"because you do not have any of the required authorities ({{authorities}}). " +"The import will still be recorded in the history, without a file available " +"for download." +msgstr "" + msgid "Drag and drop file to import" msgstr "Перетащите файл для импорта" diff --git a/src/data/D2UsersRepository.ts b/src/data/D2UsersRepository.ts index fb803f54..a4e52029 100644 --- a/src/data/D2UsersRepository.ts +++ b/src/data/D2UsersRepository.ts @@ -2,6 +2,7 @@ import { DhisInstance } from "../domain/entities/DhisInstance"; import { User } from "../domain/entities/User"; import { SearchResults, UsersRepository } from "../domain/repositories/UsersRepository"; import { D2OrganisationUnit, D2Api } from "../types/d2-api"; +import { canUploadDocuments } from "./d2-authorities"; export class D2UsersRepository implements UsersRepository { private api: D2Api; @@ -36,11 +37,14 @@ export class D2UsersRepository implements UsersRepository { }) .getData(); + const authorities = new Set(apiUser.authorities); + return { id: apiUser.id, name: apiUser.name, username: apiUser.username ?? apiUser.userCredentials?.username ?? "", - authorities: new Set(apiUser.authorities), + authorities, + canUploadDocuments: canUploadDocuments(authorities), userGroups: apiUser.userGroups, orgUnitsView: apiUser.dataViewOrganisationUnits.map(this.buildOrgUnit), orgUnits: apiUser.organisationUnits.map(this.buildOrgUnit), diff --git a/src/data/d2-authorities.ts b/src/data/d2-authorities.ts new file mode 100644 index 00000000..6684e224 --- /dev/null +++ b/src/data/d2-authorities.ts @@ -0,0 +1,8 @@ +/* Authorities are a DHIS2 concept: they are translated here into the capabilities that the rest + of the app uses, so that no other layer has to know which authority grants what. */ + +export const documentUploadAuthorities = ["ALL", "F_DOCUMENT_PRIVATE_ADD"] as const; + +export function canUploadDocuments(authorities: ReadonlySet): boolean { + return documentUploadAuthorities.some(authority => authorities.has(authority)); +} diff --git a/src/domain/entities/User.ts b/src/domain/entities/User.ts index 29ecabe7..01f9ea19 100644 --- a/src/domain/entities/User.ts +++ b/src/domain/entities/User.ts @@ -8,6 +8,8 @@ export interface User { username: string; name: string; authorities: Set; + /* We should remove all the references to authorities in the rest of the app and use this property instead.*/ + canUploadDocuments: boolean; userGroups: Ref[]; orgUnits: OrgUnit[]; orgUnitsView: OrgUnit[]; diff --git a/src/test/ImportTemplatePage.spec.tsx b/src/test/ImportTemplatePage.spec.tsx index 0629f708..68f848ac 100644 --- a/src/test/ImportTemplatePage.spec.tsx +++ b/src/test/ImportTemplatePage.spec.tsx @@ -10,6 +10,17 @@ import { initializeMockServer } from "./mocks/server"; let settings: Settings; +const documentWarning = + "The data will be imported as usual, but the file itself will not be saved because you do not have " + + "any of the required authorities (ALL, F_DOCUMENT_PRIVATE_ADD). The import will still be recorded " + + "in the history, without a file available for download."; + +const settingsWithUploadPermission = (currentSettings: Settings): Settings => + new Settings({ + ...currentSettings, + currentUser: { ...currentSettings.currentUser, canUploadDocuments: true }, + }); + const { api } = initializeMockServer(); const compositionRoot = getCompositionRoot({ appConfig: { @@ -20,14 +31,14 @@ const compositionRoot = getCompositionRoot({ mockApi: api, }); -const renderComponent = async () => { +const renderComponent = async (pageSettings: Settings = settings) => { await act(async () => { render( { expect(screen.getByText(/drag and drop/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Import data" })).toBeInTheDocument(); }); + + describe("document upload warning", () => { + it("warns that the file will not be saved when the user cannot upload documents", async () => { + await renderComponent(); + + expect(screen.getByRole("alert")).toHaveTextContent(documentWarning); + }); + + it("is not shown when the user can upload documents", async () => { + await renderComponent(settingsWithUploadPermission(settings)); + + expect(screen.queryByRole("alert")).toBeNull(); + }); + }); }); export {}; diff --git a/src/test/data/d2-authorities.spec.ts b/src/test/data/d2-authorities.spec.ts new file mode 100644 index 00000000..3c072b87 --- /dev/null +++ b/src/test/data/d2-authorities.spec.ts @@ -0,0 +1,21 @@ +import { canUploadDocuments } from "../../data/d2-authorities"; + +const OTHER_AUTHORITY = "F_DATAVALUE_ADD"; + +describe("canUploadDocuments", () => { + it("is granted to superusers", () => { + expect(canUploadDocuments(new Set(["ALL"]))).toBe(true); + }); + + it("is granted by the document authority alone", () => { + expect(canUploadDocuments(new Set([OTHER_AUTHORITY, "F_DOCUMENT_PRIVATE_ADD"]))).toBe(true); + }); + + it("is denied when none of the required authorities is present", () => { + expect(canUploadDocuments(new Set([OTHER_AUTHORITY]))).toBe(false); + }); + + it("is denied for users without authorities", () => { + expect(canUploadDocuments(new Set())).toBe(false); + }); +}); diff --git a/src/test/usecases/DocumentsCleanupUseCase.spec.ts b/src/test/usecases/DocumentsCleanupUseCase.spec.ts index 0ebcf27e..2c0c051e 100644 --- a/src/test/usecases/DocumentsCleanupUseCase.spec.ts +++ b/src/test/usecases/DocumentsCleanupUseCase.spec.ts @@ -28,6 +28,7 @@ describe("DocumentsCleanupUseCase", () => { username: "testuser", name: "Test User", authorities: new Set(), + canUploadDocuments: false, userGroups: [], orgUnits: [], orgUnitsView: [], diff --git a/src/test/usecases/HistoryCleanupUseCase.spec.ts b/src/test/usecases/HistoryCleanupUseCase.spec.ts index 93d2fd33..3f7bc9c0 100644 --- a/src/test/usecases/HistoryCleanupUseCase.spec.ts +++ b/src/test/usecases/HistoryCleanupUseCase.spec.ts @@ -28,6 +28,7 @@ describe("HistoryCleanupUseCase", () => { username: "testuser", name: "Test User", authorities: new Set(), + canUploadDocuments: false, userGroups: [], orgUnits: [], orgUnitsView: [], diff --git a/src/webapp/components/warning-alert/WarningAlert.tsx b/src/webapp/components/warning-alert/WarningAlert.tsx new file mode 100644 index 00000000..034b5aa7 --- /dev/null +++ b/src/webapp/components/warning-alert/WarningAlert.tsx @@ -0,0 +1,34 @@ +import { Icon, Typography, makeStyles } from "@material-ui/core"; + +interface WarningAlertProps { + message: string; +} + +export function WarningAlert({ message }: WarningAlertProps) { + const classes = useStyles(); + + return ( +
+ warning + {message} +
+ ); +} + +const useStyles = makeStyles({ + alert: { + display: "flex", + alignItems: "center", + marginTop: "1em", + marginBottom: "1em", + padding: "8px 16px", + backgroundColor: "#fff3cd", + border: "1px solid #ffeaa7", + borderRadius: 4, + color: "#856404", + }, + icon: { + marginRight: 8, + color: "#f39c12", + }, +}); diff --git a/src/webapp/logic/settings.ts b/src/webapp/logic/settings.ts index 07d7e4fa..834fdf8e 100644 --- a/src/webapp/logic/settings.ts +++ b/src/webapp/logic/settings.ts @@ -26,6 +26,7 @@ import i18n from "../../utils/i18n"; import { D2Api, Ref } from "../../types/d2-api"; import { GetArrayInnerType, Maybe, OkOrError } from "../../types/utils"; import { isAdmin, User } from "../../domain/entities/User"; +import { canUploadDocuments } from "../../data/d2-authorities"; const privateFields = ["currentUser"] as const; @@ -105,7 +106,7 @@ export default class Settings { } static async build(api: D2Api, compositionRoot: CompositionRoot): Promise { - const authorities = await api.get("/me/authorization").getData(); + const authorities = new Set(await api.get("/me/authorization").getData()); const d2CurrentUser = await api.currentUser .get({ @@ -134,7 +135,8 @@ export default class Settings { id: d2CurrentUser.id, name: d2CurrentUser.name, username: d2CurrentUser.username ?? d2CurrentUser.userCredentials?.username ?? "", - authorities: new Set(authorities), + authorities, + canUploadDocuments: canUploadDocuments(authorities), userGroups: d2CurrentUser.userGroups, orgUnits: d2CurrentUser.organisationUnits, orgUnitsView: d2CurrentUser.dataViewOrganisationUnits, @@ -497,8 +499,7 @@ export default class Settings { } canUploadDocument(): boolean { - const DOCUMENT_AUTHORITIES = ["ALL", "F_DOCUMENT_PRIVATE_ADD"]; - return DOCUMENT_AUTHORITIES.some(auth => this.currentUser.authorities.has(auth)); + return this.currentUser.canUploadDocuments; } getModelsInfo(): Array<{ key: Model; name: string; value: boolean }> { diff --git a/src/webapp/pages/import-template/ImportTemplatePage.tsx b/src/webapp/pages/import-template/ImportTemplatePage.tsx index ec592f50..10d9eeb3 100644 --- a/src/webapp/pages/import-template/ImportTemplatePage.tsx +++ b/src/webapp/pages/import-template/ImportTemplatePage.tsx @@ -17,6 +17,8 @@ import { useAppContext } from "../../contexts/app-context"; import { orgUnitListParams } from "../../utils/template"; import { RouteComponentProps } from "../Router"; import { TemplateDataPackage, templateToDataPackage } from "../../../domain/entities/Template"; +import { WarningAlert } from "../../components/warning-alert/WarningAlert"; +import { documentUploadAuthorities } from "../../../data/d2-authorities"; const importAcceptedMimeTypes = ["application/zip", "application/x-zip-compressed", xlsxMimeType, xlsxMacroMimeType]; @@ -311,6 +313,15 @@ export default function ImportTemplatePage({ settings }: RouteComponentProps) {

{i18n.t("Bulk data import")}

+ {!settings.canUploadDocument() && ( + + )} + Date: Mon, 24 Aug 2026 07:35:00 -0500 Subject: [PATCH 3/4] update locales --- i18n/es.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i18n/es.po b/i18n/es.po index b530e76c..6abc187b 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -259,7 +259,7 @@ msgid "The original file is not available for download" msgstr "" msgid "and {{total}} more" -msgstr "y {{count}} más" +msgstr "y {{total}} más" msgid "Default" msgstr "" From d2dcce92ecbd3bddb62e70f7a2fdca5515849134 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Mon, 24 Aug 2026 07:52:16 -0500 Subject: [PATCH 4/4] import authorities from settings --- src/webapp/logic/settings.ts | 6 +++++- src/webapp/pages/import-template/ImportTemplatePage.tsx | 3 +-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/webapp/logic/settings.ts b/src/webapp/logic/settings.ts index 34620929..6bde97a3 100644 --- a/src/webapp/logic/settings.ts +++ b/src/webapp/logic/settings.ts @@ -26,7 +26,7 @@ import i18n from "../../utils/i18n"; import { D2Api, Ref } from "../../types/d2-api"; import { GetArrayInnerType, Maybe, OkOrError } from "../../types/utils"; import { isAdmin, User } from "../../domain/entities/User"; -import { canUploadDocuments } from "../../data/d2-authorities"; +import { canUploadDocuments, documentUploadAuthorities } from "../../data/d2-authorities"; const privateFields = ["currentUser"] as const; @@ -508,6 +508,10 @@ export default class Settings { return this.currentUser.canUploadDocuments; } + getDocumentUploadAuthorities(): ReadonlyArray { + return documentUploadAuthorities; + } + getModelsInfo(): Array<{ key: Model; name: string; value: boolean }> { return [ { key: "dataSet", name: i18n.t("Data set"), value: this.models.dataSet }, diff --git a/src/webapp/pages/import-template/ImportTemplatePage.tsx b/src/webapp/pages/import-template/ImportTemplatePage.tsx index fddb0137..13204eaa 100644 --- a/src/webapp/pages/import-template/ImportTemplatePage.tsx +++ b/src/webapp/pages/import-template/ImportTemplatePage.tsx @@ -18,7 +18,6 @@ import { orgUnitListParams } from "../../utils/template"; import { RouteComponentProps } from "../Router"; import { TemplateDataPackage, templateToDataPackage } from "../../../domain/entities/Template"; import { WarningAlert } from "../../components/warning-alert/WarningAlert"; -import { documentUploadAuthorities } from "../../../data/d2-authorities"; const importAcceptedMimeTypes = ["application/zip", "application/x-zip-compressed", xlsxMimeType, xlsxMacroMimeType]; @@ -322,7 +321,7 @@ export default function ImportTemplatePage({ settings }: RouteComponentProps) { )}