From 35152a159755ee4a0d41f53c1c3b5975d9b56b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Mestach?= Date: Thu, 1 Oct 2026 08:32:01 +0200 Subject: [PATCH 01/25] IA-5375 allow import/export of mappings --- .../Iaso/domains/app/translations/en.json | 44 +++ .../Iaso/domains/app/translations/es.json | 44 +++ .../Iaso/domains/app/translations/fr.json | 44 +++ .../components/ImportMappings/CompareStep.tsx | 282 +++++++++++++++ .../ImportMappingsDialog.test.tsx | 141 ++++++++ .../ImportMappings/ImportMappingsDialog.tsx | 253 +++++++++++++ .../ImportMappings/MappingImportActions.tsx | 120 ++++++ .../components/ImportMappings/SourceCard.tsx | 90 +++++ .../components/ImportMappings/SourceStep.tsx | 96 +++++ .../js/apps/Iaso/domains/mappings/details.tsx | 15 +- .../requests/useBulkUpdateQuestionMappings.ts | 18 + .../requests/useGetMappingImportSources.ts | 20 + .../mappings/hooks/useImportSources.ts | 103 ++++++ .../domains/mappings/importMappings.test.ts | 331 +++++++++++++++++ .../Iaso/domains/mappings/importMappings.ts | 341 ++++++++++++++++++ .../js/apps/Iaso/domains/mappings/messages.js | 195 ++++++++++ .../js/apps/Iaso/domains/mappings/types.ts | 60 +++ iaso/api/mapping_versions.py | 7 + iaso/dhis2/form_mapping.py | 4 +- iaso/models/forms.py | 3 + iaso/odk/__init__.py | 3 +- iaso/odk/parsing.py | 35 ++ iaso/tests/api/test_mappingversions.py | 164 +++++++++ iaso/tests/dhis2/tests_form_mapping.py | 77 ++++ iaso/tests/odk/test_parsing.py | 44 ++- 25 files changed, 2530 insertions(+), 4 deletions(-) create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.tsx create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/MappingImportActions.tsx create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/SourceCard.tsx create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/SourceStep.tsx create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/hooks/requests/useBulkUpdateQuestionMappings.ts create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/hooks/requests/useGetMappingImportSources.ts create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/hooks/useImportSources.ts create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/importMappings.test.ts create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts create mode 100644 hat/assets/js/apps/Iaso/domains/mappings/types.ts create mode 100644 iaso/tests/dhis2/tests_form_mapping.py diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/en.json b/hat/assets/js/apps/Iaso/domains/app/translations/en.json index 81956fdedb..64e90da427 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/en.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/en.json @@ -1123,6 +1123,50 @@ "iaso.mappings.coverage": "Coverage", "iaso.mappings.create": "Create Mapping", "iaso.mappings.currentMapping": "Current mapping", + "iaso.mappings.export": "Export mappings", + "iaso.mappings.import.addAll": "Add all", + "iaso.mappings.import.allAdditions": "All additions:", + "iaso.mappings.import.applyChanges": "Apply {count} changes", + "iaso.mappings.import.bucket.add": "To add ({count})", + "iaso.mappings.import.bucket.conflict": "Conflicts ({count})", + "iaso.mappings.import.bucket.dropped": "Dropped ({count})", + "iaso.mappings.import.bucket.identical": "Identical ({count})", + "iaso.mappings.import.chooseFile": "Choose a file", + "iaso.mappings.import.compareLabel": "Comparing {source} with version {versionId}. Nothing is applied until you confirm.", + "iaso.mappings.import.decision": "Decision", + "iaso.mappings.import.done": "{added} mappings added, {overwritten} overwritten, {kept} kept, {ignored} not imported", + "iaso.mappings.import.droppedHint": "These mappings cannot be imported: the question does not exist in this version or is marked as never mapped.", + "iaso.mappings.import.fileMeta": "Exported from {formName} - {versionId}", + "iaso.mappings.import.fromFile": "Or import a mapping export (.json), for example from another account.", + "iaso.mappings.import.identicalHint": "Same DHIS2 target on both sides. Nothing to resolve.", + "iaso.mappings.import.incomingMapping": "Incoming mapping", + "iaso.mappings.import.invalidFormat": "This file is not a mapping export.", + "iaso.mappings.import.invalidJson": "This file is not valid JSON.", + "iaso.mappings.import.keep": "Keep", + "iaso.mappings.import.keepEverywhere": "Keep current everywhere", + "iaso.mappings.import.mappingsCount": "{count} mappings", + "iaso.mappings.import.mappingTypeMismatch": "This export was made for another mapping type.", + "iaso.mappings.import.markedNeverMapped": "Marked as never mapped", + "iaso.mappings.import.matchCount": "{matching} / {total} match this version", + "iaso.mappings.import.noChange": "No change", + "iaso.mappings.import.noSource": "No other mapping version uses this data source with the same DHIS2 dataset or program.", + "iaso.mappings.import.notImportable": "Not importable", + "iaso.mappings.import.otherFormDatasetMeta": "Another form, same DHIS2 dataset, last updated {date}", + "iaso.mappings.import.otherFormProgramMeta": "Another form, same DHIS2 program, last updated {date}", + "iaso.mappings.import.otherFormTitle": "{formName} - {versionId}", + "iaso.mappings.import.overwrite": "Overwrite", + "iaso.mappings.import.overwriteEverywhere": "Overwrite everywhere", + "iaso.mappings.import.pickSource": "Pick the mappings to reuse. Nothing is applied until you confirm.", + "iaso.mappings.import.questionAbsent": "Question absent from version {versionId}", + "iaso.mappings.import.resolveAllConflicts": "Resolve all conflicts:", + "iaso.mappings.import.sameFormMeta": "Same form, last updated {date}", + "iaso.mappings.import.skip": "Skip", + "iaso.mappings.import.skipAll": "Skip all", + "iaso.mappings.import.summary": "{added} to add, {overwritten} to overwrite, {ignored} not imported", + "iaso.mappings.import.title": "Import mappings", + "iaso.mappings.import.undo": "Undo import", + "iaso.mappings.import.undone": "Import undone", + "iaso.mappings.import.versionTitle": "Version {versionId}", "iaso.mappings.label.atLeastAMapping": "at least a mapping", "iaso.mappings.label.dataset": "Dataset", "iaso.mappings.label.duplicateMappingAlert": "Duplicate mapping ! Will be used in both {duplicates}", diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/es.json b/hat/assets/js/apps/Iaso/domains/app/translations/es.json index 8c768d228a..b26ac5d844 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/es.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/es.json @@ -926,6 +926,50 @@ "iaso.mappings.coverage": "Cobertura", "iaso.mappings.create": "Crear Mapeo", "iaso.mappings.currentMapping": "Mapeo actual", + "iaso.mappings.export": "Exportar mapeos", + "iaso.mappings.import.addAll": "Añadir todo", + "iaso.mappings.import.allAdditions": "Todas las adiciones:", + "iaso.mappings.import.applyChanges": "Aplicar {count} cambios", + "iaso.mappings.import.bucket.add": "Para añadir ({count})", + "iaso.mappings.import.bucket.conflict": "Conflictos ({count})", + "iaso.mappings.import.bucket.dropped": "Descartados ({count})", + "iaso.mappings.import.bucket.identical": "Idénticos ({count})", + "iaso.mappings.import.chooseFile": "Elegir un archivo", + "iaso.mappings.import.compareLabel": "Comparando {source} con la versión {versionId}. No se aplica nada hasta que confirme.", + "iaso.mappings.import.decision": "Decisión", + "iaso.mappings.import.done": "{added} mapeos añadidos, {overwritten} sobrescritos, {kept} mantenidos, {ignored} no importados", + "iaso.mappings.import.droppedHint": "Estos mapeos no se pueden importar: la pregunta no existe en esta versión o está marcada como nunca mapeada.", + "iaso.mappings.import.fileMeta": "Exportado desde {formName} - {versionId}", + "iaso.mappings.import.fromFile": "O importe una exportación de mapeo (.json), por ejemplo desde otra cuenta.", + "iaso.mappings.import.identicalHint": "Mismo destino DHIS2 en ambos lados. Nada que resolver.", + "iaso.mappings.import.incomingMapping": "Mapeo entrante", + "iaso.mappings.import.invalidFormat": "Este archivo no es una exportación de mapeo.", + "iaso.mappings.import.invalidJson": "Este archivo no es un JSON válido.", + "iaso.mappings.import.keep": "Mantener", + "iaso.mappings.import.keepEverywhere": "Mantener el actual en todas partes", + "iaso.mappings.import.mappingsCount": "{count} mapeos", + "iaso.mappings.import.mappingTypeMismatch": "Esta exportación se hizo para otro tipo de mapeo.", + "iaso.mappings.import.markedNeverMapped": "Marcada como nunca mapeada", + "iaso.mappings.import.matchCount": "{matching} / {total} coinciden con esta versión", + "iaso.mappings.import.noChange": "Sin cambios", + "iaso.mappings.import.noSource": "Ninguna otra versión de mapeo usa esta fuente de datos con el mismo dataset o programa DHIS2.", + "iaso.mappings.import.notImportable": "No importable", + "iaso.mappings.import.otherFormDatasetMeta": "Otro formulario, mismo dataset DHIS2, actualizado el {date}", + "iaso.mappings.import.otherFormProgramMeta": "Otro formulario, mismo programa DHIS2, actualizado el {date}", + "iaso.mappings.import.otherFormTitle": "{formName} - {versionId}", + "iaso.mappings.import.overwrite": "Sobrescribir", + "iaso.mappings.import.overwriteEverywhere": "Sobrescribir en todas partes", + "iaso.mappings.import.pickSource": "Elija los mapeos a reutilizar. No se aplica nada hasta que confirme.", + "iaso.mappings.import.questionAbsent": "Pregunta ausente de la versión {versionId}", + "iaso.mappings.import.resolveAllConflicts": "Resolver todos los conflictos:", + "iaso.mappings.import.sameFormMeta": "Mismo formulario, actualizado el {date}", + "iaso.mappings.import.skip": "Omitir", + "iaso.mappings.import.skipAll": "Omitir todo", + "iaso.mappings.import.summary": "{added} para añadir, {overwritten} para sobrescribir, {ignored} no importados", + "iaso.mappings.import.title": "Importar mapeos", + "iaso.mappings.import.undo": "Deshacer importación", + "iaso.mappings.import.undone": "Importación deshecha", + "iaso.mappings.import.versionTitle": "Versión {versionId}", "iaso.mappings.label.atLeastAMapping": "al menos un mapeo", "iaso.mappings.label.dataset": "Conjunto de datos", "iaso.mappings.label.duplicateMappingAlert": "¡Mapeo duplicado! Se usará en ambos {duplicates}", diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json index 84aa53962e..9acbd8a582 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json @@ -1124,6 +1124,50 @@ "iaso.mappings.coverage": "Couverture", "iaso.mappings.create": "Ajouter une correspondance", "iaso.mappings.currentMapping": "Correspondance actuelle", + "iaso.mappings.export": "Exporter les mappings", + "iaso.mappings.import.addAll": "Tout ajouter", + "iaso.mappings.import.allAdditions": "Tous les ajouts :", + "iaso.mappings.import.applyChanges": "Appliquer {count} changements", + "iaso.mappings.import.bucket.add": "À ajouter ({count})", + "iaso.mappings.import.bucket.conflict": "Conflits ({count})", + "iaso.mappings.import.bucket.dropped": "Ignorés ({count})", + "iaso.mappings.import.bucket.identical": "Identiques ({count})", + "iaso.mappings.import.chooseFile": "Choisir un fichier", + "iaso.mappings.import.compareLabel": "Comparaison de {source} avec la version {versionId}. Rien n'est appliqué avant votre confirmation.", + "iaso.mappings.import.decision": "Décision", + "iaso.mappings.import.done": "{added} mappings ajoutés, {overwritten} écrasés, {kept} gardés, {ignored} non importés", + "iaso.mappings.import.droppedHint": "Ces mappings ne peuvent pas être importés : la question n'existe pas dans cette version ou est marquée comme jamais mappée.", + "iaso.mappings.import.fileMeta": "Exporté depuis {formName} - {versionId}", + "iaso.mappings.import.fromFile": "Ou importez un export de mapping (.json), par exemple depuis un autre compte.", + "iaso.mappings.import.identicalHint": "Même cible DHIS2 des deux côtés. Rien à résoudre.", + "iaso.mappings.import.incomingMapping": "Mapping importé", + "iaso.mappings.import.invalidFormat": "Ce fichier n'est pas un export de mapping.", + "iaso.mappings.import.invalidJson": "Ce fichier n'est pas un JSON valide.", + "iaso.mappings.import.keep": "Garder", + "iaso.mappings.import.keepEverywhere": "Garder l'actuel partout", + "iaso.mappings.import.mappingsCount": "{count} mappings", + "iaso.mappings.import.mappingTypeMismatch": "Cet export concerne un autre type de mapping.", + "iaso.mappings.import.markedNeverMapped": "Marquée comme jamais mappée", + "iaso.mappings.import.matchCount": "{matching} / {total} correspondent à cette version", + "iaso.mappings.import.noChange": "Aucun changement", + "iaso.mappings.import.noSource": "Aucune autre version de mapping n'utilise cette source avec le même dataset ou programme DHIS2.", + "iaso.mappings.import.notImportable": "Non importable", + "iaso.mappings.import.otherFormDatasetMeta": "Autre formulaire, même dataset DHIS2, mis à jour le {date}", + "iaso.mappings.import.otherFormProgramMeta": "Autre formulaire, même programme DHIS2, mis à jour le {date}", + "iaso.mappings.import.otherFormTitle": "{formName} - {versionId}", + "iaso.mappings.import.overwrite": "Écraser", + "iaso.mappings.import.overwriteEverywhere": "Écraser partout", + "iaso.mappings.import.pickSource": "Choisissez les mappings à réutiliser. Rien n'est appliqué avant votre confirmation.", + "iaso.mappings.import.questionAbsent": "Question absente de la version {versionId}", + "iaso.mappings.import.resolveAllConflicts": "Résoudre tous les conflits :", + "iaso.mappings.import.sameFormMeta": "Même formulaire, mis à jour le {date}", + "iaso.mappings.import.skip": "Ignorer", + "iaso.mappings.import.skipAll": "Tout ignorer", + "iaso.mappings.import.summary": "{added} à ajouter, {overwritten} à écraser, {ignored} non importés", + "iaso.mappings.import.title": "Importer des mappings", + "iaso.mappings.import.undo": "Annuler l'import", + "iaso.mappings.import.undone": "Import annulé", + "iaso.mappings.import.versionTitle": "Version {versionId}", "iaso.mappings.label.atLeastAMapping": "au moins une correspondance", "iaso.mappings.label.dataset": "Dataset", "iaso.mappings.label.duplicateMappingAlert": "Doublon ! Sera utilisé dans {duplicates}", diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx new file mode 100644 index 0000000000..dbb110c575 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx @@ -0,0 +1,282 @@ +import React, { FunctionComponent, useMemo } from 'react'; +import { + Box, + Button, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + Typography, +} from '@mui/material'; +import { IntlMessage, useSafeIntl } from 'bluesquare-components'; +import { textPlaceholder } from '../../../../constants/uiConstants'; +import { SxStyles } from '../../../../types/general'; +import { DIFF_KINDS, getMappingLabel } from '../../importMappings'; +import MESSAGES from '../../messages'; +import { Decision, DiffKind, DiffRow, ImportPlan } from '../../types'; + +type Props = { + sourceTitle: string; + versionId: string; + rows: DiffRow[]; + bucket: DiffKind; + setBucket: (bucket: DiffKind) => void; + decisions: Record; + setDecision: (questionKey: string, decision: Decision) => void; + setBulkDecision: (kind: DiffKind, decision: Decision) => void; + plan: ImportPlan; +}; + +const BUCKET_MESSAGES: Record = { + conflict: MESSAGES.bucketConflict, + add: MESSAGES.bucketAdd, + identical: MESSAGES.bucketIdentical, + dropped: MESSAGES.bucketDropped, +}; + +type Choice = { decision: Decision; message: IntlMessage }; + +const CHOICES: Partial> = { + conflict: [ + { decision: 'keep', message: MESSAGES.keep }, + { decision: 'overwrite', message: MESSAGES.overwrite }, + ], + add: [ + { decision: 'skip', message: MESSAGES.skip }, + { decision: 'apply', message: MESSAGES.add }, + ], +}; + +const BULK: Record = { + conflict: { + message: MESSAGES.resolveAllConflicts, + actions: [ + { decision: 'keep', message: MESSAGES.keepEverywhere }, + { decision: 'overwrite', message: MESSAGES.overwriteEverywhere }, + ], + }, + add: { + message: MESSAGES.allAdditions, + actions: [ + { decision: 'apply', message: MESSAGES.addAll }, + { decision: 'skip', message: MESSAGES.skipAll }, + ], + }, + identical: { message: MESSAGES.identicalHint, actions: [] }, + dropped: { message: MESSAGES.droppedHint, actions: [] }, +}; + +const styles: SxStyles = { + tabs: { borderBottom: 1, borderColor: 'divider', mt: 1 }, + bulkRow: { display: 'flex', alignItems: 'center', gap: 1, minHeight: 44 }, + table: { maxHeight: '40vh' }, + questionColumn: { width: 240 }, + decisionColumn: { width: 210 }, + current: { color: 'text.primary' }, + noCurrent: { color: 'text.disabled' }, + choices: { display: 'flex', gap: 0.75, justifyContent: 'flex-end' }, + summary: { mt: 1.5 }, +}; + +export const CompareStep: FunctionComponent = ({ + sourceTitle, + versionId, + rows, + bucket, + setBucket, + decisions, + setDecision, + setBulkDecision, + plan, +}) => { + const { formatMessage } = useSafeIntl(); + const counts = useMemo(() => { + const result = { conflict: 0, add: 0, identical: 0, dropped: 0 }; + rows.forEach(row => { + result[row.kind] += 1; + }); + return result; + }, [rows]); + const bucketRows = rows.filter(row => row.kind === bucket); + const bulk = BULK[bucket]; + + const getCurrentLabel = (row: DiffRow): string => { + if (row.kind !== 'dropped') { + return getMappingLabel(row.current) || textPlaceholder; + } + return row.neverMapped + ? formatMessage(MESSAGES.markedNeverMapped) + : formatMessage(MESSAGES.questionAbsent, { versionId }); + }; + + return ( + + + {formatMessage(MESSAGES.compareLabel, { + source: sourceTitle, + versionId, + })} + + setBucket(value)} + indicatorColor="primary" + textColor="primary" + sx={styles.tabs} + > + {DIFF_KINDS.map(kind => ( + + ))} + + + + {formatMessage(bulk.message)} + + {bulk.actions.map(action => ( + + ))} + + + + + + + {formatMessage(MESSAGES.question)} + + + {formatMessage( + bucket === 'dropped' + ? MESSAGES.question + : MESSAGES.currentMapping, + )} + + + {formatMessage(MESSAGES.incomingMapping)} + + + {formatMessage(MESSAGES.decision)} + + + + + {bucketRows.map(row => { + const decision = decisions[row.questionKey]; + const choices = CHOICES[row.kind]; + return ( + + + {/* dropped questions have no label: the name alone is the title */} + + {row.questionLabel ?? + row.questionKey} + + {row.questionLabel && + row.questionLabel !== + row.questionKey && ( + + {row.questionKey} + + )} + + + {getCurrentLabel(row)} + + + {getMappingLabel(row.incoming)} + + + {choices ? ( + + {choices.map(choice => ( + + ))} + + ) : ( + + {formatMessage( + row.kind === 'identical' + ? MESSAGES.noChange + : MESSAGES.notImportable, + )} + + )} + + + ); + })} + +
+
+ + {formatMessage(MESSAGES.importSummary, { + added: plan.added, + overwritten: plan.overwritten, + ignored: plan.dropped + plan.skipped, + })} + +
+ ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx new file mode 100644 index 0000000000..46bf92f888 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { renderWithThemeAndIntlProvider } from '../../../../../../tests/helpers'; +import { getMappableQuestions } from '../../importMappings'; +import { ImportMappingsDialog } from './ImportMappingsDialog'; + +const de = (id: string) => ({ + id, + name: `DE ${id}`, + valueType: 'NUMBER', + categoryOptionCombo: 'coc', + categoryOptionComboName: 'default', +}); + +const form = { id: 5, name: 'Form' }; +const mapping = { + mapping_type: 'AGGREGATE', + data_source: { id: 3, name: 'dhis2' }, +}; + +const mappingVersion = { + id: 1, + form_version: { id: 10, version_id: '2021111101', form }, + mapping, + question_mappings: { q1: de('a'), q2: de('b') }, + derivate_settings: { data_set_id: 'ds1' }, +}; + +const sources = [ + mappingVersion, + { + id: 2, + form_version: { id: 9, version_id: '2020100801', form }, + mapping, + question_mappings: { + q1: de('a'), + q2: de('c'), + q3: de('d'), + gone: de('e'), + }, + derivate_settings: { data_set_id: 'ds1' }, + updated_at: 1600000000, + }, + { + // other data source: never offered + id: 3, + form_version: { id: 8, version_id: '2019', form }, + mapping: { ...mapping, data_source: { id: 4, name: 'other' } }, + question_mappings: { q3: de('z') }, + derivate_settings: { data_set_id: 'ds1' }, + }, +]; + +vi.mock('../../hooks/requests/useGetMappingImportSources', () => ({ + useGetMappingImportSources: () => ({ data: sources, isLoading: false }), +})); + +const questions = getMappableQuestions({ + name: 'survey', + type: 'survey', + children: ['q1', 'q2', 'q3'].map(name => ({ + name, + type: 'integer', + label: `Label ${name}`, + })), +}); + +describe('ImportMappingsDialog', () => { + it('compares a previous version and applies the decisions', async () => { + const onApply = vi.fn().mockResolvedValue(undefined); + const closeDialog = vi.fn(); + renderWithThemeAndIntlProvider( + , + ); + + expect(screen.getByText('Version 2020100801')).toBeInTheDocument(); + expect(screen.queryByText('Version 2019')).not.toBeInTheDocument(); + expect( + screen.getByText('3 / 4 match this version'), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Compare')); + + expect(screen.getByText('Conflicts (1)')).toBeInTheDocument(); + expect(screen.getByText('To add (1)')).toBeInTheDocument(); + expect(screen.getByText('Identical (1)')).toBeInTheDocument(); + expect(screen.getByText('Dropped (1)')).toBeInTheDocument(); + + // conflicts are kept by default, only the addition is applied + expect(screen.getByText('Apply 1 changes')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Overwrite everywhere')); + fireEvent.click(screen.getByText('Apply 2 changes')); + + await waitFor(() => expect(closeDialog).toHaveBeenCalled()); + expect(onApply).toHaveBeenCalledWith( + expect.objectContaining({ + changes: { q2: de('c'), q3: de('d') }, + undo: { q2: de('b'), q3: { action: 'unmap' } }, + }), + ); + }); + + it('rejects a file of another mapping type', async () => { + renderWithThemeAndIntlProvider( + , + ); + const content = JSON.stringify({ + mapping_type: 'EVENT', + question_mappings: {}, + }); + const file = new File([content], 'export.json', { + type: 'application/json', + }); + // jsdom does not implement Blob.text() + Object.defineProperty(file, 'text', { + value: () => Promise.resolve(content), + }); + fireEvent.change( + document.querySelector('input[type="file"]') as HTMLInputElement, + { target: { files: [file] } }, + ); + expect( + await screen.findByText( + 'This export was made for another mapping type.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.tsx new file mode 100644 index 0000000000..571eec1361 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.tsx @@ -0,0 +1,253 @@ +import React, { FunctionComponent, useMemo, useState } from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import { SxStyles } from '../../../../types/general'; +import { useImportSources } from '../../hooks/useImportSources'; +import { + buildImportPlan, + computeMappingsDiff, + countMatchingMappings, + getDefaultDecision, + getImportableMappings, + MappingImportError, + parseMappingExport, +} from '../../importMappings'; +import MESSAGES from '../../messages'; +import { + Decision, + DiffKind, + ImportPlan, + ImportSource, + MappingVersionRow, +} from '../../types'; +import { CompareStep } from './CompareStep'; +import { SourceStep } from './SourceStep'; + +type Props = { + open: boolean; + closeDialog: () => void; + mappingVersion: MappingVersionRow; + questions: Record; + onApply: (plan: ImportPlan) => Promise; +}; + +const FILE_SOURCE_ID = 'file'; + +const IMPORT_ERROR_MESSAGES = { + invalidJson: MESSAGES.importInvalidJson, + invalidFormat: MESSAGES.importInvalidFormat, + mappingTypeMismatch: MESSAGES.importMappingTypeMismatch, +}; + +const styles: SxStyles = { + content: { position: 'relative', minHeight: 200 }, +}; + +export const ImportMappingsDialog: FunctionComponent = ({ + open, + closeDialog, + mappingVersion, + questions, + onApply, +}) => { + const { formatMessage } = useSafeIntl(); + const [step, setStep] = useState<1 | 2>(1); + const [selectedId, setSelectedId] = useState(); + const [fileSource, setFileSource] = useState(); + const [fileError, setFileError] = useState(); + const [bucket, setBucket] = useState('conflict'); + const [decisions, setDecisions] = useState< + Record + >({}); + const [isApplying, setIsApplying] = useState(false); + + const { sources: versionSources, isLoading } = useImportSources( + mappingVersion, + questions, + open, + ); + const sources = fileSource + ? [fileSource, ...versionSources] + : versionSources; + const selectedSource = sources.find(s => s.id === selectedId) ?? sources[0]; + + const rows = useMemo( + () => + selectedSource && step === 2 + ? computeMappingsDiff( + mappingVersion.question_mappings, + selectedSource.questionMappings, + questions, + ) + : [], + [selectedSource, step, mappingVersion.question_mappings, questions], + ); + const plan = useMemo( + () => buildImportPlan(rows, decisions), + [rows, decisions], + ); + const changesCount = Object.keys(plan.changes).length; + + const reset = () => { + setStep(1); + setSelectedId(undefined); + setFileSource(undefined); + setFileError(undefined); + setDecisions({}); + }; + const handleClose = () => { + reset(); + closeDialog(); + }; + + const onFileChosen = async (file: File) => { + setFileError(undefined); + try { + const content = parseMappingExport( + await file.text(), + mappingVersion.mapping.mapping_type, + ); + const questionMappings = getImportableMappings( + content.question_mappings, + ); + setFileSource({ + id: FILE_SOURCE_ID, + title: file.name, + meta: content.form + ? formatMessage(MESSAGES.importFileMeta, { + formName: content.form.name, + versionId: content.form_version?.version_id, + }) + : '', + mappingsCount: Object.keys(questionMappings).length, + matchingCount: countMatchingMappings( + questionMappings, + questions, + ), + questionMappings, + }); + setSelectedId(FILE_SOURCE_ID); + } catch (e) { + if (e instanceof MappingImportError) { + setFileError(formatMessage(IMPORT_ERROR_MESSAGES[e.reason])); + } else { + throw e; + } + } + }; + + const goToCompare = () => { + const diff = computeMappingsDiff( + mappingVersion.question_mappings, + selectedSource.questionMappings, + questions, + ); + setDecisions( + Object.fromEntries( + diff.map(row => [row.questionKey, getDefaultDecision(row)]), + ), + ); + const firstBucket = diff.some(row => row.kind === 'conflict') + ? 'conflict' + : 'add'; + setBucket(firstBucket); + setStep(2); + }; + + const apply = async () => { + setIsApplying(true); + try { + await onApply(plan); + handleClose(); + } finally { + setIsApplying(false); + } + }; + + const setDecision = (questionKey: string, decision: Decision) => + setDecisions(current => ({ ...current, [questionKey]: decision })); + const setBulkDecision = (kind: DiffKind, decision: Decision) => + setDecisions(current => { + const next = { ...current }; + rows.filter(row => row.kind === kind).forEach(row => { + next[row.questionKey] = decision; + }); + return next; + }); + + return ( + + {formatMessage(MESSAGES.importMappings)} + + {step === 1 && ( + + )} + {step === 2 && selectedSource && ( + + )} + + + + {step === 1 ? ( + + ) : ( + + )} + + + ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/MappingImportActions.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/MappingImportActions.tsx new file mode 100644 index 0000000000..9ac3137173 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/MappingImportActions.tsx @@ -0,0 +1,120 @@ +import React, { FunctionComponent, useState } from 'react'; +import DownloadIcon from '@mui/icons-material/Download'; +import InputIcon from '@mui/icons-material/Input'; +import UndoIcon from '@mui/icons-material/Undo'; +import { Box, Button } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import { openSnackBar } from '../../../../components/snackBars/EventDispatcher'; +import { succesfullSnackBar } from '../../../../constants/snackBars'; +import { SxStyles } from '../../../../types/general'; +import { useBulkUpdateQuestionMappings } from '../../hooks/requests/useBulkUpdateQuestionMappings'; +import { buildMappingExport, getExportFileName } from '../../importMappings'; +import MESSAGES from '../../messages'; +import { ImportPlan, MappingVersionRow } from '../../types'; +import { ImportMappingsDialog } from './ImportMappingsDialog'; + +type Props = { + mappingVersion: MappingVersionRow; + questions: Record; +}; + +const styles: SxStyles = { + actions: { display: 'flex', justifyContent: 'flex-end', gap: 1, mb: 2 }, +}; + +const downloadJson = (content: unknown, fileName: string) => { + const blob = new Blob([JSON.stringify(content, null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +}; + +export const MappingImportActions: FunctionComponent = ({ + mappingVersion, + questions, +}) => { + const { formatMessage } = useSafeIntl(); + const [open, setOpen] = useState(false); + // payload restoring the mappings as they were before the last import + const [undoPayload, setUndoPayload] = useState(); + const { mutateAsync: bulkUpdate, isLoading } = + useBulkUpdateQuestionMappings(); + + const onApply = async (plan: ImportPlan) => { + await bulkUpdate({ + mappingVersionId: mappingVersion.id, + questionMappings: plan.changes, + }); + setUndoPayload(plan.undo); + openSnackBar( + succesfullSnackBar( + undefined, + formatMessage(MESSAGES.importDone, { + added: plan.added, + overwritten: plan.overwritten, + kept: plan.kept, + ignored: plan.dropped + plan.skipped, + }), + ), + ); + }; + + const onUndo = async () => { + await bulkUpdate({ + mappingVersionId: mappingVersion.id, + questionMappings: undoPayload ?? {}, + }); + setUndoPayload(undefined); + openSnackBar( + succesfullSnackBar(undefined, formatMessage(MESSAGES.importUndone)), + ); + }; + + return ( + + {undoPayload && ( + + )} + + + setOpen(false)} + mappingVersion={mappingVersion} + questions={questions} + onApply={onApply} + /> + + ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/SourceCard.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/SourceCard.tsx new file mode 100644 index 0000000000..49a81e0390 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/SourceCard.tsx @@ -0,0 +1,90 @@ +import React, { FunctionComponent, KeyboardEvent } from 'react'; +import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; +import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; +import { Box, Chip, Typography } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import { SxStyles } from '../../../../types/general'; +import MESSAGES from '../../messages'; +import { ImportSource } from '../../types'; + +type Props = { + source: ImportSource; + selected: boolean; + onSelect: () => void; +}; + +const card = { + display: 'flex', + alignItems: 'center', + gap: 1.5, + p: 1.5, + mb: 1, + cursor: 'pointer', + border: 1, + borderRadius: 1, + borderColor: 'divider', + backgroundColor: 'transparent', +}; + +const styles: SxStyles = { + card, + selectedCard: { + ...card, + borderColor: 'primary.main', + backgroundColor: 'action.hover', + }, + text: { flex: '1 1 auto', minWidth: 0 }, +}; + +export const SourceCard: FunctionComponent = ({ + source, + selected, + onSelect, +}) => { + const { formatMessage } = useSafeIntl(); + const RadioIcon = selected + ? RadioButtonCheckedIcon + : RadioButtonUncheckedIcon; + const isGoodMatch = + source.mappingsCount > 0 && + source.matchingCount / source.mappingsCount >= 0.5; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(); + } + }; + return ( + + + + {source.title} + + {source.meta} + + + + {formatMessage(MESSAGES.importMappingsCount, { + count: source.mappingsCount, + })} + + + + ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/SourceStep.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/SourceStep.tsx new file mode 100644 index 0000000000..3cbd555f97 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/SourceStep.tsx @@ -0,0 +1,96 @@ +import React, { ChangeEvent, FunctionComponent, useRef } from 'react'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import { Alert, Box, Button, Divider, Typography } from '@mui/material'; +import { LoadingSpinner, useSafeIntl } from 'bluesquare-components'; +import { SxStyles } from '../../../../types/general'; +import MESSAGES from '../../messages'; +import { ImportSource } from '../../types'; +import { SourceCard } from './SourceCard'; + +type Props = { + sources: ImportSource[]; + selectedId?: string; + onSelect: (id: string) => void; + onFileChosen: (file: File) => void; + fileError?: string; + isLoading: boolean; +}; + +const styles: SxStyles = { + divider: { mt: 2 }, + fileRow: { display: 'flex', alignItems: 'center', gap: 1.5, pt: 2 }, + fileText: { flex: '1 1 auto' }, + fileError: { mt: 1.5 }, +}; + +export const SourceStep: FunctionComponent = ({ + sources, + selectedId, + onSelect, + onFileChosen, + fileError, + isLoading, +}) => { + const { formatMessage } = useSafeIntl(); + const fileInput = useRef(null); + const onFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + onFileChosen(file); + } + // allow choosing the same file again + event.target.value = ''; + }; + return ( + + + {formatMessage(MESSAGES.importPickSource)} + + {isLoading && } + {!isLoading && sources.length === 0 && ( + + {formatMessage(MESSAGES.importNoSource)} + + )} + {sources.map(source => ( + onSelect(source.id)} + /> + ))} + + + + {formatMessage(MESSAGES.importFromFile)} + + + + + {fileError && ( + + {fileError} + + )} + + ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/details.tsx b/hat/assets/js/apps/Iaso/domains/mappings/details.tsx index 17fcf00605..c575e7347b 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/details.tsx +++ b/hat/assets/js/apps/Iaso/domains/mappings/details.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Grid, Box } from '@mui/material'; import { makeStyles } from '@mui/styles'; @@ -14,6 +14,7 @@ import { baseUrls } from '../../constants/urls'; import { useParamsObject } from '../../routing/hooks/useParamsObject'; import DerivedQuestionMappingForm from './components/DerivedQuestionMappingForm'; import GeneraMappingInfo from './components/GeneraMappingInfo'; +import { MappingImportActions } from './components/ImportMappings/MappingImportActions'; import QuestionInfos from './components/QuestionInfos'; import QuestionMappingForm from './components/QuestionMappingForm'; import RecursiveTreeView from './components/RecursiveTreeView'; @@ -23,6 +24,7 @@ import { useApplyUpdate, useGetMappingVersionDetail, } from './hooks'; +import { getMappableQuestions } from './importMappings'; import MESSAGES from './messages'; const baseUrl = baseUrls.mappingDetail; @@ -73,6 +75,11 @@ const MappingDetails = () => { ? Descriptor.indexQuestions(currentFormVersion.descriptor) : {}; + const mappableQuestions = useMemo( + () => getMappableQuestions(currentFormVersion?.descriptor), + [currentFormVersion], + ); + const [currentQuestion, setCurrentQuestion] = useState( indexedQuestions[params.questionName], ); @@ -158,6 +165,12 @@ const MappingDetails = () => { {currentMappingVersion && ( + {currentFormVersion && isDataElementMappable && ( + + )} {currentFormVersion && currentMappingVersion && ( diff --git a/hat/assets/js/apps/Iaso/domains/mappings/hooks/requests/useBulkUpdateQuestionMappings.ts b/hat/assets/js/apps/Iaso/domains/mappings/hooks/requests/useBulkUpdateQuestionMappings.ts new file mode 100644 index 0000000000..833689edef --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/hooks/requests/useBulkUpdateQuestionMappings.ts @@ -0,0 +1,18 @@ +import { UseMutationResult } from 'react-query'; +import { patchRequest } from '../../../../libs/Api'; +import { useSnackMutation } from '../../../../libs/apiHooks'; +import { BulkUpdateVariables, MappingVersionRow } from '../../types'; + +export const useBulkUpdateQuestionMappings = (): UseMutationResult< + MappingVersionRow, + unknown, + BulkUpdateVariables +> => + useSnackMutation({ + mutationFn: ({ mappingVersionId, questionMappings }) => + patchRequest(`/api/mappingversions/${mappingVersionId}/`, { + question_mappings: questionMappings, + }), + invalidateQueryKey: ['mappingversions'], + showSuccessSnackBar: false, + }); diff --git a/hat/assets/js/apps/Iaso/domains/mappings/hooks/requests/useGetMappingImportSources.ts b/hat/assets/js/apps/Iaso/domains/mappings/hooks/requests/useGetMappingImportSources.ts new file mode 100644 index 0000000000..a509b6f6b1 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/hooks/requests/useGetMappingImportSources.ts @@ -0,0 +1,20 @@ +import { UseQueryResult } from 'react-query'; +import { getRequest } from '../../../../libs/Api'; +import { useSnackQuery } from '../../../../libs/apiHooks'; +import { MappingVersionRow } from '../../types'; + +const SOURCE_FIELDS = + 'id,form_version,mapping,question_mappings,derivate_settings,updated_at'; + +export const useGetMappingImportSources = ( + mappingType: string, + enabled: boolean, +): UseQueryResult => + useSnackQuery({ + queryKey: ['mappingversions', 'importSources', mappingType], + queryFn: () => + getRequest( + `/api/mappingversions/?mappingTypes=${mappingType}&fields=${SOURCE_FIELDS}`, + ).then(r => r.mapping_versions), + options: { enabled: Boolean(mappingType) && enabled }, + }); diff --git a/hat/assets/js/apps/Iaso/domains/mappings/hooks/useImportSources.ts b/hat/assets/js/apps/Iaso/domains/mappings/hooks/useImportSources.ts new file mode 100644 index 0000000000..ccd3bb03c7 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/hooks/useImportSources.ts @@ -0,0 +1,103 @@ +import { useMemo } from 'react'; +import { useSafeIntl } from 'bluesquare-components'; +import { DateTimeCell } from '../../../components/Cells/DateTimeCell'; +import { + countMatchingMappings, + getImportableMappings, +} from '../importMappings'; +import MESSAGES from '../messages'; +import { ImportSource, MappingVersionRow } from '../types'; +import { useGetMappingImportSources } from './requests/useGetMappingImportSources'; + +const sortByVersionDesc = (a: MappingVersionRow, b: MappingVersionRow) => + `${b.form_version.version_id}`.localeCompare( + `${a.form_version.version_id}`, + ); + +const isSameTarget = ( + candidate: MappingVersionRow, + mappingVersion: MappingVersionRow, +): boolean => { + const settings = mappingVersion.derivate_settings ?? {}; + const candidateSettings = candidate.derivate_settings ?? {}; + if (settings.data_set_id) { + return candidateSettings.data_set_id === settings.data_set_id; + } + return ( + Boolean(settings.program_id) && + candidateSettings.program_id === settings.program_id + ); +}; + +/** + * Other mapping versions of the same type and data source the wizard can + * import from: first the versions of the same form, then the other forms + * mapped to the same DHIS2 dataset or program. + */ +export const useImportSources = ( + mappingVersion: MappingVersionRow, + questions: Record, + enabled: boolean, +): { sources: ImportSource[]; isLoading: boolean } => { + const { formatMessage } = useSafeIntl(); + const { data, isLoading } = useGetMappingImportSources( + mappingVersion.mapping.mapping_type, + enabled, + ); + const sources = useMemo(() => { + const formId = mappingVersion.form_version.form.id; + const dataSourceId = mappingVersion.mapping.data_source.id; + const candidates = (data ?? []).filter( + candidate => + candidate.id !== mappingVersion.id && + candidate.mapping.data_source.id === dataSourceId, + ); + const toImportSource = ( + candidate: MappingVersionRow, + sameForm: boolean, + ): ImportSource => { + const questionMappings = getImportableMappings( + candidate.question_mappings, + ); + const date = DateTimeCell({ value: candidate.updated_at }); + const otherFormMeta = candidate.derivate_settings?.program_id + ? MESSAGES.importOtherFormProgramMeta + : MESSAGES.importOtherFormDatasetMeta; + return { + id: `${candidate.id}`, + title: sameForm + ? formatMessage(MESSAGES.importVersionTitle, { + versionId: candidate.form_version.version_id, + }) + : formatMessage(MESSAGES.importOtherFormTitle, { + formName: candidate.form_version.form.name, + versionId: candidate.form_version.version_id, + }), + meta: formatMessage( + sameForm ? MESSAGES.importSameFormMeta : otherFormMeta, + { date }, + ), + mappingsCount: Object.keys(questionMappings).length, + matchingCount: countMatchingMappings( + questionMappings, + questions, + ), + questionMappings, + }; + }; + const sameFormSources = candidates + .filter(c => c.form_version.form.id === formId) + .sort(sortByVersionDesc) + .map(c => toImportSource(c, true)); + const otherFormSources = candidates + .filter( + c => + c.form_version.form.id !== formId && + isSameTarget(c, mappingVersion), + ) + .sort(sortByVersionDesc) + .map(c => toImportSource(c, false)); + return [...sameFormSources, ...otherFormSources]; + }, [data, mappingVersion, questions, formatMessage]); + return { sources, isLoading }; +}; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/importMappings.test.ts b/hat/assets/js/apps/Iaso/domains/mappings/importMappings.test.ts new file mode 100644 index 0000000000..fb54a10d29 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/importMappings.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from 'vitest'; +import { + buildImportPlan, + buildMappingExport, + computeMappingsDiff, + getDefaultDecision, + getExportFileName, + getMappableQuestions, + getMappingLabel, + MappingImportError, + parseMappingExport, +} from './importMappings'; + +const descriptor = { + name: 'survey', + type: 'survey', + children: [ + { + name: 'grp1', + type: 'group', + label: 'Group 1', + children: [ + { name: 'q1', type: 'integer', label: 'Question 1' }, + { name: 'q2', type: 'integer', label: 'Question 2' }, + ], + }, + { name: 'q3', type: 'integer', label: 'Question 3' }, + { name: 'q4', type: 'integer', label: 'Question 4' }, + { name: 'q5', type: 'integer', label: 'Question 5' }, + ], +}; + +const de = (id: string, extra = {}) => ({ + id, + name: `DE ${id}`, + valueType: 'NUMBER', + categoryOptionCombo: 'coc1', + categoryOptionComboName: 'default', + ...extra, +}); + +describe('getMappableQuestions', () => { + it('keeps repeat groups, select all that apply and their choices', () => { + const questions = getMappableQuestions({ + name: 'survey', + type: 'survey', + children: [ + { + name: 'household', + type: 'repeat', + children: [{ name: 'age', type: 'integer' }], + }, + { + name: 'symptoms', + type: 'select all that apply', + children: [ + { name: 'fever', label: 'Fever' }, + { name: 'cough', label: 'Cough' }, + ], + }, + { + name: 'sex', + type: 'select one', + children: [{ name: 'male' }], + }, + ], + }); + expect(Object.keys(questions).sort()).toEqual([ + 'age', + 'household', + 'sex', + 'symptoms', + 'symptoms__cough', + 'symptoms__fever', + ]); + }); + + it('skips groups', () => { + expect(Object.keys(getMappableQuestions(descriptor)).sort()).toEqual([ + 'q1', + 'q2', + 'q3', + 'q4', + 'q5', + ]); + }); +}); + +describe('getMappingLabel', () => { + it('shows the combo when not default', () => { + expect(getMappingLabel(de('a'))).toBe('DE a'); + expect( + getMappingLabel(de('a', { categoryOptionComboName: 'Male' })), + ).toBe('DE a (Male)'); + }); + it('joins event tracker mappings', () => { + expect( + getMappingLabel([ + { dataElement: { id: 'x', name: 'X' } }, + { trackedEntityAttribute: { id: 'y', name: 'Y' } }, + ]), + ).toBe('X, Y'); + }); +}); + +describe('mapping shapes', () => { + const multiple = (fever: string) => ({ + type: 'multiple', + values: { fever: de(fever), cough: de('cough') }, + }); + const tracker = (stage: string) => [ + { + dataElement: { id: 'de1', name: 'Age' }, + programStage: stage, + parent: 'household', + }, + ]; + const repeat = (relationship: string) => [ + { + type: 'repeat', + program_id: 'p1', + program_name: 'Program', + tracked_entity_type: 'tet', + tracked_entity_identifier: 'uid', + relationship_type: relationship, + relationship_type_name: `Rel ${relationship}`, + }, + ]; + const questions = { + symptoms: { name: 'symptoms', type: 'select all that apply' }, + age: { name: 'age', type: 'integer' }, + household: { name: 'household', type: 'repeat' }, + empty: { name: 'empty', type: 'integer' }, + }; + + it('compares each shape on its DHIS2 target', () => { + const rows = computeMappingsDiff( + { + symptoms: multiple('a'), + age: tracker('s1'), + household: repeat('r1'), + empty: [], + }, + { + symptoms: multiple('b'), + age: [{ ...tracker('s1')[0], dataElement: { id: 'de1' } }], + household: repeat('r2'), + empty: de('x'), + }, + questions, + ); + expect( + Object.fromEntries(rows.map(r => [r.questionKey, r.kind])), + ).toEqual({ + // a "multiple" mapping has no id but is a real mapping + symptoms: 'conflict', + age: 'identical', + household: 'conflict', + // an empty list is not a mapping + empty: 'add', + }); + expect( + computeMappingsDiff( + { age: tracker('s1') }, + { age: tracker('s2') }, + questions, + )[0].kind, + ).toBe('conflict'); + }); + + it('labels each shape', () => { + expect(getMappingLabel(multiple('a'))).toBe( + 'fever: DE a, cough: DE cough', + ); + expect(getMappingLabel(repeat('r1'))).toBe('Program (Rel r1)'); + expect(getMappingLabel(tracker('s1'))).toBe('Age'); + expect( + getMappingLabel( + de('a', { + displayName: 'DE a - Male', + categoryOptionComboName: 'Male', + }), + ), + ).toBe('DE a - Male'); + }); +}); + +describe('computeMappingsDiff', () => { + const current = { + q1: de('a'), + q2: de('b'), + q4: { type: 'neverMapped' }, + }; + const incoming = { + q1: de('a', { name: 'renamed' }), + q2: de('c'), + q3: de('d'), + q4: de('e'), + q5: { type: 'neverMapped' }, + gone: de('f'), + }; + const rows = computeMappingsDiff( + current, + incoming, + getMappableQuestions(descriptor), + ); + const kinds = Object.fromEntries(rows.map(r => [r.questionKey, r.kind])); + + it('classifies each incoming mapping', () => { + expect(kinds).toEqual({ + q1: 'identical', + q2: 'conflict', + q3: 'add', + q4: 'dropped', + gone: 'dropped', + }); + expect(rows.find(r => r.questionKey === 'q4')?.neverMapped).toBe(true); + }); + + it('only imports choice keys of select all that apply questions', () => { + const choiceQuestions = getMappableQuestions({ + name: 'survey', + type: 'survey', + children: [ + { + name: 'symptoms', + type: 'select all that apply', + children: [{ name: 'fever' }], + }, + { + name: 'sex', + type: 'select one', + children: [{ name: 'male' }], + }, + ], + }); + const choiceRows = computeMappingsDiff( + {}, + { symptoms__fever: de('a'), sex__male: de('b') }, + choiceQuestions, + ); + expect( + Object.fromEntries(choiceRows.map(r => [r.questionKey, r.kind])), + ).toEqual({ symptoms__fever: 'add', sex__male: 'dropped' }); + }); + + it('builds the patch and undo payloads', () => { + const decisions = Object.fromEntries( + rows.map(r => [r.questionKey, getDefaultDecision(r, true)]), + ); + const plan = buildImportPlan(rows, decisions); + expect(plan.changes).toEqual({ q2: de('c'), q3: de('d') }); + expect(plan.undo).toEqual({ q2: de('b'), q3: { action: 'unmap' } }); + expect(plan).toMatchObject({ + added: 1, + overwritten: 1, + kept: 0, + skipped: 0, + dropped: 2, + }); + }); + + it('keeps current mappings by default', () => { + const decisions = Object.fromEntries( + rows.map(r => [r.questionKey, getDefaultDecision(r)]), + ); + const plan = buildImportPlan(rows, decisions); + expect(Object.keys(plan.changes)).toEqual(['q3']); + expect(plan.kept).toBe(1); + }); +}); + +describe('export / import', () => { + const mappingVersion = { + id: 1, + form_version: { + id: 10, + version_id: '2021111101', + form: { id: 5, name: "Cadre de l'ECZS" }, + }, + mapping: { + mapping_type: 'AGGREGATE', + data_source: { id: 3, name: 'dhis2' }, + }, + question_mappings: { q1: de('a'), q2: { type: 'neverMapped' } }, + derivate_settings: { data_set_id: 'ds1', data_set_name: 'DS 1' }, + }; + + it('exports without never mapped markers', () => { + const exported = buildMappingExport( + mappingVersion, + new Date('2026-01-01T00:00:00Z'), + ); + expect(exported.question_mappings).toEqual({ q1: de('a') }); + expect(exported.dataset).toEqual({ id: 'ds1', name: 'DS 1' }); + expect(exported.program).toBeUndefined(); + expect(getExportFileName(mappingVersion)).toBe( + 'mapping-cadre-de-l-eczs-2021111101-aggregate.json', + ); + }); + + it('round trips', () => { + const text = JSON.stringify(buildMappingExport(mappingVersion)); + expect(parseMappingExport(text, 'AGGREGATE').question_mappings).toEqual( + { q1: de('a') }, + ); + }); + + it('accepts a bare question_mappings dictionary', () => { + expect( + parseMappingExport(JSON.stringify({ q1: de('a') }), 'AGGREGATE') + .question_mappings, + ).toEqual({ q1: de('a') }); + }); + + it.each([ + ['not json', 'invalidJson'], + ['[]', 'invalidFormat'], + ['{"q1": 3}', 'invalidFormat'], + ['{"q1": [3]}', 'invalidFormat'], + [ + '{"mapping_type": "EVENT", "question_mappings": {}}', + 'mappingTypeMismatch', + ], + ])('rejects %s', (text, reason) => { + // the error message is the reason + expect(() => parseMappingExport(text, 'AGGREGATE')).toThrow( + new MappingImportError(reason as any), + ); + }); +}); diff --git a/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts b/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts new file mode 100644 index 0000000000..1186d2d3a7 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts @@ -0,0 +1,341 @@ +import { isEqual } from 'lodash'; +import Descriptor from './descriptor'; +import { isNeverMapped } from './question_mappings'; +import { + Decision, + DiffKind, + DiffRow, + ImportPlan, + MappingExport, + QuestionMapping, + QuestionMappings, +} from './types'; + +export const DIFF_KINDS: DiffKind[] = [ + 'conflict', + 'add', + 'identical', + 'dropped', +]; + +export const EXPORT_FORMAT = 'iaso-mapping-export'; +export const EXPORT_FORMAT_VERSION = 1; + +/* + * question_mappings values come in several shapes: + * - AGGREGATE / EVENT data element: { id, valueType, categoryOptionCombo, ... } + * - select all that apply as a whole: { type: 'multiple', values: { choice: { id, ... } } } + * or one boolean data element per choice, keyed `question__choice` + * - EVENT_TRACKER: a list [{ dataElement | trackedEntityAttribute, programStage, iaso_field, parent }] + * - EVENT_TRACKER repeat group: [{ type: 'repeat', program_id, relationship_type, tracked_entity_identifier, ... }] + * - never mapped marker: { type: 'neverMapped' } + */ + +// Same rule as the backend copy_mappings_from_previous_version: everything +// but survey and plain groups can carry a mapping (repeat groups and select +// all that apply questions included). +const NOT_MAPPABLE_TYPES = ['survey', 'group']; + +/** + * Questions of a form version that can be mapped, indexed by the key used in + * question_mappings. + */ +export const getMappableQuestions = ( + descriptor: Record | undefined, +): Record => { + const result: Record = {}; + Object.values(Descriptor.indexQuestions(descriptor)).forEach( + (question: any) => { + if (!NOT_MAPPABLE_TYPES.includes(question.type)) { + result[Descriptor.getKey(question)] = question; + } + }, + ); + return result; +}; + +/** A mapping pointing to something in DHIS2 (not a never mapped marker). */ +export const hasTarget = (mapping?: QuestionMapping): boolean => { + if (!mapping || isNeverMapped(mapping)) { + return false; + } + return Array.isArray(mapping) ? mapping.length > 0 : true; +}; + +/** Mappings that carry a target, i.e. no "never mapped" markers. */ +export const getImportableMappings = ( + questionMappings: QuestionMappings | undefined, +): QuestionMappings => { + const result: QuestionMappings = {}; + Object.entries(questionMappings ?? {}).forEach(([key, mapping]) => { + if (hasTarget(mapping)) { + result[key] = mapping; + } + }); + return result; +}; + +const mappingItemLabel = (mapping: Record): string => { + if (mapping.type === 'multiple') { + return Object.entries(mapping.values ?? {}) + .map( + ([choice, target]: [string, any]) => + `${choice}: ${mappingItemLabel(target)}`, + ) + .join(', '); + } + if (mapping.type === 'repeat') { + const program = mapping.program_name ?? mapping.program_id; + return mapping.relationship_type_name + ? `${program} (${mapping.relationship_type_name})` + : program; + } + const name = + mapping.displayName ?? + mapping.name ?? + mapping.dataElement?.name ?? + mapping.trackedEntityAttribute?.name ?? + mapping.id ?? + ''; + const comboName = mapping.categoryOptionComboName; + return comboName && comboName !== 'default' && !name.includes(comboName) + ? `${name} (${comboName})` + : name; +}; + +export const getMappingLabel = (mapping?: QuestionMapping): string => { + if (!mapping) { + return ''; + } + if (Array.isArray(mapping)) { + return mapping.map(mappingItemLabel).join(', '); + } + return mappingItemLabel(mapping); +}; + +// Fields identifying the DHIS2 target of a mapping, ignoring the cached +// metadata (names, option sets, datasets, ...). +const mappingTargetKey = ( + mapping: Record, +): Record => ({ + type: mapping.type, + id: mapping.id, + categoryOptionCombo: mapping.categoryOptionCombo, + dataElement: mapping.dataElement?.id, + trackedEntityAttribute: mapping.trackedEntityAttribute?.id, + programStage: mapping.programStage, + iaso_field: mapping.iaso_field, + parent: mapping.parent, + program_id: mapping.program_id, + tracked_entity_type: mapping.tracked_entity_type, + tracked_entity_identifier: mapping.tracked_entity_identifier, + relationship_type: mapping.relationship_type, + values: mapping.values + ? Object.fromEntries( + Object.entries(mapping.values).map( + ([choice, target]: [string, any]) => [ + choice, + mappingTargetKey(target), + ], + ), + ) + : undefined, +}); + +/** + * Two mappings are identical when they point to the same DHIS2 target, even + * if the cached metadata (names, datasets, ...) differs. + */ +export const isSameMapping = (a: QuestionMapping, b: QuestionMapping) => { + if (Array.isArray(a) !== Array.isArray(b)) { + return false; + } + if (Array.isArray(a) && Array.isArray(b)) { + return isEqual(a.map(mappingTargetKey), b.map(mappingTargetKey)); + } + return isEqual( + mappingTargetKey(a as Record), + mappingTargetKey(b as Record), + ); +}; + +export const computeMappingsDiff = ( + currentMappings: QuestionMappings, + incomingMappings: QuestionMappings, + questions: Record, +): DiffRow[] => + Object.entries(getImportableMappings(incomingMappings)).map( + ([questionKey, incoming]) => { + const question = questions[questionKey]; + const current = currentMappings[questionKey]; + const row: DiffRow = { + kind: 'add', + questionKey, + questionLabel: question + ? Descriptor.getHumanLabel(question) + : undefined, + current: hasTarget(current) ? current : undefined, + incoming, + }; + if (!question) { + row.kind = 'dropped'; + } else if (isNeverMapped(current)) { + row.kind = 'dropped'; + row.neverMapped = true; + } else if (row.current) { + row.kind = isSameMapping(row.current, incoming) + ? 'identical' + : 'conflict'; + } + return row; + }, + ); + +export const countMatchingMappings = ( + questionMappings: QuestionMappings, + questions: Record, +): number => Object.keys(questionMappings).filter(key => questions[key]).length; + +export const getDefaultDecision = ( + row: DiffRow, + overwriteConflicts = false, +): Decision | undefined => { + if (row.kind === 'conflict') { + return overwriteConflicts ? 'overwrite' : 'keep'; + } + if (row.kind === 'add') { + return 'apply'; + } + return undefined; +}; + +export const willApply = (decision?: Decision) => + decision === 'apply' || decision === 'overwrite'; + +export const buildImportPlan = ( + rows: DiffRow[], + decisions: Record, +): ImportPlan => { + const plan: ImportPlan = { + changes: {}, + undo: {}, + added: 0, + overwritten: 0, + kept: 0, + skipped: 0, + dropped: 0, + }; + rows.forEach(row => { + const decision = decisions[row.questionKey]; + if (row.kind === 'dropped') { + plan.dropped += 1; + } else if (row.kind === 'conflict' && decision === 'overwrite') { + plan.changes[row.questionKey] = row.incoming; + plan.undo[row.questionKey] = row.current as QuestionMapping; + plan.overwritten += 1; + } else if (row.kind === 'conflict') { + plan.kept += 1; + } else if (row.kind === 'add' && decision === 'apply') { + plan.changes[row.questionKey] = row.incoming; + plan.undo[row.questionKey] = { action: 'unmap' }; + plan.added += 1; + } else if (row.kind === 'add') { + plan.skipped += 1; + } + }); + return plan; +}; + +export const buildMappingExport = ( + mappingVersion: Record, + now: Date = new Date(), +): MappingExport => { + const { mapping, form_version: formVersion } = mappingVersion; + const settings = mappingVersion.derivate_settings ?? {}; + const result: MappingExport = { + format: EXPORT_FORMAT, + format_version: EXPORT_FORMAT_VERSION, + exported_at: now.toISOString(), + mapping_type: mapping.mapping_type, + data_source: mapping.data_source, + form: { id: formVersion.form.id, name: formVersion.form.name }, + form_version: { + id: formVersion.id, + version_id: formVersion.version_id, + }, + question_mappings: getImportableMappings( + mappingVersion.question_mappings, + ), + }; + if (settings.data_set_id) { + result.dataset = { + id: settings.data_set_id, + name: settings.data_set_name, + }; + } + if (settings.program_id) { + result.program = { + id: settings.program_id, + name: settings.program_name, + }; + } + return result; +}; + +export const getExportFileName = (mappingVersion: Record) => { + const { form_version: formVersion, mapping } = mappingVersion; + const slug = + `${formVersion.form.name}-${formVersion.version_id}-${mapping.mapping_type}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + return `mapping-${slug}.json`; +}; + +const isObject = (value: unknown): boolean => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const isValidMapping = (mapping: unknown): boolean => + Array.isArray(mapping) ? mapping.every(isObject) : isObject(mapping); + +export class MappingImportError extends Error { + constructor( + public reason: 'invalidJson' | 'invalidFormat' | 'mappingTypeMismatch', + ) { + super(reason); + } +} + +/** + * Accepts an export produced by buildMappingExport, or a bare + * question_mappings dictionary. + */ +export const parseMappingExport = ( + text: string, + expectedMappingType: string, +): Partial & { question_mappings: QuestionMappings } => { + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new MappingImportError('invalidJson'); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new MappingImportError('invalidFormat'); + } + const content = + 'question_mappings' in parsed ? parsed : { question_mappings: parsed }; + const { question_mappings: questionMappings } = content; + if ( + !questionMappings || + typeof questionMappings !== 'object' || + Array.isArray(questionMappings) || + Object.values(questionMappings).some(m => !isValidMapping(m)) + ) { + throw new MappingImportError('invalidFormat'); + } + if (content.mapping_type && content.mapping_type !== expectedMappingType) { + throw new MappingImportError('mappingTypeMismatch'); + } + return content; +}; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/messages.js b/hat/assets/js/apps/Iaso/domains/mappings/messages.js index 7f188f4187..bedb7dc85c 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/messages.js +++ b/hat/assets/js/apps/Iaso/domains/mappings/messages.js @@ -233,6 +233,201 @@ const MESSAGES = defineMessages({ id: 'iaso.mappings.startTypingFormVersion', defaultMessage: "Start typing to search by form's name or version id", }, + importMappings: { + id: 'iaso.mappings.import.title', + defaultMessage: 'Import mappings', + }, + exportMappings: { + id: 'iaso.mappings.export', + defaultMessage: 'Export mappings', + }, + undoImport: { + id: 'iaso.mappings.import.undo', + defaultMessage: 'Undo import', + }, + importUndone: { + id: 'iaso.mappings.import.undone', + defaultMessage: 'Import undone', + }, + importPickSource: { + id: 'iaso.mappings.import.pickSource', + defaultMessage: + 'Pick the mappings to reuse. Nothing is applied until you confirm.', + }, + importVersionTitle: { + id: 'iaso.mappings.import.versionTitle', + defaultMessage: 'Version {versionId}', + }, + importOtherFormTitle: { + id: 'iaso.mappings.import.otherFormTitle', + defaultMessage: '{formName} - {versionId}', + }, + importSameFormMeta: { + id: 'iaso.mappings.import.sameFormMeta', + defaultMessage: 'Same form, last updated {date}', + }, + importOtherFormDatasetMeta: { + id: 'iaso.mappings.import.otherFormDatasetMeta', + defaultMessage: 'Another form, same DHIS2 dataset, last updated {date}', + }, + importOtherFormProgramMeta: { + id: 'iaso.mappings.import.otherFormProgramMeta', + defaultMessage: 'Another form, same DHIS2 program, last updated {date}', + }, + importFileMeta: { + id: 'iaso.mappings.import.fileMeta', + defaultMessage: 'Exported from {formName} - {versionId}', + }, + importMappingsCount: { + id: 'iaso.mappings.import.mappingsCount', + defaultMessage: '{count} mappings', + }, + importMatchCount: { + id: 'iaso.mappings.import.matchCount', + defaultMessage: '{matching} / {total} match this version', + }, + importNoSource: { + id: 'iaso.mappings.import.noSource', + defaultMessage: + 'No other mapping version uses this data source with the same DHIS2 dataset or program.', + }, + importFromFile: { + id: 'iaso.mappings.import.fromFile', + defaultMessage: + 'Or import a mapping export (.json), for example from another account.', + }, + chooseFile: { + id: 'iaso.mappings.import.chooseFile', + defaultMessage: 'Choose a file', + }, + importInvalidJson: { + id: 'iaso.mappings.import.invalidJson', + defaultMessage: 'This file is not valid JSON.', + }, + importInvalidFormat: { + id: 'iaso.mappings.import.invalidFormat', + defaultMessage: 'This file is not a mapping export.', + }, + importMappingTypeMismatch: { + id: 'iaso.mappings.import.mappingTypeMismatch', + defaultMessage: 'This export was made for another mapping type.', + }, + compare: { + id: 'iaso.label.compare', + defaultMessage: 'Compare', + }, + back: { + id: 'iaso.label.back', + defaultMessage: 'Back', + }, + applyChanges: { + id: 'iaso.mappings.import.applyChanges', + defaultMessage: 'Apply {count} changes', + }, + compareLabel: { + id: 'iaso.mappings.import.compareLabel', + defaultMessage: + 'Comparing {source} with version {versionId}. Nothing is applied until you confirm.', + }, + bucketConflict: { + id: 'iaso.mappings.import.bucket.conflict', + defaultMessage: 'Conflicts ({count})', + }, + bucketAdd: { + id: 'iaso.mappings.import.bucket.add', + defaultMessage: 'To add ({count})', + }, + bucketIdentical: { + id: 'iaso.mappings.import.bucket.identical', + defaultMessage: 'Identical ({count})', + }, + bucketDropped: { + id: 'iaso.mappings.import.bucket.dropped', + defaultMessage: 'Dropped ({count})', + }, + resolveAllConflicts: { + id: 'iaso.mappings.import.resolveAllConflicts', + defaultMessage: 'Resolve all conflicts:', + }, + keepEverywhere: { + id: 'iaso.mappings.import.keepEverywhere', + defaultMessage: 'Keep current everywhere', + }, + overwriteEverywhere: { + id: 'iaso.mappings.import.overwriteEverywhere', + defaultMessage: 'Overwrite everywhere', + }, + allAdditions: { + id: 'iaso.mappings.import.allAdditions', + defaultMessage: 'All additions:', + }, + addAll: { + id: 'iaso.mappings.import.addAll', + defaultMessage: 'Add all', + }, + skipAll: { + id: 'iaso.mappings.import.skipAll', + defaultMessage: 'Skip all', + }, + identicalHint: { + id: 'iaso.mappings.import.identicalHint', + defaultMessage: 'Same DHIS2 target on both sides. Nothing to resolve.', + }, + droppedHint: { + id: 'iaso.mappings.import.droppedHint', + defaultMessage: + 'These mappings cannot be imported: the question does not exist in this version or is marked as never mapped.', + }, + question: { + id: 'iaso.label.question', + defaultMessage: 'Question', + }, + incomingMapping: { + id: 'iaso.mappings.import.incomingMapping', + defaultMessage: 'Incoming mapping', + }, + decision: { + id: 'iaso.mappings.import.decision', + defaultMessage: 'Decision', + }, + keep: { + id: 'iaso.mappings.import.keep', + defaultMessage: 'Keep', + }, + overwrite: { + id: 'iaso.mappings.import.overwrite', + defaultMessage: 'Overwrite', + }, + skip: { + id: 'iaso.mappings.import.skip', + defaultMessage: 'Skip', + }, + noChange: { + id: 'iaso.mappings.import.noChange', + defaultMessage: 'No change', + }, + notImportable: { + id: 'iaso.mappings.import.notImportable', + defaultMessage: 'Not importable', + }, + questionAbsent: { + id: 'iaso.mappings.import.questionAbsent', + defaultMessage: 'Question absent from version {versionId}', + }, + markedNeverMapped: { + id: 'iaso.mappings.import.markedNeverMapped', + defaultMessage: 'Marked as never mapped', + }, + importSummary: { + id: 'iaso.mappings.import.summary', + defaultMessage: + '{added} to add, {overwritten} to overwrite, {ignored} not imported', + }, + importDone: { + id: 'iaso.mappings.import.done', + defaultMessage: + '{added} mappings added, {overwritten} overwritten, {kept} kept, {ignored} not imported', + }, }); export default MESSAGES; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/types.ts b/hat/assets/js/apps/Iaso/domains/mappings/types.ts new file mode 100644 index 0000000000..a800205911 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/mappings/types.ts @@ -0,0 +1,60 @@ +// mapping versions come from the untyped /api/mappingversions/ serializer +export type MappingVersionRow = Record; + +// See importMappings.ts for the shapes a question mapping can take +export type QuestionMapping = Record | Record[]; +export type QuestionMappings = Record; + +export type DiffKind = 'conflict' | 'add' | 'identical' | 'dropped'; +export type Decision = 'keep' | 'overwrite' | 'apply' | 'skip'; + +export type DiffRow = { + kind: DiffKind; + questionKey: string; + questionLabel?: string; + current?: QuestionMapping; + incoming: QuestionMapping; + // dropped rows: the question is absent from the current version, + // or marked as never mapped + neverMapped?: boolean; +}; + +export type ImportPlan = { + // payload for PATCH question_mappings + changes: QuestionMappings; + // payload restoring the state before the import + undo: Record; + added: number; + overwritten: number; + kept: number; + skipped: number; + dropped: number; +}; + +export type MappingExport = { + format: string; + format_version: number; + exported_at: string; + mapping_type: string; + data_source: { id: number; name: string }; + form: { id: number; name: string }; + form_version: { id: number; version_id: string }; + dataset?: { id?: string; name?: string }; + program?: { id?: string; name?: string }; + question_mappings: QuestionMappings; +}; + +// a mapping version, or an uploaded export, the wizard can import from +export type ImportSource = { + id: string; + title: string; + meta: string; + mappingsCount: number; + matchingCount: number; + questionMappings: QuestionMappings; +}; + +export type BulkUpdateVariables = { + mappingVersionId: number; + questionMappings: Record; +}; diff --git a/iaso/api/mapping_versions.py b/iaso/api/mapping_versions.py index 0ad2c2d29d..38bfc77475 100644 --- a/iaso/api/mapping_versions.py +++ b/iaso/api/mapping_versions.py @@ -143,9 +143,16 @@ def create(self, validated_data: typing.MutableMapping): def update(self, instance, validated_data): # partial update only question mappings if "question_mappings" in validated_data: + # empty when the form version has no descriptor: nothing to check against + mappable_questions = instance.form_version.mappable_questions_by_name() for question_name, data_element in validated_data["question_mappings"].items(): path = "question_mappings." + question_name + # unmapping stays allowed, to clean up mappings of questions removed from the form + is_unmap = isinstance(data_element, dict) and data_element.get("action") == "unmap" + if mappable_questions and not is_unmap and question_name not in mappable_questions: + raise serializers.ValidationError({path: "question does not exist in this form version"}) + if type(data_element) is list: instance.json["question_mappings"][question_name] = data_element else: diff --git a/iaso/dhis2/form_mapping.py b/iaso/dhis2/form_mapping.py index e11dae1477..0e0b554880 100644 --- a/iaso/dhis2/form_mapping.py +++ b/iaso/dhis2/form_mapping.py @@ -42,11 +42,13 @@ def copy_mappings_from_previous_version(form_version, previous_form_version): mapping_version.form_version = form_version questions_by_name = form_version.questions_by_name() + # unlike questions_by_name, also has the questions inside repeat groups + mappable_questions = form_version.mappable_questions_by_name() if "question_mappings" in mapping_version.json: filtered_question_mappings = {} for k, v in mapping_version.json["question_mappings"].items(): - if k in questions_by_name: + if k in mappable_questions: # preserve all question mappings except the questions that don't exist in the newer version filtered_question_mappings[k] = v elif "__" in k: diff --git a/iaso/models/forms.py b/iaso/models/forms.py index 31013b1b4f..d97a5cae7b 100644 --- a/iaso/models/forms.py +++ b/iaso/models/forms.py @@ -357,6 +357,9 @@ def get_or_save_form_descriptor(self): # TODO: remove me - should be populated def questions_by_name(self): return parsing.to_questions_by_name(self.get_or_save_form_descriptor()) + def mappable_questions_by_name(self): + return parsing.to_mappable_questions_by_name(self.get_or_save_form_descriptor()) + def repeat_groups(self): questions = self.questions_by_name() repeats = [] diff --git a/iaso/odk/__init__.py b/iaso/odk/__init__.py index 0eb724738b..2bf1f4716d 100644 --- a/iaso/odk/__init__.py +++ b/iaso/odk/__init__.py @@ -1,4 +1,4 @@ -from .parsing import ParsingError, Survey, parse_xls_form, to_questions_by_name +from .parsing import ParsingError, Survey, parse_xls_form, to_mappable_questions_by_name, to_questions_by_name from .validator import validate_xls_form @@ -6,6 +6,7 @@ "ParsingError", "Survey", "parse_xls_form", + "to_mappable_questions_by_name", "to_questions_by_name", "validate_xls_form", ] diff --git a/iaso/odk/parsing.py b/iaso/odk/parsing.py index ad86ad6972..e87de51bdb 100644 --- a/iaso/odk/parsing.py +++ b/iaso/odk/parsing.py @@ -95,6 +95,41 @@ def to_questions_by_name(form_descriptor): return questions_by_name +NOT_MAPPABLE_TYPES = ("survey", "group") +SELECT_MULTIPLE_TYPE = "select all that apply" + + +def _choices(node, form_descriptor): + if "children" in node: + return node["children"] + # recent pyxform versions only reference the choices list + return form_descriptor.get("choices", {}).get(node.get("itemset"), []) + + +def visit_mappable(node, form_descriptor, mappable_questions): + if node.get("type") not in NOT_MAPPABLE_TYPES and "name" in node: + mappable_questions[node["name"]] = node + if node.get("type") == SELECT_MULTIPLE_TYPE: + # one boolean data element per choice + for choice in _choices(node, form_descriptor): + mappable_questions[f"{node['name']}__{choice['name']}"] = choice + return + + if node.get("type") in (*NOT_MAPPABLE_TYPES, "repeat"): + for child in node.get("children", []): + visit_mappable(child, form_descriptor, mappable_questions) + + +def to_mappable_questions_by_name(form_descriptor): + """Keys a question_mappings entry can use: like to_questions_by_name, plus the questions inside repeat + groups and the `question__choice` keys of the select all that apply questions.""" + mappable_questions = {} + if not form_descriptor: + return mappable_questions + visit_mappable(form_descriptor, form_descriptor, mappable_questions) + return mappable_questions + + def visit_by_path(node, questions_by_name, current_path): parent = node.get("type", None) is not None and (node["type"] == "survey" or node["type"] == "group") diff --git a/iaso/tests/api/test_mappingversions.py b/iaso/tests/api/test_mappingversions.py index 8b9df57521..6445cd88c0 100644 --- a/iaso/tests/api/test_mappingversions.py +++ b/iaso/tests/api/test_mappingversions.py @@ -331,6 +331,170 @@ def test_mappingversions_list_filters_project_ids(self): resp = self.client.get(f"/api/mappingversions/?projectsIds={other_project.id},{self.project.id}") self.assertEqual(len(resp.json()["mapping_versions"]), 1) + def test_mappingversions_bulk_patch_and_undo(self): + """PATCH /mappingversions/: several question mappings of every shape at once, as the import wizard + does, then the payload the wizard sends to undo that import""" + + self.client.force_authenticate(self.yoda) + form_version = self.create_form_version() + mapping_version_id = self.create_mapping_version(form_version, self.sw_source)["id"] + + original = { + "question_1": {"id": "de1", "valueType": "NUMBER", "categoryOptionCombo": "coc1"}, + "question_2": {"id": "de2", "valueType": "NUMBER", "categoryOptionCombo": "coc1"}, + "question_6": {"type": "neverMapped"}, + } + response = self.patch_question_mappings(mapping_version_id, original) + self.assertJSONResponse(response, status.HTTP_200_OK) + + imported = { + # overwrite + "question_2": {"id": "de2bis", "valueType": "INTEGER", "categoryOptionCombo": "coc2"}, + # select all that apply, no data element id at the top level + "question_3": {"type": "multiple", "values": {"a": {"id": "de3a", "valueType": "BOOLEAN"}}}, + # event tracker question inside a repeat group + "question_4": [{"dataElement": {"id": "de4"}, "programStage": "stage1", "parent": "question_5"}], + # event tracker repeat group + "question_5": [ + { + "type": "repeat", + "program_id": "program1", + "tracked_entity_type": "tet1", + "tracked_entity_identifier": "uid", + "relationship_type": "rel1", + } + ], + } + response = self.patch_question_mappings(mapping_version_id, imported) + self.assertJSONResponse(response, status.HTTP_200_OK) + self.assertEqual(self.get_question_mappings(mapping_version_id), {**original, **imported}) + + undo = { + "question_2": original["question_2"], + "question_3": {"action": "unmap"}, + "question_4": {"action": "unmap"}, + "question_5": {"action": "unmap"}, + } + response = self.patch_question_mappings(mapping_version_id, undo) + self.assertJSONResponse(response, status.HTTP_200_OK) + self.assertEqual(self.get_question_mappings(mapping_version_id), original) + + def test_mappingversions_bulk_patch_invalid_entry_saves_nothing(self): + """PATCH /mappingversions/: one invalid question mapping rejects the whole import""" + + self.client.force_authenticate(self.yoda) + form_version = self.create_form_version() + mapping_version_id = self.create_mapping_version(form_version, self.sw_source)["id"] + original = {"question_1": {"id": "de1", "valueType": "NUMBER"}} + self.patch_question_mappings(mapping_version_id, original) + + response = self.patch_question_mappings( + mapping_version_id, + { + "question_1": {"action": "unmap"}, + "question_2": {"id": "de2", "valueType": "NUMBER"}, + "question_3": {"id": "de3"}, + }, + ) + + self.assertJSONResponse(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), {"question_mappings.question_3": "should have a valueType"}) + self.assertEqual(self.get_question_mappings(mapping_version_id), original) + + def test_mappingversions_list_import_sources_fields(self): + """GET /mappingversions/ with the fields the import wizard needs to list its sources""" + + self.client.force_authenticate(self.yoda) + form_version = self.create_form_version() + mapping_version_id = self.create_mapping_version(form_version, self.sw_source)["id"] + question_mappings = {"question_1": {"id": "de1", "valueType": "NUMBER"}} + self.patch_question_mappings(mapping_version_id, question_mappings) + + response = self.client.get( + "/api/mappingversions/", + { + "mappingTypes": "AGGREGATE", + "fields": "id,form_version,mapping,question_mappings,derivate_settings,updated_at", + }, + ) + + mapping_versions = self.assertJSONResponse(response, status.HTTP_200_OK)["mapping_versions"] + self.assertEqual(len(mapping_versions), 1) + mapping_version = mapping_versions[0] + self.assertEqual( + set(mapping_version.keys()), + {"id", "form_version", "mapping", "question_mappings", "derivate_settings", "updated_at"}, + ) + self.assertEqual(mapping_version["question_mappings"], question_mappings) + self.assertEqual(mapping_version["derivate_settings"]["data_set_id"], "ERTFDG") + self.assertEqual(mapping_version["mapping"]["data_source"]["id"], self.sw_source.id) + self.assertEqual(mapping_version["form_version"]["form"]["id"], form_version.form_id) + + def test_mappingversions_patch_checks_questions_exist(self): + """PATCH /mappingversions/: question mappings must target a question of the form version""" + + self.client.force_authenticate(self.yoda) + form_version = self.create_form_version() + form_version.form_descriptor = { + "name": "data", + "type": "survey", + "children": [ + {"name": "weight", "type": "decimal"}, + {"name": "household", "type": "repeat", "children": [{"name": "age", "type": "integer"}]}, + {"name": "symptoms", "type": "select all that apply", "children": [{"name": "fever"}]}, + {"name": "sex", "type": "select one", "children": [{"name": "male"}]}, + ], + } + form_version.save() + mapping_version_id = self.create_mapping_version(form_version, self.sw_source)["id"] + data_element = {"id": "de1", "valueType": "NUMBER"} + + response = self.patch_question_mappings(mapping_version_id, {"unknown": data_element}) + self.assertJSONResponse(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), {"question_mappings.unknown": "question does not exist in this form version"}) + + response = self.patch_question_mappings(mapping_version_id, {"unknown": {"type": "neverMapped"}}) + self.assertJSONResponse(response, status.HTTP_400_BAD_REQUEST) + + # choice keys only exist for select all that apply questions (one boolean data element per choice), + # even though copy_mappings_from_previous_version still copies the select one ones + response = self.patch_question_mappings(mapping_version_id, {"sex__male": data_element}) + self.assertJSONResponse(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), {"question_mappings.sex__male": "question does not exist in this form version"} + ) + + valid = { + "weight": data_element, + "household": [{"type": "repeat", "program_id": "p1"}], + # inside the repeat group + "age": [{"dataElement": {"id": "de2"}, "programStage": "s1", "parent": "household"}], + "symptoms": {"type": "multiple", "values": {"fever": data_element}}, + "symptoms__fever": data_element, + } + response = self.patch_question_mappings(mapping_version_id, valid) + self.assertJSONResponse(response, status.HTTP_200_OK) + self.assertEqual(self.get_question_mappings(mapping_version_id), valid) + + # a mapping left over for a question removed from the form can still be unmapped + mapping_version = m.MappingVersion.objects.get(id=mapping_version_id) + mapping_version.json["question_mappings"]["removed_question"] = data_element + mapping_version.save() + response = self.patch_question_mappings(mapping_version_id, {"removed_question": {"action": "unmap"}}) + self.assertJSONResponse(response, status.HTTP_200_OK) + self.assertEqual(self.get_question_mappings(mapping_version_id), valid) + + def patch_question_mappings(self, mapping_version_id, question_mappings): + return self.client.patch( + f"/api/mappingversions/{mapping_version_id}/", + data={"question_mappings": question_mappings}, + format="json", + headers={"accept": "application/json"}, + ) + + def get_question_mappings(self, mapping_version_id): + return self.client.get(f"/api/mappingversions/{mapping_version_id}/?fields=:all").json()["question_mappings"] + def create_form_version(self): with open("iaso/tests/fixtures/odk_form_valid_sample1_2020022401.xlsx", "rb") as xls_file: self.client.post( diff --git a/iaso/tests/dhis2/tests_form_mapping.py b/iaso/tests/dhis2/tests_form_mapping.py new file mode 100644 index 0000000000..a5a4fe2eb7 --- /dev/null +++ b/iaso/tests/dhis2/tests_form_mapping.py @@ -0,0 +1,77 @@ +from iaso import models as m +from iaso.dhis2.form_mapping import copy_mappings_from_previous_version +from iaso.test import TestCase + + +DESCRIPTOR = { + "name": "data", + "type": "survey", + "children": [ + {"name": "weight", "type": "decimal"}, + { + "name": "household", + "type": "repeat", + "children": [{"name": "age", "type": "integer"}], + }, + { + "name": "symptoms", + "type": "select all that apply", + "children": [{"name": "fever"}, {"name": "cough"}], + }, + {"name": "sex", "type": "select one", "children": [{"name": "male"}]}, + ], +} + + +class CopyMappingsFromPreviousVersionTestCase(TestCase): + @classmethod + def setUpTestData(cls): + account = m.Account.objects.create(name="account") + cls.data_source = m.DataSource.objects.create(name="dhis2") + cls.form = m.Form.objects.create(name="form") + project = m.Project.objects.create(name="project", app_id="app", account=account) + project.forms.add(cls.form) + + def test_keeps_mappings_of_questions_still_in_the_new_version(self): + previous = m.FormVersion.objects.create(form=self.form, version_id="1", form_descriptor=DESCRIPTOR) + new_descriptor = { + **DESCRIPTOR, + # "weight" and the "cough" choice were removed + "children": [ + DESCRIPTOR["children"][1], + {**DESCRIPTOR["children"][2], "children": [{"name": "fever"}]}, + DESCRIPTOR["children"][3], + ], + } + new = m.FormVersion.objects.create(form=self.form, version_id="2", form_descriptor=new_descriptor) + mapping = m.Mapping.objects.create(form=self.form, data_source=self.data_source, mapping_type=m.EVENT_TRACKER) + repeat_mapping = [{"type": "repeat", "program_id": "p1", "relationship_type": "r1"}] + age_mapping = [{"dataElement": {"id": "de_age"}, "programStage": "s1", "parent": "household"}] + m.MappingVersion.objects.create( + mapping=mapping, + form_version=previous, + json={ + "question_mappings": { + "weight": {"id": "de_weight", "valueType": "NUMBER"}, + "household": repeat_mapping, + "age": age_mapping, + "symptoms__fever": {"id": "de_fever", "valueType": "BOOLEAN"}, + "symptoms__cough": {"id": "de_cough", "valueType": "BOOLEAN"}, + "sex__male": {"id": "de_male", "valueType": "BOOLEAN"}, + } + }, + ) + + copy_mappings_from_previous_version(new, previous) + + self.assertEqual( + new.mapping_versions.get().json["question_mappings"], + { + "household": repeat_mapping, + # inside the repeat group + "age": age_mapping, + "symptoms__fever": {"id": "de_fever", "valueType": "BOOLEAN"}, + # choice keys are kept for any question with choices, as before + "sex__male": {"id": "de_male", "valueType": "BOOLEAN"}, + }, + ) diff --git a/iaso/tests/odk/test_parsing.py b/iaso/tests/odk/test_parsing.py index c378c3e747..f83f8aeb99 100644 --- a/iaso/tests/odk/test_parsing.py +++ b/iaso/tests/odk/test_parsing.py @@ -5,7 +5,7 @@ from django.test import SimpleTestCase from django.utils.dateparse import parse_datetime -from iaso.odk import ParsingError, Survey, parse_xls_form, to_questions_by_name +from iaso.odk import ParsingError, Survey, parse_xls_form, to_mappable_questions_by_name, to_questions_by_name class ParsingTestCase(SimpleTestCase): @@ -174,3 +174,45 @@ def test_to_questions_by_name_with_node_without_children(self): {"name": "parent", "type": "survey", "children": [{"name": "group_without_children", "type": "group"}]} ) self.assertEqual({}, flattened) + + def test_to_mappable_questions_by_name(self): + descriptor = { + "name": "data", + "type": "survey", + "choices": {"yes_no": [{"name": "yes"}, {"name": "no"}]}, + "children": [ + {"name": "weight", "type": "decimal"}, + {"name": "grp", "type": "group", "children": [{"name": "in_group", "type": "text"}]}, + {"name": "household", "type": "repeat", "children": [{"name": "age", "type": "integer"}]}, + {"name": "symptoms", "type": "select all that apply", "children": [{"name": "fever"}]}, + # recent pyxform: choices only referenced through the itemset + {"name": "answers", "type": "select all that apply", "itemset": "yes_no"}, + {"name": "sex", "type": "select one", "children": [{"name": "male"}]}, + {"name": "meta", "type": "group", "children": [{"name": "instanceID", "type": "calculate"}]}, + ], + } + + mappable = to_mappable_questions_by_name(descriptor) + + self.assertEqual( + sorted(mappable.keys()), + [ + "age", + "answers", + "answers__no", + "answers__yes", + "household", + "in_group", + "instanceID", + "sex", + "symptoms", + "symptoms__fever", + "weight", + ], + ) + # to_questions_by_name is unchanged: no repeat children nor choices + self.assertNotIn("age", to_questions_by_name(descriptor)) + + def test_to_mappable_questions_by_name_with_empty_descriptor(self): + self.assertEqual(to_mappable_questions_by_name({}), {}) + self.assertEqual(to_mappable_questions_by_name(None), {}) From 5c8f2bd90ad013926990d31cbef9d253060ba66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Mestach?= Date: Thu, 1 Oct 2026 08:58:50 +0200 Subject: [PATCH 02/25] conside neverMapped as a mapped question, shows it in the conflicts --- .../Iaso/domains/app/translations/en.json | 2 +- .../Iaso/domains/app/translations/es.json | 2 +- .../Iaso/domains/app/translations/fr.json | 2 +- .../components/ImportMappings/CompareStep.tsx | 11 +++++---- .../domains/mappings/importMappings.test.ts | 23 +++++++++++++------ .../Iaso/domains/mappings/importMappings.ts | 9 ++++---- .../js/apps/Iaso/domains/mappings/messages.js | 2 +- .../js/apps/Iaso/domains/mappings/types.ts | 4 +--- iaso/tests/api/test_mappingversions.py | 3 +++ 9 files changed, 36 insertions(+), 22 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/en.json b/hat/assets/js/apps/Iaso/domains/app/translations/en.json index 64e90da427..c046eb48bd 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/en.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/en.json @@ -1135,7 +1135,7 @@ "iaso.mappings.import.compareLabel": "Comparing {source} with version {versionId}. Nothing is applied until you confirm.", "iaso.mappings.import.decision": "Decision", "iaso.mappings.import.done": "{added} mappings added, {overwritten} overwritten, {kept} kept, {ignored} not imported", - "iaso.mappings.import.droppedHint": "These mappings cannot be imported: the question does not exist in this version or is marked as never mapped.", + "iaso.mappings.import.droppedHint": "These mappings cannot be imported: the question does not exist in this version.", "iaso.mappings.import.fileMeta": "Exported from {formName} - {versionId}", "iaso.mappings.import.fromFile": "Or import a mapping export (.json), for example from another account.", "iaso.mappings.import.identicalHint": "Same DHIS2 target on both sides. Nothing to resolve.", diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/es.json b/hat/assets/js/apps/Iaso/domains/app/translations/es.json index b26ac5d844..a149786b23 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/es.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/es.json @@ -938,7 +938,7 @@ "iaso.mappings.import.compareLabel": "Comparando {source} con la versión {versionId}. No se aplica nada hasta que confirme.", "iaso.mappings.import.decision": "Decisión", "iaso.mappings.import.done": "{added} mapeos añadidos, {overwritten} sobrescritos, {kept} mantenidos, {ignored} no importados", - "iaso.mappings.import.droppedHint": "Estos mapeos no se pueden importar: la pregunta no existe en esta versión o está marcada como nunca mapeada.", + "iaso.mappings.import.droppedHint": "Estos mapeos no se pueden importar: la pregunta no existe en esta versión.", "iaso.mappings.import.fileMeta": "Exportado desde {formName} - {versionId}", "iaso.mappings.import.fromFile": "O importe una exportación de mapeo (.json), por ejemplo desde otra cuenta.", "iaso.mappings.import.identicalHint": "Mismo destino DHIS2 en ambos lados. Nada que resolver.", diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json index 9acbd8a582..18fc95cfac 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json @@ -1136,7 +1136,7 @@ "iaso.mappings.import.compareLabel": "Comparaison de {source} avec la version {versionId}. Rien n'est appliqué avant votre confirmation.", "iaso.mappings.import.decision": "Décision", "iaso.mappings.import.done": "{added} mappings ajoutés, {overwritten} écrasés, {kept} gardés, {ignored} non importés", - "iaso.mappings.import.droppedHint": "Ces mappings ne peuvent pas être importés : la question n'existe pas dans cette version ou est marquée comme jamais mappée.", + "iaso.mappings.import.droppedHint": "Ces mappings ne peuvent pas être importés : la question n'existe pas dans cette version.", "iaso.mappings.import.fileMeta": "Exporté depuis {formName} - {versionId}", "iaso.mappings.import.fromFile": "Ou importez un export de mapping (.json), par exemple depuis un autre compte.", "iaso.mappings.import.identicalHint": "Même cible DHIS2 des deux côtés. Rien à résoudre.", diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx index dbb110c575..e8bbef8615 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx @@ -17,6 +17,7 @@ import { textPlaceholder } from '../../../../constants/uiConstants'; import { SxStyles } from '../../../../types/general'; import { DIFF_KINDS, getMappingLabel } from '../../importMappings'; import MESSAGES from '../../messages'; +import { isNeverMapped } from '../../question_mappings'; import { Decision, DiffKind, DiffRow, ImportPlan } from '../../types'; type Props = { @@ -105,12 +106,13 @@ export const CompareStep: FunctionComponent = ({ const bulk = BULK[bucket]; const getCurrentLabel = (row: DiffRow): string => { + if (isNeverMapped(row.current)) { + return formatMessage(MESSAGES.markedNeverMapped); + } if (row.kind !== 'dropped') { return getMappingLabel(row.current) || textPlaceholder; } - return row.neverMapped - ? formatMessage(MESSAGES.markedNeverMapped) - : formatMessage(MESSAGES.questionAbsent, { versionId }); + return formatMessage(MESSAGES.questionAbsent, { versionId }); }; return ( @@ -215,7 +217,8 @@ export const CompareStep: FunctionComponent = ({ { q1: 'identical', q2: 'conflict', q3: 'add', - q4: 'dropped', + // overwriting a never mapped marker is a decision to take + q4: 'conflict', gone: 'dropped', }); - expect(rows.find(r => r.questionKey === 'q4')?.neverMapped).toBe(true); }); it('only imports choice keys of select all that apply questions', () => { @@ -249,14 +249,23 @@ describe('computeMappingsDiff', () => { rows.map(r => [r.questionKey, getDefaultDecision(r, true)]), ); const plan = buildImportPlan(rows, decisions); - expect(plan.changes).toEqual({ q2: de('c'), q3: de('d') }); - expect(plan.undo).toEqual({ q2: de('b'), q3: { action: 'unmap' } }); + expect(plan.changes).toEqual({ + q2: de('c'), + q3: de('d'), + q4: de('e'), + }); + expect(plan.undo).toEqual({ + q2: de('b'), + q3: { action: 'unmap' }, + // undo puts the never mapped marker back + q4: { type: 'neverMapped' }, + }); expect(plan).toMatchObject({ added: 1, - overwritten: 1, + overwritten: 2, kept: 0, skipped: 0, - dropped: 2, + dropped: 1, }); }); @@ -266,7 +275,7 @@ describe('computeMappingsDiff', () => { ); const plan = buildImportPlan(rows, decisions); expect(Object.keys(plan.changes)).toEqual(['q3']); - expect(plan.kept).toBe(1); + expect(plan.kept).toBe(2); }); }); diff --git a/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts b/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts index 1186d2d3a7..783efec9b3 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts +++ b/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts @@ -174,14 +174,15 @@ export const computeMappingsDiff = ( questionLabel: question ? Descriptor.getHumanLabel(question) : undefined, - current: hasTarget(current) ? current : undefined, + // a never mapped marker is a decision too: overwriting it is a conflict + current: + hasTarget(current) || isNeverMapped(current) + ? current + : undefined, incoming, }; if (!question) { row.kind = 'dropped'; - } else if (isNeverMapped(current)) { - row.kind = 'dropped'; - row.neverMapped = true; } else if (row.current) { row.kind = isSameMapping(row.current, incoming) ? 'identical' diff --git a/hat/assets/js/apps/Iaso/domains/mappings/messages.js b/hat/assets/js/apps/Iaso/domains/mappings/messages.js index bedb7dc85c..cf4e49a0ea 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/messages.js +++ b/hat/assets/js/apps/Iaso/domains/mappings/messages.js @@ -376,7 +376,7 @@ const MESSAGES = defineMessages({ droppedHint: { id: 'iaso.mappings.import.droppedHint', defaultMessage: - 'These mappings cannot be imported: the question does not exist in this version or is marked as never mapped.', + 'These mappings cannot be imported: the question does not exist in this version.', }, question: { id: 'iaso.label.question', diff --git a/hat/assets/js/apps/Iaso/domains/mappings/types.ts b/hat/assets/js/apps/Iaso/domains/mappings/types.ts index a800205911..0bb6b5c8fc 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/types.ts +++ b/hat/assets/js/apps/Iaso/domains/mappings/types.ts @@ -12,11 +12,9 @@ export type DiffRow = { kind: DiffKind; questionKey: string; questionLabel?: string; + // a mapping or a never mapped marker current?: QuestionMapping; incoming: QuestionMapping; - // dropped rows: the question is absent from the current version, - // or marked as never mapped - neverMapped?: boolean; }; export type ImportPlan = { diff --git a/iaso/tests/api/test_mappingversions.py b/iaso/tests/api/test_mappingversions.py index 6445cd88c0..74644e414a 100644 --- a/iaso/tests/api/test_mappingversions.py +++ b/iaso/tests/api/test_mappingversions.py @@ -350,6 +350,8 @@ def test_mappingversions_bulk_patch_and_undo(self): imported = { # overwrite "question_2": {"id": "de2bis", "valueType": "INTEGER", "categoryOptionCombo": "coc2"}, + # overwrite a never mapped marker + "question_6": {"id": "de6", "valueType": "NUMBER"}, # select all that apply, no data element id at the top level "question_3": {"type": "multiple", "values": {"a": {"id": "de3a", "valueType": "BOOLEAN"}}}, # event tracker question inside a repeat group @@ -371,6 +373,7 @@ def test_mappingversions_bulk_patch_and_undo(self): undo = { "question_2": original["question_2"], + "question_6": {"type": "neverMapped"}, "question_3": {"action": "unmap"}, "question_4": {"action": "unmap"}, "question_5": {"action": "unmap"}, From e5bed48a926a291a16b87533e67c141315cc43ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Mestach?= Date: Thu, 1 Oct 2026 09:29:26 +0200 Subject: [PATCH 03/25] stronger validation for mappings json --- .../Iaso/domains/app/translations/en.json | 6 +- .../Iaso/domains/app/translations/es.json | 6 +- .../Iaso/domains/app/translations/fr.json | 6 +- .../components/ImportMappings/CompareStep.tsx | 8 +- .../ImportMappingsDialog.test.tsx | 38 +++++- .../ImportMappings/ImportMappingsDialog.tsx | 47 ++++++- .../components/ImportMappings/SourceStep.tsx | 12 +- .../mappings/hooks/useImportSources.ts | 1 + .../domains/mappings/importMappings.test.ts | 120 +++++++++++++++--- .../Iaso/domains/mappings/importMappings.ts | 113 ++++++++++++++++- .../js/apps/Iaso/domains/mappings/messages.js | 21 ++- .../js/apps/Iaso/domains/mappings/types.ts | 2 + iaso/api/mapping_versions.py | 36 ++++++ iaso/tests/api/test_mappingversions.py | 105 +++++++++++++-- 14 files changed, 465 insertions(+), 56 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/en.json b/hat/assets/js/apps/Iaso/domains/app/translations/en.json index c046eb48bd..a19f8476dd 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/en.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/en.json @@ -1135,13 +1135,14 @@ "iaso.mappings.import.compareLabel": "Comparing {source} with version {versionId}. Nothing is applied until you confirm.", "iaso.mappings.import.decision": "Decision", "iaso.mappings.import.done": "{added} mappings added, {overwritten} overwritten, {kept} kept, {ignored} not imported", - "iaso.mappings.import.droppedHint": "These mappings cannot be imported: the question does not exist in this version.", + "iaso.mappings.import.droppedHint": "These mappings cannot be imported: the question does not exist in this version, or the mapping is not valid for this mapping type.", "iaso.mappings.import.fileMeta": "Exported from {formName} - {versionId}", "iaso.mappings.import.fromFile": "Or import a mapping export (.json), for example from another account.", "iaso.mappings.import.identicalHint": "Same DHIS2 target on both sides. Nothing to resolve.", "iaso.mappings.import.incomingMapping": "Incoming mapping", "iaso.mappings.import.invalidFormat": "This file is not a mapping export.", "iaso.mappings.import.invalidJson": "This file is not valid JSON.", + "iaso.mappings.import.invalidMapping": "Not valid for a {type} mapping", "iaso.mappings.import.keep": "Keep", "iaso.mappings.import.keepEverywhere": "Keep current everywhere", "iaso.mappings.import.mappingsCount": "{count} mappings", @@ -1151,9 +1152,12 @@ "iaso.mappings.import.noChange": "No change", "iaso.mappings.import.noSource": "No other mapping version uses this data source with the same DHIS2 dataset or program.", "iaso.mappings.import.notImportable": "Not importable", + "iaso.mappings.import.noValidMapping": "This file contains no mapping valid for a {type} mapping.", + "iaso.mappings.import.otherDataset": "This export was made for another DHIS2 dataset ({name}). Check that its data elements belong to this one.", "iaso.mappings.import.otherFormDatasetMeta": "Another form, same DHIS2 dataset, last updated {date}", "iaso.mappings.import.otherFormProgramMeta": "Another form, same DHIS2 program, last updated {date}", "iaso.mappings.import.otherFormTitle": "{formName} - {versionId}", + "iaso.mappings.import.otherProgram": "This export was made for another DHIS2 program ({name}). Check that its data elements belong to this one.", "iaso.mappings.import.overwrite": "Overwrite", "iaso.mappings.import.overwriteEverywhere": "Overwrite everywhere", "iaso.mappings.import.pickSource": "Pick the mappings to reuse. Nothing is applied until you confirm.", diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/es.json b/hat/assets/js/apps/Iaso/domains/app/translations/es.json index a149786b23..4ea6b14d12 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/es.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/es.json @@ -938,13 +938,14 @@ "iaso.mappings.import.compareLabel": "Comparando {source} con la versión {versionId}. No se aplica nada hasta que confirme.", "iaso.mappings.import.decision": "Decisión", "iaso.mappings.import.done": "{added} mapeos añadidos, {overwritten} sobrescritos, {kept} mantenidos, {ignored} no importados", - "iaso.mappings.import.droppedHint": "Estos mapeos no se pueden importar: la pregunta no existe en esta versión.", + "iaso.mappings.import.droppedHint": "Estos mapeos no se pueden importar: la pregunta no existe en esta versión, o el mapeo no es válido para este tipo de mapeo.", "iaso.mappings.import.fileMeta": "Exportado desde {formName} - {versionId}", "iaso.mappings.import.fromFile": "O importe una exportación de mapeo (.json), por ejemplo desde otra cuenta.", "iaso.mappings.import.identicalHint": "Mismo destino DHIS2 en ambos lados. Nada que resolver.", "iaso.mappings.import.incomingMapping": "Mapeo entrante", "iaso.mappings.import.invalidFormat": "Este archivo no es una exportación de mapeo.", "iaso.mappings.import.invalidJson": "Este archivo no es un JSON válido.", + "iaso.mappings.import.invalidMapping": "No válido para un mapeo {type}", "iaso.mappings.import.keep": "Mantener", "iaso.mappings.import.keepEverywhere": "Mantener el actual en todas partes", "iaso.mappings.import.mappingsCount": "{count} mapeos", @@ -954,9 +955,12 @@ "iaso.mappings.import.noChange": "Sin cambios", "iaso.mappings.import.noSource": "Ninguna otra versión de mapeo usa esta fuente de datos con el mismo dataset o programa DHIS2.", "iaso.mappings.import.notImportable": "No importable", + "iaso.mappings.import.noValidMapping": "Este archivo no contiene ningún mapeo válido para un mapeo {type}.", + "iaso.mappings.import.otherDataset": "Esta exportación se hizo para otro dataset DHIS2 ({name}). Verifique que sus data elements pertenecen a este.", "iaso.mappings.import.otherFormDatasetMeta": "Otro formulario, mismo dataset DHIS2, actualizado el {date}", "iaso.mappings.import.otherFormProgramMeta": "Otro formulario, mismo programa DHIS2, actualizado el {date}", "iaso.mappings.import.otherFormTitle": "{formName} - {versionId}", + "iaso.mappings.import.otherProgram": "Esta exportación se hizo para otro programa DHIS2 ({name}). Verifique que sus data elements pertenecen a este.", "iaso.mappings.import.overwrite": "Sobrescribir", "iaso.mappings.import.overwriteEverywhere": "Sobrescribir en todas partes", "iaso.mappings.import.pickSource": "Elija los mapeos a reutilizar. No se aplica nada hasta que confirme.", diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json index 18fc95cfac..b27458e0b7 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json @@ -1136,13 +1136,14 @@ "iaso.mappings.import.compareLabel": "Comparaison de {source} avec la version {versionId}. Rien n'est appliqué avant votre confirmation.", "iaso.mappings.import.decision": "Décision", "iaso.mappings.import.done": "{added} mappings ajoutés, {overwritten} écrasés, {kept} gardés, {ignored} non importés", - "iaso.mappings.import.droppedHint": "Ces mappings ne peuvent pas être importés : la question n'existe pas dans cette version.", + "iaso.mappings.import.droppedHint": "Ces mappings ne peuvent pas être importés : la question n'existe pas dans cette version, ou le mapping n'est pas valide pour ce type de mapping.", "iaso.mappings.import.fileMeta": "Exporté depuis {formName} - {versionId}", "iaso.mappings.import.fromFile": "Ou importez un export de mapping (.json), par exemple depuis un autre compte.", "iaso.mappings.import.identicalHint": "Même cible DHIS2 des deux côtés. Rien à résoudre.", "iaso.mappings.import.incomingMapping": "Mapping importé", "iaso.mappings.import.invalidFormat": "Ce fichier n'est pas un export de mapping.", "iaso.mappings.import.invalidJson": "Ce fichier n'est pas un JSON valide.", + "iaso.mappings.import.invalidMapping": "Non valide pour un mapping {type}", "iaso.mappings.import.keep": "Garder", "iaso.mappings.import.keepEverywhere": "Garder l'actuel partout", "iaso.mappings.import.mappingsCount": "{count} mappings", @@ -1152,9 +1153,12 @@ "iaso.mappings.import.noChange": "Aucun changement", "iaso.mappings.import.noSource": "Aucune autre version de mapping n'utilise cette source avec le même dataset ou programme DHIS2.", "iaso.mappings.import.notImportable": "Non importable", + "iaso.mappings.import.noValidMapping": "Ce fichier ne contient aucun mapping valide pour un mapping {type}.", + "iaso.mappings.import.otherDataset": "Cet export a été fait pour un autre dataset DHIS2 ({name}). Vérifiez que ses data elements font partie de celui-ci.", "iaso.mappings.import.otherFormDatasetMeta": "Autre formulaire, même dataset DHIS2, mis à jour le {date}", "iaso.mappings.import.otherFormProgramMeta": "Autre formulaire, même programme DHIS2, mis à jour le {date}", "iaso.mappings.import.otherFormTitle": "{formName} - {versionId}", + "iaso.mappings.import.otherProgram": "Cet export a été fait pour un autre programme DHIS2 ({name}). Vérifiez que ses data elements font partie de celui-ci.", "iaso.mappings.import.overwrite": "Écraser", "iaso.mappings.import.overwriteEverywhere": "Écraser partout", "iaso.mappings.import.pickSource": "Choisissez les mappings à réutiliser. Rien n'est appliqué avant votre confirmation.", diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx index e8bbef8615..92e13d5cf5 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/CompareStep.tsx @@ -23,6 +23,7 @@ import { Decision, DiffKind, DiffRow, ImportPlan } from '../../types'; type Props = { sourceTitle: string; versionId: string; + mappingType: string; rows: DiffRow[]; bucket: DiffKind; setBucket: (bucket: DiffKind) => void; @@ -86,6 +87,7 @@ const styles: SxStyles = { export const CompareStep: FunctionComponent = ({ sourceTitle, versionId, + mappingType, rows, bucket, setBucket, @@ -112,7 +114,11 @@ export const CompareStep: FunctionComponent = ({ if (row.kind !== 'dropped') { return getMappingLabel(row.current) || textPlaceholder; } - return formatMessage(MESSAGES.questionAbsent, { versionId }); + return row.invalid + ? formatMessage(MESSAGES.importInvalidMapping, { + type: mappingType, + }) + : formatMessage(MESSAGES.questionAbsent, { versionId }); }; return ( diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx index 46bf92f888..cb9d35f3fe 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx @@ -107,7 +107,7 @@ describe('ImportMappingsDialog', () => { ); }); - it('rejects a file of another mapping type', async () => { + const renderAndChooseFile = (content: unknown) => { renderWithThemeAndIntlProvider( { onApply={vi.fn()} />, ); - const content = JSON.stringify({ - mapping_type: 'EVENT', - question_mappings: {}, - }); - const file = new File([content], 'export.json', { + const text = JSON.stringify(content); + const file = new File([text], 'export.json', { type: 'application/json', }); // jsdom does not implement Blob.text() Object.defineProperty(file, 'text', { - value: () => Promise.resolve(content), + value: () => Promise.resolve(text), }); fireEvent.change( document.querySelector('input[type="file"]') as HTMLInputElement, { target: { files: [file] } }, ); + }; + + it('rejects a file of another mapping type', async () => { + renderAndChooseFile({ mapping_type: 'EVENT', question_mappings: {} }); expect( await screen.findByText( 'This export was made for another mapping type.', ), ).toBeInTheDocument(); }); + + it('rejects a file without any valid mapping', async () => { + renderAndChooseFile({ compilerOptions: { strict: true } }); + expect( + await screen.findByText( + 'This file contains no mapping valid for a AGGREGATE mapping.', + ), + ).toBeInTheDocument(); + }); + + it('warns about a file made for another dataset', async () => { + renderAndChooseFile({ + mapping_type: 'AGGREGATE', + dataset: { id: 'ds2', name: 'Other dataset' }, + question_mappings: { q1: de('x') }, + }); + expect(await screen.findByText('export.json')).toBeInTheDocument(); + expect( + screen.getByText( + 'This export was made for another DHIS2 dataset (Other dataset). Check that its data elements belong to this one.', + ), + ).toBeInTheDocument(); + }); }); diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.tsx index 571eec1361..7f245f444e 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.tsx +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.tsx @@ -15,6 +15,7 @@ import { countMatchingMappings, getDefaultDecision, getImportableMappings, + getOtherTarget, MappingImportError, parseMappingExport, } from '../../importMappings'; @@ -43,6 +44,12 @@ const IMPORT_ERROR_MESSAGES = { invalidJson: MESSAGES.importInvalidJson, invalidFormat: MESSAGES.importInvalidFormat, mappingTypeMismatch: MESSAGES.importMappingTypeMismatch, + noValidMapping: MESSAGES.importNoValidMapping, +}; + +const OTHER_TARGET_MESSAGES = { + dataset: MESSAGES.importOtherDataset, + program: MESSAGES.importOtherProgram, }; const styles: SxStyles = { @@ -61,12 +68,14 @@ export const ImportMappingsDialog: FunctionComponent = ({ const [selectedId, setSelectedId] = useState(); const [fileSource, setFileSource] = useState(); const [fileError, setFileError] = useState(); + const [fileWarning, setFileWarning] = useState(); const [bucket, setBucket] = useState('conflict'); const [decisions, setDecisions] = useState< Record >({}); const [isApplying, setIsApplying] = useState(false); + const mappingType = mappingVersion.mapping.mapping_type; const { sources: versionSources, isLoading } = useImportSources( mappingVersion, questions, @@ -84,9 +93,16 @@ export const ImportMappingsDialog: FunctionComponent = ({ mappingVersion.question_mappings, selectedSource.questionMappings, questions, + mappingType, ) : [], - [selectedSource, step, mappingVersion.question_mappings, questions], + [ + selectedSource, + step, + mappingVersion.question_mappings, + questions, + mappingType, + ], ); const plan = useMemo( () => buildImportPlan(rows, decisions), @@ -99,6 +115,7 @@ export const ImportMappingsDialog: FunctionComponent = ({ setSelectedId(undefined); setFileSource(undefined); setFileError(undefined); + setFileWarning(undefined); setDecisions({}); }; const handleClose = () => { @@ -108,11 +125,17 @@ export const ImportMappingsDialog: FunctionComponent = ({ const onFileChosen = async (file: File) => { setFileError(undefined); + setFileWarning(undefined); try { - const content = parseMappingExport( - await file.text(), - mappingVersion.mapping.mapping_type, - ); + const content = parseMappingExport(await file.text(), mappingType); + const otherTarget = getOtherTarget(content, mappingVersion); + if (otherTarget) { + setFileWarning( + formatMessage(OTHER_TARGET_MESSAGES[otherTarget.kind], { + name: otherTarget.name, + }), + ); + } const questionMappings = getImportableMappings( content.question_mappings, ); @@ -129,13 +152,18 @@ export const ImportMappingsDialog: FunctionComponent = ({ matchingCount: countMatchingMappings( questionMappings, questions, + mappingType, ), questionMappings, }); setSelectedId(FILE_SOURCE_ID); } catch (e) { if (e instanceof MappingImportError) { - setFileError(formatMessage(IMPORT_ERROR_MESSAGES[e.reason])); + setFileError( + formatMessage(IMPORT_ERROR_MESSAGES[e.reason], { + type: mappingType, + }), + ); } else { throw e; } @@ -147,6 +175,7 @@ export const ImportMappingsDialog: FunctionComponent = ({ mappingVersion.question_mappings, selectedSource.questionMappings, questions, + mappingType, ); setDecisions( Object.fromEntries( @@ -198,6 +227,11 @@ export const ImportMappingsDialog: FunctionComponent = ({ onSelect={setSelectedId} onFileChosen={onFileChosen} fileError={fileError} + fileWarning={ + selectedSource?.id === FILE_SOURCE_ID + ? fileWarning + : undefined + } isLoading={isLoading} /> )} @@ -205,6 +239,7 @@ export const ImportMappingsDialog: FunctionComponent = ({ void; onFileChosen: (file: File) => void; fileError?: string; + // the file targets another DHIS2 dataset or program + fileWarning?: string; isLoading: boolean; }; @@ -20,7 +22,7 @@ const styles: SxStyles = { divider: { mt: 2 }, fileRow: { display: 'flex', alignItems: 'center', gap: 1.5, pt: 2 }, fileText: { flex: '1 1 auto' }, - fileError: { mt: 1.5 }, + fileAlert: { mt: 1.5 }, }; export const SourceStep: FunctionComponent = ({ @@ -29,6 +31,7 @@ export const SourceStep: FunctionComponent = ({ onSelect, onFileChosen, fileError, + fileWarning, isLoading, }) => { const { formatMessage } = useSafeIntl(); @@ -87,10 +90,15 @@ export const SourceStep: FunctionComponent = ({ {fileError && ( - + {fileError} )} + {fileWarning && ( + + {fileWarning} + + )} ); }; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/hooks/useImportSources.ts b/hat/assets/js/apps/Iaso/domains/mappings/hooks/useImportSources.ts index ccd3bb03c7..8f7c579042 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/hooks/useImportSources.ts +++ b/hat/assets/js/apps/Iaso/domains/mappings/hooks/useImportSources.ts @@ -81,6 +81,7 @@ export const useImportSources = ( matchingCount: countMatchingMappings( questionMappings, questions, + mappingVersion.mapping.mapping_type, ), questionMappings, }; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/importMappings.test.ts b/hat/assets/js/apps/Iaso/domains/mappings/importMappings.test.ts index e75475c76a..64315b2c81 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/importMappings.test.ts +++ b/hat/assets/js/apps/Iaso/domains/mappings/importMappings.test.ts @@ -7,6 +7,8 @@ import { getExportFileName, getMappableQuestions, getMappingLabel, + getOtherTarget, + isValidForMappingType, MappingImportError, parseMappingExport, } from './importMappings'; @@ -133,41 +135,95 @@ describe('mapping shapes', () => { empty: { name: 'empty', type: 'integer' }, }; + const kindsOf = (rows: { questionKey: string; kind: string }[]) => + Object.fromEntries(rows.map(r => [r.questionKey, r.kind])); + it('compares each shape on its DHIS2 target', () => { - const rows = computeMappingsDiff( - { - symptoms: multiple('a'), - age: tracker('s1'), - household: repeat('r1'), - empty: [], - }, - { - symptoms: multiple('b'), - age: [{ ...tracker('s1')[0], dataElement: { id: 'de1' } }], - household: repeat('r2'), - empty: de('x'), - }, - questions, - ); expect( - Object.fromEntries(rows.map(r => [r.questionKey, r.kind])), + kindsOf( + computeMappingsDiff( + { symptoms: multiple('a'), empty: [] }, + { symptoms: multiple('b'), empty: de('x') }, + questions, + 'AGGREGATE', + ), + ), ).toEqual({ // a "multiple" mapping has no id but is a real mapping symptoms: 'conflict', - age: 'identical', - household: 'conflict', // an empty list is not a mapping empty: 'add', }); + expect( + kindsOf( + computeMappingsDiff( + { age: tracker('s1'), household: repeat('r1') }, + { + age: [ + { ...tracker('s1')[0], dataElement: { id: 'de1' } }, + ], + household: repeat('r2'), + }, + questions, + 'EVENT_TRACKER', + ), + ), + ).toEqual({ age: 'identical', household: 'conflict' }); expect( computeMappingsDiff( { age: tracker('s1') }, { age: tracker('s2') }, questions, + 'EVENT_TRACKER', )[0].kind, ).toBe('conflict'); }); + it.each([ + ['a data element', de('a'), 'AGGREGATE', true], + ['a data element without valueType', { id: 'a' }, 'AGGREGATE', false], + ['a select all that apply', multiple('a'), 'EVENT', true], + [ + 'a select all that apply choice without id', + { type: 'multiple', values: { fever: { name: 'Fever' } } }, + 'AGGREGATE', + false, + ], + ['a tracker list', tracker('s1'), 'AGGREGATE', false], + ['a tracker list', tracker('s1'), 'EVENT_TRACKER', true], + ['a repeat group', repeat('r1'), 'EVENT_TRACKER', true], + [ + 'a tracked entity attribute', + [{ trackedEntityAttribute: { id: 'tea' } }], + 'EVENT_TRACKER', + true, + ], + ['a data element', de('a'), 'EVENT_TRACKER', false], + ['an empty list', [], 'EVENT_TRACKER', false], + ['a list of junk', [{ foo: 1 }], 'EVENT_TRACKER', false], + [ + 'a never mapped marker', + { type: 'neverMapped' }, + 'EVENT_TRACKER', + true, + ], + ])('%s in a %s mapping is valid: %s', (_, mapping, mappingType, valid) => { + expect(isValidForMappingType(mapping as any, mappingType)).toBe(valid); + }); + + it('drops mappings that do not fit the mapping type', () => { + const rows = computeMappingsDiff( + {}, + { symptoms: tracker('s1'), age: de('a') }, + questions, + 'AGGREGATE', + ); + expect(kindsOf(rows)).toEqual({ symptoms: 'dropped', age: 'add' }); + expect(rows.find(r => r.questionKey === 'symptoms')?.invalid).toBe( + true, + ); + }); + it('labels each shape', () => { expect(getMappingLabel(multiple('a'))).toBe( 'fever: DE a, cough: DE cough', @@ -203,6 +259,7 @@ describe('computeMappingsDiff', () => { current, incoming, getMappableQuestions(descriptor), + 'AGGREGATE', ); const kinds = Object.fromEntries(rows.map(r => [r.questionKey, r.kind])); @@ -238,6 +295,7 @@ describe('computeMappingsDiff', () => { {}, { symptoms__fever: de('a'), sex__male: de('b') }, choiceQuestions, + 'AGGREGATE', ); expect( Object.fromEntries(choiceRows.map(r => [r.questionKey, r.kind])), @@ -331,6 +389,10 @@ describe('export / import', () => { '{"mapping_type": "EVENT", "question_mappings": {}}', 'mappingTypeMismatch', ], + // any JSON object whose values are objects, e.g. a tsconfig.json + ['{"compilerOptions": {"strict": true}}', 'noValidMapping'], + ['{"q1": [{"dataElement": {"id": "x"}}]}', 'noValidMapping'], + ['{"question_mappings": {}}', 'noValidMapping'], ])('rejects %s', (text, reason) => { // the error message is the reason expect(() => parseMappingExport(text, 'AGGREGATE')).toThrow( @@ -338,3 +400,25 @@ describe('export / import', () => { ); }); }); + +describe('getOtherTarget', () => { + const aggregate = { derivate_settings: { data_set_id: 'ds1' } }; + const tracker = { derivate_settings: { program_id: 'p1' } }; + + it('flags an export made for another dataset or program', () => { + expect( + getOtherTarget({ dataset: { id: 'ds2', name: 'DS 2' } }, aggregate), + ).toEqual({ kind: 'dataset', name: 'DS 2' }); + expect(getOtherTarget({ program: { id: 'p2' } }, tracker)).toEqual({ + kind: 'program', + name: 'p2', + }); + }); + + it('accepts the same target, or an export without one', () => { + expect(getOtherTarget({ dataset: { id: 'ds1' } }, aggregate)).toBe( + undefined, + ); + expect(getOtherTarget({}, aggregate)).toBe(undefined); + }); +}); diff --git a/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts b/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts index 783efec9b3..a5b2310b7c 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts +++ b/hat/assets/js/apps/Iaso/domains/mappings/importMappings.ts @@ -159,10 +159,55 @@ export const isSameMapping = (a: QuestionMapping, b: QuestionMapping) => { ); }; +const isObject = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const isTrackerItem = (item: unknown): boolean => + isObject(item) && + Boolean( + item.dataElement?.id || + item.trackedEntityAttribute?.id || + (item.type === 'repeat' && item.program_id), + ); + +/** + * Whether a question mapping has the shape the exporter of the mapping type + * reads. Same rules as get_question_mapping_shape_error on the backend. + */ +export const isValidForMappingType = ( + mapping: QuestionMapping, + mappingType: string, +): boolean => { + if (isNeverMapped(mapping)) { + return true; + } + if (mappingType === 'EVENT_TRACKER') { + return ( + Array.isArray(mapping) && + mapping.length > 0 && + mapping.every(isTrackerItem) + ); + } + if (Array.isArray(mapping) || !isObject(mapping)) { + return false; + } + const { type, values, id, valueType } = mapping; + if (type === 'multiple') { + return ( + isObject(values) && + Object.values(values).every( + value => isObject(value) && Boolean(value.id), + ) + ); + } + return Boolean(id && valueType); +}; + export const computeMappingsDiff = ( currentMappings: QuestionMappings, incomingMappings: QuestionMappings, questions: Record, + mappingType: string, ): DiffRow[] => Object.entries(getImportableMappings(incomingMappings)).map( ([questionKey, incoming]) => { @@ -183,6 +228,9 @@ export const computeMappingsDiff = ( }; if (!question) { row.kind = 'dropped'; + } else if (!isValidForMappingType(incoming, mappingType)) { + row.kind = 'dropped'; + row.invalid = true; } else if (row.current) { row.kind = isSameMapping(row.current, incoming) ? 'identical' @@ -192,10 +240,24 @@ export const computeMappingsDiff = ( }, ); +/** Mappings of a question of this version, valid for the mapping type. */ export const countMatchingMappings = ( questionMappings: QuestionMappings, questions: Record, -): number => Object.keys(questionMappings).filter(key => questions[key]).length; + mappingType: string, +): number => + Object.entries(questionMappings).filter( + ([key, mapping]) => + questions[key] && isValidForMappingType(mapping, mappingType), + ).length; + +export const countValidMappings = ( + questionMappings: QuestionMappings, + mappingType: string, +): number => + Object.values(questionMappings).filter(mapping => + isValidForMappingType(mapping, mappingType), + ).length; export const getDefaultDecision = ( row: DiffRow, @@ -293,15 +355,16 @@ export const getExportFileName = (mappingVersion: Record) => { return `mapping-${slug}.json`; }; -const isObject = (value: unknown): boolean => - Boolean(value) && typeof value === 'object' && !Array.isArray(value); - const isValidMapping = (mapping: unknown): boolean => Array.isArray(mapping) ? mapping.every(isObject) : isObject(mapping); export class MappingImportError extends Error { constructor( - public reason: 'invalidJson' | 'invalidFormat' | 'mappingTypeMismatch', + public reason: + | 'invalidJson' + | 'invalidFormat' + | 'mappingTypeMismatch' + | 'noValidMapping', ) { super(reason); } @@ -338,5 +401,45 @@ export const parseMappingExport = ( if (content.mapping_type && content.mapping_type !== expectedMappingType) { throw new MappingImportError('mappingTypeMismatch'); } + if ( + countValidMappings( + getImportableMappings(questionMappings), + expectedMappingType, + ) === 0 + ) { + throw new MappingImportError('noValidMapping'); + } return content; }; + +/** + * The DHIS2 dataset or program an export was made for, when it is not the + * one of the mapping version it is imported in. + */ +export const getOtherTarget = ( + content: Partial, + mappingVersion: Record, +): { kind: 'dataset' | 'program'; name: string } | undefined => { + const settings = mappingVersion.derivate_settings ?? {}; + if ( + content.dataset?.id && + settings.data_set_id && + content.dataset.id !== settings.data_set_id + ) { + return { + kind: 'dataset', + name: content.dataset.name ?? content.dataset.id, + }; + } + if ( + content.program?.id && + settings.program_id && + content.program.id !== settings.program_id + ) { + return { + kind: 'program', + name: content.program.name ?? content.program.id, + }; + } + return undefined; +}; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/messages.js b/hat/assets/js/apps/Iaso/domains/mappings/messages.js index cf4e49a0ea..b42c8aa93e 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/messages.js +++ b/hat/assets/js/apps/Iaso/domains/mappings/messages.js @@ -376,7 +376,7 @@ const MESSAGES = defineMessages({ droppedHint: { id: 'iaso.mappings.import.droppedHint', defaultMessage: - 'These mappings cannot be imported: the question does not exist in this version.', + 'These mappings cannot be imported: the question does not exist in this version, or the mapping is not valid for this mapping type.', }, question: { id: 'iaso.label.question', @@ -428,6 +428,25 @@ const MESSAGES = defineMessages({ defaultMessage: '{added} mappings added, {overwritten} overwritten, {kept} kept, {ignored} not imported', }, + importInvalidMapping: { + id: 'iaso.mappings.import.invalidMapping', + defaultMessage: 'Not valid for a {type} mapping', + }, + importNoValidMapping: { + id: 'iaso.mappings.import.noValidMapping', + defaultMessage: + 'This file contains no mapping valid for a {type} mapping.', + }, + importOtherDataset: { + id: 'iaso.mappings.import.otherDataset', + defaultMessage: + 'This export was made for another DHIS2 dataset ({name}). Check that its data elements belong to this one.', + }, + importOtherProgram: { + id: 'iaso.mappings.import.otherProgram', + defaultMessage: + 'This export was made for another DHIS2 program ({name}). Check that its data elements belong to this one.', + }, }); export default MESSAGES; diff --git a/hat/assets/js/apps/Iaso/domains/mappings/types.ts b/hat/assets/js/apps/Iaso/domains/mappings/types.ts index 0bb6b5c8fc..a59b9af62e 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/types.ts +++ b/hat/assets/js/apps/Iaso/domains/mappings/types.ts @@ -15,6 +15,8 @@ export type DiffRow = { // a mapping or a never mapped marker current?: QuestionMapping; incoming: QuestionMapping; + // dropped because its shape does not fit the mapping type + invalid?: boolean; }; export type ImportPlan = { diff --git a/iaso/api/mapping_versions.py b/iaso/api/mapping_versions.py index 38bfc77475..13fcc4cf85 100644 --- a/iaso/api/mapping_versions.py +++ b/iaso/api/mapping_versions.py @@ -16,6 +16,37 @@ from .common import HasPermission, ModelViewSet, TimestampField +def get_question_mapping_shape_error(mapping_type, data_element): + """The shape a question mapping must have for the exporters of its mapping type, or None if it is valid. + + The id and valueType of plain data element mappings are checked separately.""" + if isinstance(data_element, dict) and data_element.get("type") == MappingVersion.QUESTION_MAPPING_NEVER_MAPPED: + return None + + if mapping_type == m.EVENT_TRACKER: + if not isinstance(data_element, list) or not data_element: + return "should be a list for EVENT_TRACKER mappings" + for item in data_element: + if not isinstance(item, dict): + return "should only contain objects" + is_data_element = isinstance(item.get("dataElement"), dict) and item["dataElement"].get("id") + is_attribute = isinstance(item.get("trackedEntityAttribute"), dict) and item["trackedEntityAttribute"].get( + "id" + ) + is_repeat_group = item.get("type") == "repeat" and item.get("program_id") + if not (is_data_element or is_attribute or is_repeat_group): + return "should map a data element, a tracked entity attribute or a repeat group" + return None + + if not isinstance(data_element, dict): + return f"should not be a list for {mapping_type} mappings" + if data_element.get("type") == MappingVersion.QUESTION_MAPPING_MULTIPLE: + values = data_element.get("values") + if not isinstance(values, dict) or not all(isinstance(v, dict) and v.get("id") for v in values.values()): + return "should map each choice to a data element id" + return None + + class MappingVersionSerializer(DynamicFieldsModelSerializerBackwardCompatible): class Meta: model = MappingVersion @@ -153,6 +184,11 @@ def update(self, instance, validated_data): if mappable_questions and not is_unmap and question_name not in mappable_questions: raise serializers.ValidationError({path: "question does not exist in this form version"}) + if data_element is not None and not is_unmap: + shape_error = get_question_mapping_shape_error(instance.mapping.mapping_type, data_element) + if shape_error: + raise serializers.ValidationError({path: shape_error}) + if type(data_element) is list: instance.json["question_mappings"][question_name] = data_element else: diff --git a/iaso/tests/api/test_mappingversions.py b/iaso/tests/api/test_mappingversions.py index 74644e414a..bb08b9f328 100644 --- a/iaso/tests/api/test_mappingversions.py +++ b/iaso/tests/api/test_mappingversions.py @@ -332,8 +332,8 @@ def test_mappingversions_list_filters_project_ids(self): self.assertEqual(len(resp.json()["mapping_versions"]), 1) def test_mappingversions_bulk_patch_and_undo(self): - """PATCH /mappingversions/: several question mappings of every shape at once, as the import wizard - does, then the payload the wizard sends to undo that import""" + """PATCH /mappingversions/: several question mappings at once, as the import wizard does, then the + payload the wizard sends to undo that import""" self.client.force_authenticate(self.yoda) form_version = self.create_form_version() @@ -354,6 +354,30 @@ def test_mappingversions_bulk_patch_and_undo(self): "question_6": {"id": "de6", "valueType": "NUMBER"}, # select all that apply, no data element id at the top level "question_3": {"type": "multiple", "values": {"a": {"id": "de3a", "valueType": "BOOLEAN"}}}, + } + response = self.patch_question_mappings(mapping_version_id, imported) + self.assertJSONResponse(response, status.HTTP_200_OK) + self.assertEqual(self.get_question_mappings(mapping_version_id), {**original, **imported}) + + undo = { + "question_2": original["question_2"], + "question_6": {"type": "neverMapped"}, + "question_3": {"action": "unmap"}, + } + response = self.patch_question_mappings(mapping_version_id, undo) + self.assertJSONResponse(response, status.HTTP_200_OK) + self.assertEqual(self.get_question_mappings(mapping_version_id), original) + + def test_mappingversions_bulk_patch_and_undo_event_tracker(self): + """PATCH /mappingversions/: event tracker mappings are lists, repeat groups included""" + + self.client.force_authenticate(self.yoda) + form_version = self.create_form_version() + mapping_version_id = self.create_tracker_mapping_version(form_version, self.sw_source) + original = {"question_1": [{"trackedEntityAttribute": {"id": "tea1"}, "iaso_field": "instance.uuid"}]} + self.patch_question_mappings(mapping_version_id, original) + + imported = { # event tracker question inside a repeat group "question_4": [{"dataElement": {"id": "de4"}, "programStage": "stage1", "parent": "question_5"}], # event tracker repeat group @@ -371,17 +395,53 @@ def test_mappingversions_bulk_patch_and_undo(self): self.assertJSONResponse(response, status.HTTP_200_OK) self.assertEqual(self.get_question_mappings(mapping_version_id), {**original, **imported}) - undo = { - "question_2": original["question_2"], - "question_6": {"type": "neverMapped"}, - "question_3": {"action": "unmap"}, - "question_4": {"action": "unmap"}, - "question_5": {"action": "unmap"}, - } - response = self.patch_question_mappings(mapping_version_id, undo) + response = self.patch_question_mappings( + mapping_version_id, {"question_4": {"action": "unmap"}, "question_5": {"action": "unmap"}} + ) self.assertJSONResponse(response, status.HTTP_200_OK) self.assertEqual(self.get_question_mappings(mapping_version_id), original) + def test_mappingversions_patch_checks_shape_for_mapping_type(self): + """PATCH /mappingversions/: a question mapping must have the shape the exporter of its type reads""" + + self.client.force_authenticate(self.yoda) + form_version = self.create_form_version() + aggregate_id = self.create_mapping_version(form_version, self.sw_source)["id"] + tracker_id = self.create_tracker_mapping_version(form_version, self.sw_source) + data_element = {"id": "de1", "valueType": "NUMBER"} + + cases = [ + (aggregate_id, [{"dataElement": {"id": "de1"}}], "should not be a list for AGGREGATE mappings"), + ( + aggregate_id, + {"type": "multiple", "values": {"a": {"name": "no id"}}}, + "should map each choice to a data element id", + ), + (aggregate_id, {"type": "multiple"}, "should map each choice to a data element id"), + (tracker_id, data_element, "should be a list for EVENT_TRACKER mappings"), + (tracker_id, [], "should be a list for EVENT_TRACKER mappings"), + (tracker_id, ["de1"], "should only contain objects"), + (tracker_id, [{"foo": 1}], "should map a data element, a tracked entity attribute or a repeat group"), + ( + tracker_id, + [{"type": "repeat"}], + "should map a data element, a tracked entity attribute or a repeat group", + ), + ] + for mapping_version_id, question_mapping, error in cases: + with self.subTest(question_mapping=question_mapping): + response = self.patch_question_mappings(mapping_version_id, {"question_1": question_mapping}) + self.assertJSONResponse(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), {"question_mappings.question_1": error}) + + # never mapped markers and unmapping are valid for every mapping type + for mapping_version_id in (aggregate_id, tracker_id): + response = self.patch_question_mappings(mapping_version_id, {"question_1": {"type": "neverMapped"}}) + self.assertJSONResponse(response, status.HTTP_200_OK) + response = self.patch_question_mappings(mapping_version_id, {"question_1": {"action": "unmap"}}) + self.assertJSONResponse(response, status.HTTP_200_OK) + self.assertEqual(self.get_question_mappings(mapping_version_id), {}) + def test_mappingversions_bulk_patch_invalid_entry_saves_nothing(self): """PATCH /mappingversions/: one invalid question mapping rejects the whole import""" @@ -469,9 +529,6 @@ def test_mappingversions_patch_checks_questions_exist(self): valid = { "weight": data_element, - "household": [{"type": "repeat", "program_id": "p1"}], - # inside the repeat group - "age": [{"dataElement": {"id": "de2"}, "programStage": "s1", "parent": "household"}], "symptoms": {"type": "multiple", "values": {"fever": data_element}}, "symptoms__fever": data_element, } @@ -479,6 +536,16 @@ def test_mappingversions_patch_checks_questions_exist(self): self.assertJSONResponse(response, status.HTTP_200_OK) self.assertEqual(self.get_question_mappings(mapping_version_id), valid) + tracker_id = self.create_tracker_mapping_version(form_version, self.sw_source) + valid_tracker = { + "household": [{"type": "repeat", "program_id": "p1"}], + # inside the repeat group + "age": [{"dataElement": {"id": "de2"}, "programStage": "s1", "parent": "household"}], + } + response = self.patch_question_mappings(tracker_id, valid_tracker) + self.assertJSONResponse(response, status.HTTP_200_OK) + self.assertEqual(self.get_question_mappings(tracker_id), valid_tracker) + # a mapping left over for a question removed from the form can still be unmapped mapping_version = m.MappingVersion.objects.get(id=mapping_version_id) mapping_version.json["question_mappings"]["removed_question"] = data_element @@ -509,6 +576,18 @@ def create_form_version(self): return m.FormVersion.objects.all()[0] + def create_tracker_mapping_version(self, form_version, source): + # created directly: the API names every mapping version "", and (form_version, name) is unique + mapping, _ = m.Mapping.objects.get_or_create( + form=form_version.form, data_source=source, mapping_type=m.EVENT_TRACKER + ) + return m.MappingVersion.objects.create( + mapping=mapping, + form_version=form_version, + name="tracker", + json={"question_mappings": {}, "program_id": "PRGRM"}, + ).id + def create_mapping_version(self, form_version, source): resp = self.client.post( "/api/mappingversions/", From 6c274b0584d4fe76abcbc085a439a573c376fcdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Mestach?= Date: Thu, 1 Oct 2026 10:10:20 +0200 Subject: [PATCH 04/25] remove undo, users had the time thinker/review before clicking apply --- .../Iaso/domains/app/translations/en.json | 2 -- .../Iaso/domains/app/translations/es.json | 2 -- .../Iaso/domains/app/translations/fr.json | 2 -- .../ImportMappingsDialog.test.tsx | 1 - .../ImportMappings/MappingImportActions.tsx | 28 +------------------ .../domains/mappings/importMappings.test.ts | 8 +----- .../Iaso/domains/mappings/importMappings.ts | 3 -- .../js/apps/Iaso/domains/mappings/messages.js | 8 ------ .../js/apps/Iaso/domains/mappings/types.ts | 2 -- iaso/tests/api/test_mappingversions.py | 12 ++++---- 10 files changed, 8 insertions(+), 60 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/en.json b/hat/assets/js/apps/Iaso/domains/app/translations/en.json index a19f8476dd..3e0fa0ac5d 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/en.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/en.json @@ -1168,8 +1168,6 @@ "iaso.mappings.import.skipAll": "Skip all", "iaso.mappings.import.summary": "{added} to add, {overwritten} to overwrite, {ignored} not imported", "iaso.mappings.import.title": "Import mappings", - "iaso.mappings.import.undo": "Undo import", - "iaso.mappings.import.undone": "Import undone", "iaso.mappings.import.versionTitle": "Version {versionId}", "iaso.mappings.label.atLeastAMapping": "at least a mapping", "iaso.mappings.label.dataset": "Dataset", diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/es.json b/hat/assets/js/apps/Iaso/domains/app/translations/es.json index 4ea6b14d12..533c60ddb3 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/es.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/es.json @@ -971,8 +971,6 @@ "iaso.mappings.import.skipAll": "Omitir todo", "iaso.mappings.import.summary": "{added} para añadir, {overwritten} para sobrescribir, {ignored} no importados", "iaso.mappings.import.title": "Importar mapeos", - "iaso.mappings.import.undo": "Deshacer importación", - "iaso.mappings.import.undone": "Importación deshecha", "iaso.mappings.import.versionTitle": "Versión {versionId}", "iaso.mappings.label.atLeastAMapping": "al menos un mapeo", "iaso.mappings.label.dataset": "Conjunto de datos", diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json index b27458e0b7..c2d18f619b 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json @@ -1169,8 +1169,6 @@ "iaso.mappings.import.skipAll": "Tout ignorer", "iaso.mappings.import.summary": "{added} à ajouter, {overwritten} à écraser, {ignored} non importés", "iaso.mappings.import.title": "Importer des mappings", - "iaso.mappings.import.undo": "Annuler l'import", - "iaso.mappings.import.undone": "Import annulé", "iaso.mappings.import.versionTitle": "Version {versionId}", "iaso.mappings.label.atLeastAMapping": "au moins une correspondance", "iaso.mappings.label.dataset": "Dataset", diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx index cb9d35f3fe..be6d060449 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/ImportMappingsDialog.test.tsx @@ -102,7 +102,6 @@ describe('ImportMappingsDialog', () => { expect(onApply).toHaveBeenCalledWith( expect.objectContaining({ changes: { q2: de('c'), q3: de('d') }, - undo: { q2: de('b'), q3: { action: 'unmap' } }, }), ); }); diff --git a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/MappingImportActions.tsx b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/MappingImportActions.tsx index 9ac3137173..d7a8fe7555 100644 --- a/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/MappingImportActions.tsx +++ b/hat/assets/js/apps/Iaso/domains/mappings/components/ImportMappings/MappingImportActions.tsx @@ -1,7 +1,6 @@ import React, { FunctionComponent, useState } from 'react'; import DownloadIcon from '@mui/icons-material/Download'; import InputIcon from '@mui/icons-material/Input'; -import UndoIcon from '@mui/icons-material/Undo'; import { Box, Button } from '@mui/material'; import { useSafeIntl } from 'bluesquare-components'; import { openSnackBar } from '../../../../components/snackBars/EventDispatcher'; @@ -40,17 +39,13 @@ export const MappingImportActions: FunctionComponent = ({ }) => { const { formatMessage } = useSafeIntl(); const [open, setOpen] = useState(false); - // payload restoring the mappings as they were before the last import - const [undoPayload, setUndoPayload] = useState(); - const { mutateAsync: bulkUpdate, isLoading } = - useBulkUpdateQuestionMappings(); + const { mutateAsync: bulkUpdate } = useBulkUpdateQuestionMappings(); const onApply = async (plan: ImportPlan) => { await bulkUpdate({ mappingVersionId: mappingVersion.id, questionMappings: plan.changes, }); - setUndoPayload(plan.undo); openSnackBar( succesfullSnackBar( undefined, @@ -64,29 +59,8 @@ export const MappingImportActions: FunctionComponent = ({ ); }; - const onUndo = async () => { - await bulkUpdate({ - mappingVersionId: mappingVersion.id, - questionMappings: undoPayload ?? {}, - }); - setUndoPayload(undefined); - openSnackBar( - succesfullSnackBar(undefined, formatMessage(MESSAGES.importUndone)), - ); - }; - return ( - {undoPayload && ( - - )}