diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000..d0d212ad --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,27 @@ +## Commands + +- Run `docker-test` to execute tests. +- When creating migrations, run `docker-makemigrations`, then format the generated migration file with ruff. +- When adding or modifying backend strings wrapped in `_`, run `docker-make-translations`. + +## Code style + +- Write minimal comments. Code should be self-explanatory through naming; only comment when the WHY isn't obvious from the code itself. +- Favor clear naming over clever tricks. When a block of logic gets complicated, extract it into a well-named function/method/util instead of adding comments to explain it. + +## Backend + +- Favor fat serializers / thin views: validation and persistence logic (including `transaction.atomic()` for delete+recreate patterns) belongs in the serializer's `validate_*` / `save()` methods. Views should just call `serializer.is_valid()` / `serializer.save()` and return the response. + +## Testing + +- Name test methods to describe the scenario and expected behavior (e.g. `test_metric_value_list_filter_by_reference_year_includes_year_match_and_timeless`). Add a one-line docstring restating that in plain English when the name alone isn't enough. + +## Frontend + +- Keep styles in a separate object that satisfies `SxStyles`. +- Add/update translations whenever `MESSAGES` is added or modified. +- Type components as `FC` with named exports (not default exports). +- Wrap handlers passed to children or effects in `useCallback`. +- Don't use array index as a React key — use `React.Children.toArray` instead. +- Prefer arrow functions whenever possible. \ No newline at end of file diff --git a/api/composite_layers/serializers.py b/api/composite_layers/serializers.py index eb0918f1..222efef8 100644 --- a/api/composite_layers/serializers.py +++ b/api/composite_layers/serializers.py @@ -69,13 +69,28 @@ def validate_comments(self, comments): def validate_legend_config(self, legend_config): domain = legend_config.get("domain") if isinstance(legend_config, dict) else None colors = legend_config.get("range") if isinstance(legend_config, dict) else None - if not isinstance(domain, list) or not isinstance(colors, list) or len(domain) != len(colors): - raise serializers.ValidationError("Legend config must have matching 'domain' and 'range' lists.") + if not isinstance(domain, list) or not isinstance(colors, list): + raise serializers.ValidationError("Legend config must have 'domain' and 'range' lists.") return legend_config def validate(self, data): if self.instance is None and not (data.get("name") or "").strip(): raise serializers.ValidationError({"name": "This field is required."}) + if "legend_config" in data: + legend_type = data.get("legend_type") or ( + self.instance.legend_type if self.instance else CompositeLayer.LegendType.AUTO + ) + domain = data["legend_config"]["domain"] + colors = data["legend_config"]["range"] + # A threshold legend's range carries one extra colour, for the open-ended bucket above + # the last breakpoint (see `hasOpenEndedTopBucket` on the frontend). + expected_range_length = ( + len(domain) + 1 if legend_type == CompositeLayer.LegendType.THRESHOLD else len(domain) + ) + if len(colors) != expected_range_length: + raise serializers.ValidationError( + {"legend_config": "Legend config 'range' does not match 'domain' for this legend type."} + ) return data diff --git a/api/openhexa_data_layers/serializers.py b/api/openhexa_data_layers/serializers.py index 276a0055..8f21de89 100644 --- a/api/openhexa_data_layers/serializers.py +++ b/api/openhexa_data_layers/serializers.py @@ -83,7 +83,7 @@ def save(self): or layer["legend_config"] ) - metric_type, _created = MetricType.objects.update_or_create( + metric_type, created = MetricType.objects.update_or_create( account=account, code=layer["code"], defaults={ @@ -99,4 +99,13 @@ def save(self): "origin": MetricType.MetricTypeOrigin.OPENHEXA, }, ) + if created: + metric_type.is_complete = False + metric_type.save(update_fields=["is_complete"]) return metric_type + + +class CancelOpenHexaImportSerializer(serializers.Serializer): + """Which layer's in-flight value import to stop, by its ``MetricType`` id.""" + + metric_type_id = serializers.IntegerField() diff --git a/api/openhexa_data_layers/views.py b/api/openhexa_data_layers/views.py index 114cbd5f..10979138 100644 --- a/api/openhexa_data_layers/views.py +++ b/api/openhexa_data_layers/views.py @@ -10,7 +10,7 @@ from rest_framework.response import Response from iaso.api.tasks.serializers import TaskSerializer -from iaso.models import Task +from iaso.models import ALIVE_STATUSES, Task from plugins.snt_malaria.providers.openhexa_data_layers import ( METADATA_FILENAME, fetch_dataset_json, @@ -21,7 +21,11 @@ from .constants import IMPORT_TASK_NAME from .metadata import parse_data_layers from .permissions import OpenHexaDataLayerPermission -from .serializers import ImportOpenHexaDataLayerSerializer, OpenHexaDataLayerSerializer +from .serializers import ( + CancelOpenHexaImportSerializer, + ImportOpenHexaDataLayerSerializer, + OpenHexaDataLayerSerializer, +) logger = logging.getLogger(__name__) @@ -120,3 +124,23 @@ def import_status(self, request): for row in latest_per_layer } ) + + @action(detail=False, methods=["post"], url_path="cancel_import") + def cancel_import(self, request): + """Ask a data layer's in-flight value import to stop, if it hasn't already finished. + + Body: ``{"metric_type_id": }``. The task cooperatively checks the kill flag at its + next checkpoint, so this doesn't stop it instantly. + """ + account = request.user.iaso_profile.account + serializer = CancelOpenHexaImportSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + metric_type_id = serializer.validated_data["metric_type_id"] + + ( + Task.objects.filter(account=account, name=IMPORT_TASK_NAME, status__in=ALIVE_STATUSES) + .annotate(mt_id=KeyTextTransform("metric_type_id", KeyTransform("kwargs", "params"))) + .filter(mt_id=str(metric_type_id)) + .update(should_be_killed=True) + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/js/src/constants/taskStatus.ts b/js/src/constants/taskStatus.ts index a7ab19b5..b5105ac9 100644 --- a/js/src/constants/taskStatus.ts +++ b/js/src/constants/taskStatus.ts @@ -29,3 +29,7 @@ export const isFailedTaskStatus = (status?: string): boolean => /** Task will not change any more (succeeded or failed). */ export const isTerminalTaskStatus = (status?: string): boolean => includesStatus(TERMINAL, status); + +/** Task finished and produced data. */ +export const isSuccessTaskStatus = (status?: string): boolean => + status === TASK_STATUS.SUCCESS; diff --git a/js/src/constants/translations/en.json b/js/src/constants/translations/en.json index ecb50fe0..fccf8913 100644 --- a/js/src/constants/translations/en.json +++ b/js/src/constants/translations/en.json @@ -77,10 +77,7 @@ "iaso.snt_malaria.settings.dataLayers.errors.required": "This field is required", "iaso.snt_malaria.settings.dataLayers.errors.noWhitespace": "Whitespace not allowed", "iaso.snt_malaria.settings.dataLayers.errors.scaleItemsCount": "Invalid number of scale items for the selected legend type", - "iaso.snt_malaria.settings.dataLayers.errors.uniqueCodeErrorHeadline": "Layer with this code already exists.", "iaso.snt_malaria.settings.dataLayers.errors.uniqueCodeError": "A Layer with this code already exists. Please choose a different code.", - "iaso.snt_malaria.settings.dataLayers.errors.codeImmutableHeadline": "Layer code is immutable.", - "iaso.snt_malaria.settings.dataLayers.errors.genericErrorHeadline": "An unexpected error occurred.", "iaso.snt_malaria.settings.dataLayers.errors.genericError": "Please try again.", "iaso.snt_malaria.settings.dataLayers.errors.maxLength": "Value must be at most {max} characters long", "iaso.snt_malaria.settings.intervention.errors.negativeValueNotAllowed": "Negative value not allowed", @@ -96,10 +93,6 @@ "iaso.snt_malaria.onboarding.skip": "Skip", "iaso.snt_malaria.onboarding.goToDocs": "View documentation", "iaso.snt_malaria.settings.dataLayers.createLayer": "Create Layer", - "iaso.snt_malaria.settings.dataLayers.continueToEditor": "Continue", - "iaso.snt_malaria.settings.dataLayers.generalSectionTitle": "General", - "iaso.snt_malaria.settings.dataLayers.legendSectionTitle": "Legend & scale", - "iaso.snt_malaria.settings.dataLayers.layerType": "Layer type", "iaso.snt_malaria.settings.dataLayers.layerTypeData": "Standard data layer", "iaso.snt_malaria.settings.dataLayers.layerTypeDataInfo": "A layer whose data is imported from a CSV file or OpenHEXA. The data key needs to match the source data's.", "iaso.snt_malaria.settings.dataLayers.layerTypeCompositeInfo": "A layer composed of other layers. Combine any number of input layers using operations and transformations.", @@ -107,15 +100,13 @@ "iaso.snt_malaria.settings.dataLayers.layerTypeOpenHexaInfo": "A layer defined in your OpenHexa configuration. Its metadata and default legend are filled in from OpenHexa.", "iaso.snt_malaria.settings.dataLayers.openHexaDataLayer": "Data layer", "iaso.snt_malaria.settings.dataLayers.openHexaDataLayerHelp": "Metadata and default legend come from this data layer.", - "iaso.snt_malaria.settings.dataLayers.openHexaFieldsReadOnly": "read-only · from data layer", - "iaso.snt_malaria.settings.dataLayers.openHexaColorsEditable": "colors editable", "iaso.snt_malaria.settings.dataLayers.openHexaDataLayersError": "Could not load data layers from OpenHexa.", "iaso.snt_malaria.settings.dataLayers.openHexaLayersUnimportable": "Some OpenHexa data layers cannot be imported and are hidden from the list:", "iaso.snt_malaria.settings.dataLayers.openHexaLayersAlreadyImported": "Already imported (hidden): {layers}", - "iaso.snt_malaria.settings.dataLayers.openHexaImportStarted": "Import started. The layer values will appear once the task completes.", "iaso.snt_malaria.settings.dataLayers.refreshFromOpenHexa": "Refresh from OpenHexa", "iaso.snt_malaria.settings.dataLayers.importRunning": "Importing data from OpenHexa…", "iaso.snt_malaria.settings.dataLayers.importFailed": "OpenHexa import failed", + "iaso.snt_malaria.settings.dataLayers.layerSetupIncomplete": "Setup was not completed for this layer", "iaso.snt_malaria.settings.dataLayers.searchByName": "Search by name", "iaso.snt_malaria.settings.dataLayers.noLayersFound": "No layers found.", "iaso.snt_malaria.settings.dataLayers.expandCategory": "Expand category", @@ -548,5 +539,35 @@ "iaso.snt_malaria.planning.comparison.totalCostColumnLabel": "Total cost", "iaso.snt_malaria.planning.comparison.versusLabel": "vs", "iaso.snt_malaria.planning.comparison.addScenario": "Add scenario", - "iaso.snt_malaria.planning.comparison.removeScenario": "Remove scenario" + "iaso.snt_malaria.planning.comparison.removeScenario": "Remove scenario", + "iaso.snt_malaria.settings.dataLayers.wizard.title": "New data layer", + "iaso.snt_malaria.settings.dataLayers.wizard.stepType": "Type", + "iaso.snt_malaria.settings.dataLayers.wizard.stepDetails": "Details", + "iaso.snt_malaria.settings.dataLayers.wizard.stepData": "Data", + "iaso.snt_malaria.settings.dataLayers.wizard.stepGraph": "Graph", + "iaso.snt_malaria.settings.dataLayers.wizard.stepLegend": "Legend", + "iaso.snt_malaria.settings.dataLayers.wizard.back": "Back", + "iaso.snt_malaria.settings.dataLayers.wizard.typeQuestion": "What kind of layer is this?", + "iaso.snt_malaria.settings.dataLayers.wizard.importCsvHint": "Upload a CSV to fill the table below, then fine-tune it there before saving.", + "iaso.snt_malaria.settings.dataLayers.wizard.addYear": "Add year", + "iaso.snt_malaria.settings.dataLayers.wizard.removeYear": "Remove {year}", + "iaso.snt_malaria.settings.dataLayers.wizard.manualPaste": "Paste from clipboard", + "iaso.snt_malaria.settings.dataLayers.wizard.manualFilledCount": "{filled} of {total} values filled", + "iaso.snt_malaria.settings.dataLayers.wizard.manualPasteHelp": "Paste one value per line, in the district order shown, to fill a year column.", + "iaso.snt_malaria.settings.dataLayers.wizard.valueAboveHundred": "Above 100 while units are percent", + "iaso.snt_malaria.settings.dataLayers.wizard.gridRegion": "Region", + "iaso.snt_malaria.settings.dataLayers.wizard.gridDistrict": "District", + "iaso.snt_malaria.settings.dataLayers.wizard.summaryAddsToCategory": "Creating this layer adds it to {category}.", + "iaso.snt_malaria.settings.dataLayers.wizard.openHexaManualRefresh": "This layer keeps the snapshot you import until someone refreshes it from OpenHexa.", + "iaso.snt_malaria.settings.dataLayers.wizard.discardLayerConfirm": "Nothing is saved yet — leaving now discards this layer.", + "iaso.snt_malaria.settings.dataLayers.wizard.discardGraphConfirm": "Leaving now discards this layer and its graph.", + "iaso.snt_malaria.settings.dataLayers.wizard.discardCreatedConfirm": "Leaving now removes this layer and any values already imported for it.", + "iaso.snt_malaria.settings.dataLayers.wizard.openHexaImportInfo": "The layer was created and its values are loading from OpenHexa in the background — you can carry on through the wizard while it runs.", + "iaso.snt_malaria.settings.dataLayers.wizard.openHexaImportComplete": "Import complete — check the result table.", + "iaso.snt_malaria.settings.dataLayers.wizard.previewPlaceholder": "A preview of your layer appears here once you add its data.", + "iaso.snt_malaria.settings.dataLayers.wizard.next": "Next: {step}", + "iaso.snt_malaria.settings.dataLayers.wizard.saveChanges": "Save changes", + "iaso.snt_malaria.settings.dataLayers.wizard.discardEditConfirm": "Discard your unsaved changes to this layer?", + "iaso.snt_malaria.settings.dataLayers.wizard.backGraphConfirm": "Going back now will discard any unsaved changes to the composite graph.", + "iaso.snt_malaria.settings.dataLayers.openHexaFieldsReadOnly": "Filled in from OpenHexa — read-only." } diff --git a/js/src/constants/translations/fr.json b/js/src/constants/translations/fr.json index de310eb9..ede0ff15 100644 --- a/js/src/constants/translations/fr.json +++ b/js/src/constants/translations/fr.json @@ -76,10 +76,7 @@ "iaso.snt_malaria.settings.dataLayers.errors.required": "Ce champ est obligatoire", "iaso.snt_malaria.settings.dataLayers.errors.noWhitespace": "Les espaces ne sont pas autorisés", "iaso.snt_malaria.settings.dataLayers.errors.scaleItemsCount": "Nombre invalide d'éléments d'échelle pour le type de légende sélectionné", - "iaso.snt_malaria.settings.dataLayers.errors.uniqueCodeErrorHeadline": "Une couche avec ce code existe déjà.", "iaso.snt_malaria.settings.dataLayers.errors.uniqueCodeError": "Une couche avec ce code existe déjà. Veuillez choisir un code différent.", - "iaso.snt_malaria.settings.dataLayers.errors.codeImmutableHeadline": "Le code de la couche est immuable.", - "iaso.snt_malaria.settings.dataLayers.errors.genericErrorHeadline": "Une erreur inattendue s'est produite.", "iaso.snt_malaria.settings.dataLayers.errors.genericError": "Veuillez réessayer.", "iaso.snt_malaria.settings.dataLayers.errors.maxLength": "La valeur doit comporter au maximum {max} caractères", "iaso.snt_malaria.settings.intervention.errors.negativeValueNotAllowed": "La valeur négative n'est pas autorisée", @@ -95,10 +92,6 @@ "iaso.snt_malaria.onboarding.skip": "Passer", "iaso.snt_malaria.onboarding.goToDocs": "Voir la documentation", "iaso.snt_malaria.settings.dataLayers.createLayer": "Créer une couche", - "iaso.snt_malaria.settings.dataLayers.continueToEditor": "Continuer", - "iaso.snt_malaria.settings.dataLayers.generalSectionTitle": "Général", - "iaso.snt_malaria.settings.dataLayers.legendSectionTitle": "Légende et échelle", - "iaso.snt_malaria.settings.dataLayers.layerType": "Type de couche", "iaso.snt_malaria.settings.dataLayers.layerTypeData": "Couche de données standard", "iaso.snt_malaria.settings.dataLayers.layerTypeDataInfo": "Une couche dont les données sont importées depuis un fichier CSV ou OpenHEXA. La clé de données doit correspondre à celle des données source.", "iaso.snt_malaria.settings.dataLayers.layerTypeCompositeInfo": "Une couche composée d'autres couches. Combinez un nombre quelconque de couches d'entrée à l'aide d'opérations et de transformations.", @@ -106,15 +99,13 @@ "iaso.snt_malaria.settings.dataLayers.layerTypeOpenHexaInfo": "Une couche définie dans votre configuration OpenHexa. Ses métadonnées et sa légende par défaut sont renseignées depuis OpenHexa.", "iaso.snt_malaria.settings.dataLayers.openHexaDataLayer": "Couche de données", "iaso.snt_malaria.settings.dataLayers.openHexaDataLayerHelp": "Les métadonnées et la légende par défaut proviennent de cette couche de données.", - "iaso.snt_malaria.settings.dataLayers.openHexaFieldsReadOnly": "lecture seule · depuis la couche de données", - "iaso.snt_malaria.settings.dataLayers.openHexaColorsEditable": "couleurs modifiables", "iaso.snt_malaria.settings.dataLayers.openHexaDataLayersError": "Impossible de charger les couches de données depuis OpenHexa.", "iaso.snt_malaria.settings.dataLayers.openHexaLayersUnimportable": "Certaines couches de données OpenHexa ne peuvent pas être importées et sont masquées de la liste :", "iaso.snt_malaria.settings.dataLayers.openHexaLayersAlreadyImported": "Déjà importées (masquées) : {layers}", - "iaso.snt_malaria.settings.dataLayers.openHexaImportStarted": "Import lancé. Les valeurs de la couche apparaîtront une fois la tâche terminée.", "iaso.snt_malaria.settings.dataLayers.refreshFromOpenHexa": "Actualiser depuis OpenHexa", "iaso.snt_malaria.settings.dataLayers.importRunning": "Import des données depuis OpenHexa…", "iaso.snt_malaria.settings.dataLayers.importFailed": "Échec de l'import OpenHexa", + "iaso.snt_malaria.settings.dataLayers.layerSetupIncomplete": "La configuration de cette couche n'a pas été terminée", "iaso.snt_malaria.settings.dataLayers.searchByName": "Rechercher par nom", "iaso.snt_malaria.settings.dataLayers.noLayersFound": "Aucune couche trouvée.", "iaso.snt_malaria.settings.dataLayers.expandCategory": "Développer la catégorie", @@ -547,5 +538,35 @@ "iaso.snt_malaria.planning.comparison.totalCostColumnLabel": "Coût total", "iaso.snt_malaria.planning.comparison.versusLabel": "vs", "iaso.snt_malaria.planning.comparison.addScenario": "Ajouter un scénario", - "iaso.snt_malaria.planning.comparison.removeScenario": "Supprimer le scénario" + "iaso.snt_malaria.planning.comparison.removeScenario": "Supprimer le scénario", + "iaso.snt_malaria.settings.dataLayers.wizard.title": "Nouvelle couche de données", + "iaso.snt_malaria.settings.dataLayers.wizard.stepType": "Type", + "iaso.snt_malaria.settings.dataLayers.wizard.stepDetails": "Détails", + "iaso.snt_malaria.settings.dataLayers.wizard.stepData": "Données", + "iaso.snt_malaria.settings.dataLayers.wizard.stepGraph": "Graphe", + "iaso.snt_malaria.settings.dataLayers.wizard.stepLegend": "Légende", + "iaso.snt_malaria.settings.dataLayers.wizard.back": "Retour", + "iaso.snt_malaria.settings.dataLayers.wizard.typeQuestion": "Quel type de couche ?", + "iaso.snt_malaria.settings.dataLayers.wizard.importCsvHint": "Importez un CSV pour remplir le tableau ci-dessous, puis ajustez-le avant d'enregistrer.", + "iaso.snt_malaria.settings.dataLayers.wizard.addYear": "Ajouter une année", + "iaso.snt_malaria.settings.dataLayers.wizard.removeYear": "Supprimer {year}", + "iaso.snt_malaria.settings.dataLayers.wizard.manualPaste": "Coller depuis le presse-papiers", + "iaso.snt_malaria.settings.dataLayers.wizard.manualFilledCount": "{filled} valeurs sur {total} renseignées", + "iaso.snt_malaria.settings.dataLayers.wizard.manualPasteHelp": "Collez une valeur par ligne, dans l'ordre des districts affichés, pour remplir une colonne d'année.", + "iaso.snt_malaria.settings.dataLayers.wizard.valueAboveHundred": "Supérieur à 100 alors que l'unité est le pourcentage", + "iaso.snt_malaria.settings.dataLayers.wizard.gridRegion": "Région", + "iaso.snt_malaria.settings.dataLayers.wizard.gridDistrict": "District", + "iaso.snt_malaria.settings.dataLayers.wizard.summaryAddsToCategory": "Créer cette couche l'ajoute à {category}.", + "iaso.snt_malaria.settings.dataLayers.wizard.openHexaManualRefresh": "Cette couche conserve l'instantané importé jusqu'à ce que quelqu'un l'actualise depuis OpenHexa.", + "iaso.snt_malaria.settings.dataLayers.wizard.discardLayerConfirm": "Rien n'est encore enregistré — quitter maintenant abandonne cette couche.", + "iaso.snt_malaria.settings.dataLayers.wizard.discardGraphConfirm": "Quitter maintenant abandonne cette couche et son graphe.", + "iaso.snt_malaria.settings.dataLayers.wizard.discardCreatedConfirm": "Quitter maintenant supprime cette couche et les valeurs déjà importées.", + "iaso.snt_malaria.settings.dataLayers.wizard.openHexaImportInfo": "La couche a été créée et ses valeurs se chargent depuis OpenHexa en arrière-plan — vous pouvez continuer l'assistant pendant ce temps.", + "iaso.snt_malaria.settings.dataLayers.wizard.openHexaImportComplete": "Import terminé — consultez le tableau de résultats.", + "iaso.snt_malaria.settings.dataLayers.wizard.previewPlaceholder": "Un aperçu de votre couche apparaît ici une fois ses données ajoutées.", + "iaso.snt_malaria.settings.dataLayers.wizard.next": "Suivant : {step}", + "iaso.snt_malaria.settings.dataLayers.wizard.saveChanges": "Enregistrer les modifications", + "iaso.snt_malaria.settings.dataLayers.wizard.discardEditConfirm": "Abandonner vos modifications non enregistrées de cette couche ?", + "iaso.snt_malaria.settings.dataLayers.wizard.backGraphConfirm": "Revenir en arrière maintenant annulera les modifications non enregistrées du graphe composite.", + "iaso.snt_malaria.settings.dataLayers.openHexaFieldsReadOnly": "Rempli depuis OpenHexa — lecture seule." } diff --git a/js/src/domains/compositeLayerEditor/components/EditorHeader.tsx b/js/src/domains/compositeLayerEditor/components/EditorHeader.tsx index 530af60c..1c453e3a 100644 --- a/js/src/domains/compositeLayerEditor/components/EditorHeader.tsx +++ b/js/src/domains/compositeLayerEditor/components/EditorHeader.tsx @@ -23,6 +23,7 @@ type Props = { onCancel: () => void; onSave: () => void; isSaving: boolean; + hideActions?: boolean; }; /** Header bar of the composite editor: sidebar toggle, title, and cancel/save actions. */ @@ -34,6 +35,7 @@ export const EditorHeader: FC = ({ onCancel, onSave, isSaving, + hideActions = false, }) => { const { formatMessage } = useSafeIntl(); return ( @@ -68,23 +70,27 @@ export const EditorHeader: FC = ({ - - + {!hideActions && ( + <> + + + + )} ); diff --git a/js/src/domains/compositeLayerEditor/components/NodeMapPreview.tsx b/js/src/domains/compositeLayerEditor/components/NodeMapPreview.tsx index 1dbea75b..e86b5217 100644 --- a/js/src/domains/compositeLayerEditor/components/NodeMapPreview.tsx +++ b/js/src/domains/compositeLayerEditor/components/NodeMapPreview.tsx @@ -66,7 +66,11 @@ export const NodeMapPreview: FC = ({ // A pinned node has nothing to browse: show only its pinned year, no picker. Otherwise let the // preview browse whichever years are actually available. const yearPicker: YearPicker = isPinned - ? { values: (metricValues ?? []).filter(value => value.year === pinnedYear) } + ? { + values: (metricValues ?? []).filter( + value => value.year === pinnedYear, + ), + } : { values: displayedValues, yearOptions: isMultiYear ? years : undefined, diff --git a/js/src/domains/compositeLayerEditor/hooks/useDeleteCompositeLayer.ts b/js/src/domains/compositeLayerEditor/hooks/useDeleteCompositeLayer.ts new file mode 100644 index 00000000..82dd49e4 --- /dev/null +++ b/js/src/domains/compositeLayerEditor/hooks/useDeleteCompositeLayer.ts @@ -0,0 +1,13 @@ +import { deleteRequest } from 'bluesquare-components'; +import { UseMutationResult } from 'react-query'; +import { useSnackMutation } from 'Iaso/libs/apiHooks'; + +export const useDeleteCompositeLayer = (): UseMutationResult => + useSnackMutation({ + invalidateQueryKey: ['metricTypes', 'metricCategories', 'compositeLayers'], + mutationFn: (compositeLayerId: number) => + deleteRequest(`/api/snt_malaria/composite_layers/${compositeLayerId}/`), + // Used both for an explicit discard and for silently dropping a draft + // shell the wizard is replacing; neither is worth a "Saved successfully" toast. + showSuccessSnackBar: false, + }); diff --git a/js/src/domains/compositeLayerEditor/hooks/useSaveCompositeLayer.ts b/js/src/domains/compositeLayerEditor/hooks/useSaveCompositeLayer.ts index 92ed1671..f2e9b612 100644 --- a/js/src/domains/compositeLayerEditor/hooks/useSaveCompositeLayer.ts +++ b/js/src/domains/compositeLayerEditor/hooks/useSaveCompositeLayer.ts @@ -7,14 +7,13 @@ import { SaveCompositeLayerPayload, } from '../types/compositeLayer'; -export const useSaveCompositeLayer = (): UseMutationResult< - CompositeLayer, - unknown, - SaveCompositeLayerPayload -> => { +export const useSaveCompositeLayer = ( + silent = false, +): UseMutationResult => { const queryClient = useQueryClient(); return useSnackMutation({ snackSuccessMessage: MESSAGES.saveSuccess, + showSuccessSnackBar: !silent, // Invalidate the data layers list so the composite appears/updates immediately. invalidateQueryKey: ['metricTypes'], options: { diff --git a/js/src/domains/compositeLayerEditor/index.tsx b/js/src/domains/compositeLayerEditor/index.tsx index 52b761a8..121b261f 100644 --- a/js/src/domains/compositeLayerEditor/index.tsx +++ b/js/src/domains/compositeLayerEditor/index.tsx @@ -113,6 +113,7 @@ type Props = { sidebarCollapsed?: boolean; /** Toggles the data layers sidebar (mirrors the scenario editor's rules-panel toggle). */ onToggleSidebar?: () => void; + hideActions?: boolean; }; // Imperative handle for the sibling AI chat panel (rendered by the parent) to push a generated @@ -124,6 +125,7 @@ export type CompositeLayerEditorHandle = { /** Restores a graph captured earlier by `getCurrentGraph` (AI chat "revert"); `null` clears * the canvas back to an empty editor. */ restoreGraph: (graph: CurrentGraph | null) => void; + saveGraph: () => void; }; export const CompositeLayerEditor = forwardRef< @@ -137,6 +139,7 @@ export const CompositeLayerEditor = forwardRef< compositeLayerId, sidebarCollapsed = false, onToggleSidebar, + hideActions = false, }, ref, ) => { @@ -508,20 +511,7 @@ export const CompositeLayerEditor = forwardRef< [handleGenerateGraph, remountWithGraph], ); - useImperativeHandle( - ref, - () => ({ - applyGeneratedGraph: handleGenerateGraph, - // nodesRef is kept current by handleChange, so this reflects the live canvas - // (including hand-built graphs that never came from the AI). - getCurrentGraph: () => - extractGraphSpecFromFlume(nodesRef.current), - restoreGraph: handleRestoreGraph, - }), - [handleGenerateGraph, handleRestoreGraph], - ); - - const handleSave = () => { + const handleSave = useCallback(() => { saveCompositeLayer( { graph: nodesRef.current, @@ -539,7 +529,21 @@ export const CompositeLayerEditor = forwardRef< }, }, ); - }; + }, [saveCompositeLayer, compositeLayerId, onSaved, onClose]); + + useImperativeHandle( + ref, + () => ({ + applyGeneratedGraph: handleGenerateGraph, + // nodesRef is kept current by handleChange, so this reflects the live canvas + // (including hand-built graphs that never came from the AI). + getCurrentGraph: () => + extractGraphSpecFromFlume(nodesRef.current), + restoreGraph: handleRestoreGraph, + saveGraph: handleSave, + }), + [handleGenerateGraph, handleRestoreGraph, handleSave], + ); const headerTitle = existingLayer?.name || formatMessage(MESSAGES.title); @@ -577,6 +581,7 @@ export const CompositeLayerEditor = forwardRef< onCancel={onClose} onSave={handleSave} isSaving={isSaving} + hideActions={hideActions} /> } > diff --git a/js/src/domains/compositeLayerEditor/nodeLibrary/CompositeSidebarTabs.tsx b/js/src/domains/compositeLayerEditor/nodeLibrary/CompositeSidebarTabs.tsx index d6ff1136..56adc798 100644 --- a/js/src/domains/compositeLayerEditor/nodeLibrary/CompositeSidebarTabs.tsx +++ b/js/src/domains/compositeLayerEditor/nodeLibrary/CompositeSidebarTabs.tsx @@ -7,7 +7,11 @@ import { MESSAGES } from '../messages'; export type CompositeSidebarTab = 'library' | 'ai'; const styles = { - noTabsTitle: { display: 'flex', alignItems: 'center', minHeight: theme => theme.spacing(6) }, + noTabsTitle: { + display: 'flex', + alignItems: 'center', + minHeight: theme => theme.spacing(6), + }, tab: { textTransform: 'none', minHeight: 48 }, } satisfies SxStyles; diff --git a/js/src/domains/dataLayers/dataLayerForm/DataLayerDialog.tsx b/js/src/domains/dataLayers/dataLayerForm/DataLayerDialog.tsx deleted file mode 100644 index 0ed9eb30..00000000 --- a/js/src/domains/dataLayers/dataLayerForm/DataLayerDialog.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import React, { FC, useCallback, useMemo, useState } from 'react'; -import { Alert, AlertTitle } from '@mui/material'; -import { - ConfirmCancelModal, - DropdownOptions, - IntlMessage, - useSafeIntl, -} from 'bluesquare-components'; -import { isConcreteLegend } from '../../../constants/legend'; -import { ExtendedFormikProvider } from '../../../hooks/useGetExtendedFormikContext'; -import { useSaveCompositeLayer } from '../../compositeLayerEditor/hooks/useSaveCompositeLayer'; -import { CompositeLayerListItem } from '../../compositeLayerEditor/types/compositeLayer'; -import { useCreateOrUpdateMetricType } from '../hooks/useCreateOrUpdateMetricType'; -import { useGetMetricTypes } from '../hooks/useGetMetrics'; -import { useImportOpenHexaDataLayer } from '../hooks/useImportOpenHexaDataLayer'; -import { useMetricTypeFormState } from '../hooks/useMetricTypeFormState'; -import { MESSAGES } from '../messages'; -import { MetricType, MetricTypeFormModel } from '../types/metrics'; -import { MetricTypeForm } from './DataLayerForm'; -import { - initialTopColor, - legendConfigFromForm, - scaleFromDomainRange, -} from './legendScale'; - -interface MetricTypeDialogProps { - open: boolean; - closeDialog: () => void; - metricType?: MetricType; - categoryOptions: DropdownOptions[]; - showCompositeLayers?: boolean; - showOpenHexaLayers?: boolean; - /** Composite layer record of the edited layer, when it is a composite. */ - compositeLayer?: CompositeLayerListItem; - /** Called with the id of a freshly created composite, to continue into the node editor. */ - onCompositeCreated?: (compositeLayerId: number) => void; -} - -export const DataLayerDialog: FC = ({ - open, - closeDialog, - metricType = undefined, - categoryOptions, - showCompositeLayers = false, - showOpenHexaLayers = false, - compositeLayer = undefined, - onCompositeCreated = undefined, -}) => { - const [errorMessage, setErrorMessage] = useState(); - const [errorHeadline, setErrorHeadline] = useState< - IntlMessage | undefined - >(); - const { formatMessage } = useSafeIntl(); - - const isEditingComposite = compositeLayer !== undefined; - const { mutate: saveCompositeLayer } = useSaveCompositeLayer(); - - // Code is unique per account (utility types like population included), so an OpenHexa - // layer whose code is already taken can't be imported again. - const { data: existingMetricTypes } = useGetMetricTypes(true); - const existingCodes = useMemo( - () => new Set((existingMetricTypes ?? []).map(mt => mt.code)), - [existingMetricTypes], - ); - - const setErrorCode = useCallback( - (code?: string) => { - if (!code) { - setErrorMessage(undefined); - setErrorHeadline(undefined); - return; - } - - setErrorMessage( - MESSAGES[code as keyof typeof MESSAGES] ?? - MESSAGES.genericError, - ); - setErrorHeadline( - MESSAGES[`${code}Headline` as keyof typeof MESSAGES] ?? - MESSAGES.genericErrorHeadline, - ); - }, - [setErrorMessage, setErrorHeadline], - ); - - const { mutate: submitMetricType } = useCreateOrUpdateMetricType({ - onError: (errorCode: string) => setErrorCode(`${errorCode}Error`), - onSuccess: () => { - setErrorCode(undefined); - closeDialog(); - }, - }); - - // Creating an OpenHexa layer upserts the shell and launches the value-import task. - const { mutate: importOpenHexaDataLayer } = useImportOpenHexaDataLayer({ - onSuccess: () => { - setErrorCode(undefined); - closeDialog(); - }, - }); - - const metricTypeFormModel = useMemo(() => { - if (metricType) { - // A composite's legend lives on its composite layer record; the MetricType holds what - // the last run resolved it to. - const legendSource = isEditingComposite - ? compositeLayer.legend_config - : metricType.legend_config; - return { - id: metricType.id, - name: metricType.name, - code: metricType.code, - description: metricType.description, - source: metricType.source, - units: metricType.units, - unit_symbol: metricType.unit_symbol, - comments: metricType.comments, - category: metricType.category, - legend_type: isEditingComposite - ? compositeLayer.legend_type - : metricType.legend_type, - origin: metricType.origin, - is_population: metricType.metric_kind === 'population', - is_composite: isEditingComposite, - legend_config: scaleFromDomainRange(legendSource), - legend_top_color: initialTopColor(legendSource), - }; - } - return undefined; - }, [metricType, isEditingComposite, compositeLayer]); - - // A composite's values come from its graph, so only the metadata + legend are saved here. - const submitCompositeLayer = (values: MetricTypeFormModel) => { - saveCompositeLayer( - { - id: compositeLayer?.id, - name: values.name, - category: values.category, - description: values.description, - units: values.units, - unit_symbol: values.unit_symbol, - is_population: !!values.is_population, - legend_type: values.legend_type, - legend_config: isConcreteLegend(values.legend_type) - ? legendConfigFromForm( - values.legend_type, - values.legend_config, - values.legend_top_color, - ) - : undefined, - }, - { - onSuccess: saved => { - setErrorCode(undefined); - closeDialog(); - if (!compositeLayer) { - onCompositeCreated?.(saved.id); - } - }, - }, - ); - }; - - const submitDataLayer = ({ - is_composite: _isComposite, - legend_top_color, - ...values - }: MetricTypeFormModel) => { - const legend_config = legendConfigFromForm( - values.legend_type, - values.legend_config, - legend_top_color, - ); - // Creating an OpenHexa layer goes through the import endpoint (shell + task); - // editing one (id present) still PATCHes /api/metrictypes/ - colours only, no re-download. - if (values.origin === 'openhexa' && !values.id) { - importOpenHexaDataLayer({ code: values.code, legend_config }); - return; - } - submitMetricType({ - ...values, - metric_kind: values.is_population ? 'population' : 'any', - legend_config, - }); - }; - - const onSubmit = (values: MetricTypeFormModel) => - values.is_composite - ? submitCompositeLayer(values) - : submitDataLayer(values); - - const formik = useMetricTypeFormState(metricTypeFormModel, onSubmit); - - const handleCancel = () => { - setErrorCode(undefined); - closeDialog(); - }; - - // Creating a composite saves it, then continues into the node editor. - const isCreatingComposite = !metricType && formik.values.is_composite; - const confirmMessage = - (metricType && MESSAGES.editLayer) || - (isCreatingComposite && MESSAGES.continueToEditor) || - MESSAGES.createLayer; - - return ( - - - - - {errorMessage && ( - - {formatMessage(errorHeadline)} - {formatMessage(errorMessage)} - - )} - - ); -}; diff --git a/js/src/domains/dataLayers/dataLayerForm/DataLayerForm.tsx b/js/src/domains/dataLayers/dataLayerForm/DataLayerForm.tsx deleted file mode 100644 index b3a07864..00000000 --- a/js/src/domains/dataLayers/dataLayerForm/DataLayerForm.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import React, { FC, useCallback, useEffect, useMemo } from 'react'; -import { Alert, Box, Grid, Typography } from '@mui/material'; -import { useSafeIntl } from 'bluesquare-components'; -import InputComponent from 'Iaso/components/forms/InputComponent'; -import { useTranslatedErrors } from 'Iaso/libs/validation'; - -import { SxStyles } from 'Iaso/types/general'; -import { - isConcreteLegend, - LEGEND_TYPE_MAX_ITEMS, - LEGEND_TYPE_MIN_ITEMS, - LegendTypes, -} from '../../../constants/legend'; -import { useGetExtendedFormikContext } from '../../../hooks/useGetExtendedFormikContext'; -import { getCompositeLegendOptions } from '../../compositeLayerEditor/utils/legendOptions'; -import { useGetLegendTypes } from '../../planning/hooks/useGetLegendTypes'; -import { useGetOpenHexaDataLayers } from '../hooks/useGetOpenHexaDataLayers'; -import { MESSAGES } from '../messages'; -import { MetricTypeFormModel, OpenHexaDataLayer } from '../types/metrics'; -import { LAYER_TYPES, LayerTypeSelect } from './LayerTypeSelect'; -import { LegendConfigForm } from './LegendConfigForm'; -import { DEFAULT_COLOR } from './legendScale'; -import { openHexaLayerToFormPatch } from './openHexaAutofill'; - -type MetricTypeFormProps = { - metricType?: MetricTypeFormModel; - categoryOptions: { label: string; value: string }[]; - showCompositeLayers?: boolean; - showOpenHexaLayers?: boolean; - /** Codes of the account's existing metric types - an OpenHexa layer whose code is - * already taken can't be imported again (code is unique per account). */ - existingCodes?: Set; -}; - -const LAYER_TYPE_INFO: Record = { - [LAYER_TYPES.COMPOSITE]: MESSAGES.layerTypeCompositeInfo, - [LAYER_TYPES.OPENHEXA]: MESSAGES.layerTypeOpenHexaInfo, - [LAYER_TYPES.DATA]: MESSAGES.layerTypeDataInfo, -}; - -const styles = { - layerTypeRow: { - display: 'flex', - gap: 2, - alignItems: 'center', - mt: 2, - }, - layerTypeSelect: { - flexGrow: 1, - }, - populationCheckbox: { - flexShrink: 0, - whiteSpace: 'nowrap', - }, - layerTypeInfo: { - display: 'block', - mt: 0.5, - }, - sectionTitle: { - mt: 3, - mb: 1, - }, - firstSectionTitle: { - mt: 2, - mb: 0, - }, - readOnlyHint: { - ml: 1, - textTransform: 'none', - fontWeight: 'normal', - }, - unimportableAlert: { - mt: 1, - '& ul': { m: 0, pl: 2.5 }, - }, -} satisfies SxStyles; - -const NO_CODES: Set = new Set(); - -export const MetricTypeForm: FC = ({ - metricType = undefined, - categoryOptions, - showCompositeLayers = false, - showOpenHexaLayers = false, - existingCodes = NO_CODES, -}) => { - const { formatMessage } = useSafeIntl(); - const { data: legendTypeOptions, isLoading: loadingLegendTypeOptions } = - useGetLegendTypes(); - - const { - values, - setFieldValueAndState, - setChildFieldValueAndState, - errors, - touched, - addChildValue, - removeChildValue, - } = useGetExtendedFormikContext(); - - const getErrors = useTranslatedErrors({ - errors, - touched, - formatMessage, - messages: MESSAGES, - }); - - const isEditing = !!metricType?.id; - const isComposite = !!values.is_composite; - // OpenHexa layers own their metadata + scale: everything but the legend colors is read-only. - const isOpenHexa = values.origin === 'openhexa'; - const canPickOpenHexaLayer = isOpenHexa && !isEditing; - - const layerKind = - (isComposite && LAYER_TYPES.COMPOSITE) || - (isOpenHexa && LAYER_TYPES.OPENHEXA) || - LAYER_TYPES.DATA; - - const compositeLegendOptions = useMemo( - () => getCompositeLegendOptions(formatMessage), - [formatMessage], - ); - - // Fetched as soon as the dialog opens (not only once the type is picked) so the - // picker is populated instantly; the result is cached for the whole session. - const { - data: openHexaDataLayers, - isFetching: isLoadingOpenHexaDataLayers, - isError: openHexaDataLayersError, - } = useGetOpenHexaDataLayers(showOpenHexaLayers && !isEditing); - - // Split the OpenHexa layers: importable ones feed the picker; the rest are listed so the - // user knows why they're missing - already created (code is unique per account), or a - // scale that doesn't fit its legend type. - const [importableLayers, unimportableLayers, alreadyImportedLayers] = - useMemo(() => { - const importable: OpenHexaDataLayer[] = []; - const unimportable: OpenHexaDataLayer[] = []; - const alreadyImported: OpenHexaDataLayer[] = []; - (openHexaDataLayers ?? []).forEach(layer => { - if (existingCodes.has(layer.code)) alreadyImported.push(layer); - else if (layer.error) unimportable.push(layer); - else importable.push(layer); - }); - return [importable, unimportable, alreadyImported]; - }, [openHexaDataLayers, existingCodes]); - - const openHexaLayerOptions = useMemo( - () => - importableLayers.map(layer => ({ - label: layer.name, - value: layer.code, - })), - [importableLayers], - ); - - const applyOpenHexaLayer = useCallback( - (layer?: OpenHexaDataLayer) => { - if (!layer) return; - Object.entries(openHexaLayerToFormPatch(layer)).forEach( - ([field, value]) => setFieldValueAndState(field, value), - ); - }, - [setFieldValueAndState], - ); - - const onSelectOpenHexaLayer = useCallback( - (_keyValue: string, code: string) => { - applyOpenHexaLayer( - importableLayers.find(layer => layer.code === code), - ); - }, - [importableLayers, applyOpenHexaLayer], - ); - - // Default to the first importable layer so the form is never left in an invalid - // "nothing selected" state; a manual pick sets `code` and stops this from re-firing. - useEffect(() => { - if ( - canPickOpenHexaLayer && - !values.code && - importableLayers.length > 0 - ) { - applyOpenHexaLayer(importableLayers[0]); - } - }, [ - canPickOpenHexaLayer, - values.code, - importableLayers, - applyOpenHexaLayer, - ]); - - // Switching type keeps the dependent fields coherent: composites default the legend to "auto" - // and pre-fill the category, regular layers reset to a concrete legend type, OpenHexa layers - // clear the read-only metadata so a data layer must actually be picked. - const onChangeLayerType = useCallback( - (value: string) => { - const composite = value === LAYER_TYPES.COMPOSITE; - const openhexa = value === LAYER_TYPES.OPENHEXA; - const wasOpenHexa = values.origin === 'openhexa'; - setFieldValueAndState('is_composite', composite); - setFieldValueAndState('origin', openhexa ? 'openhexa' : 'custom'); - setFieldValueAndState( - 'legend_type', - composite ? LegendTypes.AUTO : LegendTypes.THRESHOLD, - ); - // Crossing the OpenHexa boundary wipes the autofilled/read-only metadata: entering, - // so the picker (not stale hand-typed values) drives the layer; leaving, so the now - // editable fields start blank. - if (openhexa !== wasOpenHexa) { - ( - [ - 'code', - 'name', - 'description', - 'source', - 'units', - 'unit_symbol', - 'category', - ] as const - ).forEach(field => setFieldValueAndState(field, '')); - setFieldValueAndState('is_population', false); - setFieldValueAndState('legend_top_color', DEFAULT_COLOR); - } - if (composite && !values.category) { - setFieldValueAndState('category', 'Composite'); - } - }, - [setFieldValueAndState, values.category, values.origin], - ); - - const onChangeTopColor = useCallback( - (color: string) => setFieldValueAndState('legend_top_color', color), - [setFieldValueAndState], - ); - - const showScaleConfig = isConcreteLegend(values.legend_type); - - return ( - - - - - - - - - - - {formatMessage(LAYER_TYPE_INFO[layerKind])} - - - {canPickOpenHexaLayer && ( - <> - - - {formatMessage(MESSAGES.openHexaDataLayerHelp)} - - {alreadyImportedLayers.length > 0 && ( - - {formatMessage( - MESSAGES.openHexaLayersAlreadyImported, - { - layers: alreadyImportedLayers - .map(layer => layer.name) - .join(', '), - }, - )} - - )} - {unimportableLayers.length > 0 && ( - - {formatMessage(MESSAGES.openHexaLayersUnimportable)} -
    - {React.Children.toArray( - unimportableLayers.map(layer => ( -
  • - {layer.name}:{' '} - {layer.error} -
  • - )), - )} -
-
- )} - - )} - - - {formatMessage(MESSAGES.generalSectionTitle)} - {isOpenHexa && ( - - {formatMessage(MESSAGES.openHexaFieldsReadOnly)} - - )} - - {/* Composites get an auto-generated data key. */} - {!isComposite && ( - - )} - - - - - - - - - - - - - - {formatMessage(MESSAGES.legendSectionTitle)} - {isOpenHexa && ( - - {formatMessage(MESSAGES.openHexaColorsEditable)} - - )} - - - {showScaleConfig && ( - - )} -
- ); -}; diff --git a/js/src/domains/dataLayers/dataLayerForm/LayerTypeSelect.tsx b/js/src/domains/dataLayers/dataLayerForm/LayerTypeSelect.tsx deleted file mode 100644 index 0f34654a..00000000 --- a/js/src/domains/dataLayers/dataLayerForm/LayerTypeSelect.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { FC, ReactNode, useMemo } from 'react'; -import AccountTreeIcon from '@mui/icons-material/AccountTree'; -import LayersIcon from '@mui/icons-material/Layers'; -import { - FormControl, - InputLabel, - ListItemIcon, - ListItemText, - MenuItem, - Select, -} from '@mui/material'; -import { useSafeIntl } from 'bluesquare-components'; -import { OpenHexaSvg } from 'Iaso/components/svg/OpenHexaSvg'; -import { SxStyles } from 'Iaso/types/general'; -import { MESSAGES } from '../messages'; - -export const LAYER_TYPES = { - DATA: 'data', - COMPOSITE: 'composite', - OPENHEXA: 'openhexa', -}; - -type Option = { - value: string; - label: string; - icon: ReactNode; -}; - -type Props = { - value: string; - onChange: (value: string) => void; - /** Whether the composite option is offered. */ - showComposite?: boolean; - /** Whether the OpenHexa data layer option is offered (account has OpenHexa configured). */ - showOpenHexa?: boolean; - disabled?: boolean; -}; - -const styles = { - // Keeps the same height (40px) as the InputComponent fields around it. - control: { - '& .MuiSelect-select': { - display: 'flex', - alignItems: 'center', - gap: 1, - paddingTop: '8.5px !important', - paddingBottom: '8.5px !important', - }, - }, - icon: { - minWidth: 32, - }, -} satisfies SxStyles; - -/** Outlined select whose options carry a leading icon - the shared InputComponent select can't. */ -export const LayerTypeSelect: FC = ({ - value, - onChange, - showComposite = false, - showOpenHexa = false, - disabled = false, -}) => { - const { formatMessage } = useSafeIntl(); - const label = formatMessage(MESSAGES.layerType); - - const options: Option[] = useMemo(() => { - const layerTypes = [ - { - value: LAYER_TYPES.DATA, - label: formatMessage(MESSAGES.layerTypeData), - icon: , - }, - ]; - if (showOpenHexa) { - layerTypes.push({ - value: LAYER_TYPES.OPENHEXA, - label: formatMessage(MESSAGES.layerTypeOpenHexa), - icon: , - }); - } - if (showComposite) { - layerTypes.push({ - value: LAYER_TYPES.COMPOSITE, - label: formatMessage(MESSAGES.compositeLayer), - icon: , - }); - } - return layerTypes; - }, [formatMessage, showComposite, showOpenHexa]); - - return ( - - {label} - - - ); -}; diff --git a/js/src/domains/dataLayers/dataLayerForm/LegendConfigForm.tsx b/js/src/domains/dataLayers/dataLayerForm/LegendConfigForm.tsx index 4f8fdf21..7c9c5c29 100644 --- a/js/src/domains/dataLayers/dataLayerForm/LegendConfigForm.tsx +++ b/js/src/domains/dataLayers/dataLayerForm/LegendConfigForm.tsx @@ -17,15 +17,6 @@ const styles: SxStyles = { mt: 2, fontWeight: 'medium', }, - scaleContainter: { - mb: 2, - ' button': { - visibility: 'hidden', - }, - '&:hover button': { - visibility: 'visible', - }, - }, topColorRow: { mt: 1, mb: 2, diff --git a/js/src/domains/dataLayers/dataLayerForm/ScaleForm.tsx b/js/src/domains/dataLayers/dataLayerForm/ScaleForm.tsx index e476e985..7bc46bcf 100644 --- a/js/src/domains/dataLayers/dataLayerForm/ScaleForm.tsx +++ b/js/src/domains/dataLayers/dataLayerForm/ScaleForm.tsx @@ -14,12 +14,6 @@ const styles: SxStyles = { }, scaleContainter: { mb: 2, - ' button': { - visibility: 'hidden', - }, - '&:hover button': { - visibility: 'visible', - }, }, }; @@ -62,9 +56,7 @@ export const ScaleForm: FC = ({ displayLabel={false} /> - {(canBeRemoved && ) || ( - - )} + ); }; diff --git a/js/src/domains/dataLayers/dataLayerList/DataLayerLine.tsx b/js/src/domains/dataLayers/dataLayerList/DataLayerLine.tsx index 270c7377..fa2013ee 100644 --- a/js/src/domains/dataLayers/dataLayerList/DataLayerLine.tsx +++ b/js/src/domains/dataLayers/dataLayerList/DataLayerLine.tsx @@ -3,6 +3,7 @@ import AccountTreeIcon from '@mui/icons-material/AccountTree'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import LayersIcon from '@mui/icons-material/Layers'; import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import { Box, ClickAwayListener, @@ -18,6 +19,7 @@ import { import { IconButton, useSafeIntl } from 'bluesquare-components'; import { DeleteModal } from 'Iaso/components/DeleteRestoreModals/DeleteModal'; import { DisplayIfUserHasPerm } from 'Iaso/components/DisplayIfUserHasPerm'; +import { OpenHexaSvg } from 'Iaso/components/svg/OpenHexaSvg'; import { SxStyles } from 'Iaso/types/general'; import * as CorePermission from 'Iaso/utils/permissions'; import { useDataLayerComparisonContext } from '../contexts/DataLayerComparisonContext'; @@ -70,6 +72,7 @@ const styles: SxStyles = { '&:active': { cursor: 'grabbing' }, }, metricTypeIcon: { minWidth: 20, mr: 2 }, + incompleteIcon: { ml: 1, flexShrink: 0 }, metricTypeDetails: { flexGrow: 1, display: 'flex', @@ -91,6 +94,7 @@ export const DataLayerLine: FC = ({ editing = false, }) => { const isComposite = compositeLayerId !== undefined; + const isOpenHexa = metricType.origin === 'openhexa'; const theme = useTheme(); const onDragStart = useCallback( (e: React.DragEvent) => { @@ -173,7 +177,7 @@ export const DataLayerLine: FC = ({ > - {/* Composites swap the layer icon for their own rather than showing both. */} + {/* Composites and OpenHexa layers swap the layer icon for their own rather than showing both. */} {isComposite ? ( = ({ sx={styles.metricTypeIcon} /> + ) : isOpenHexa ? ( + + + ) : ( = ({ /> )} {metricType.name} + {metricType.is_complete === false && ( + + + + )} diff --git a/js/src/domains/dataLayers/dataLayerList/DataLayerList.tsx b/js/src/domains/dataLayers/dataLayerList/DataLayerList.tsx index fbe1d9fe..6cd6f838 100644 --- a/js/src/domains/dataLayers/dataLayerList/DataLayerList.tsx +++ b/js/src/domains/dataLayers/dataLayerList/DataLayerList.tsx @@ -133,9 +133,13 @@ export const DataLayerList: FC = ({ onDelete={() => deleteMetricType(metricType.id) } - onRefreshOpenHexaLayer={onRefreshOpenHexaLayer} + onRefreshOpenHexaLayer={ + onRefreshOpenHexaLayer + } importStatus={ - openHexaImportStatus?.[metricType.id] + openHexaImportStatus?.[ + metricType.id + ] } selected={ metricType.id === diff --git a/js/src/domains/dataLayers/dataLayerMap/DataLayerMap.tsx b/js/src/domains/dataLayers/dataLayerMap/DataLayerMap.tsx index 94f58f38..3edadbaa 100644 --- a/js/src/domains/dataLayers/dataLayerMap/DataLayerMap.tsx +++ b/js/src/domains/dataLayers/dataLayerMap/DataLayerMap.tsx @@ -9,8 +9,13 @@ import { } from '../../planning/libs/map-utils'; import { MetricType, MetricValue } from '../types/metrics'; +export type MapLegendConfig = Pick< + MetricType, + 'units' | 'unit_symbol' | 'legend_type' | 'legend_config' +>; + type Props = { - metricType?: MetricType; + legendConfig?: MapLegendConfig; metricValues?: MetricValue[]; orgUnits: OrgUnit[]; }; @@ -21,7 +26,7 @@ const defaultOrgUnitStyle = { }; export const DataLayerMap: FC = ({ - metricType, + legendConfig, metricValues, orgUnits, }) => { @@ -29,14 +34,15 @@ export const DataLayerMap: FC = ({ const getOrgUnitMapMisc = useCallback( (orgUnitId: number) => { - if (!metricType) { + if (!legendConfig) { return defaultOrgUnitStyle; } - const selectedMetric = getSelectedMetric(orgUnitId); - - return getMapStyleForOrgUnit(metricType, selectedMetric); + return getMapStyleForOrgUnit( + legendConfig, + getSelectedMetric(orgUnitId), + ); }, - [getSelectedMetric, metricType], + [getSelectedMetric, legendConfig], ); return ( @@ -44,7 +50,7 @@ export const DataLayerMap: FC = ({ id={'data-layer-map'} border orgUnits={orgUnits} - legendConfig={metricType} + legendConfig={legendConfig} getOrgUnitMapMisc={getOrgUnitMapMisc} /> ); diff --git a/js/src/domains/dataLayers/dataLayerMap/DataLayerMapWrapper.tsx b/js/src/domains/dataLayers/dataLayerMap/DataLayerMapWrapper.tsx index 27939aed..42128293 100644 --- a/js/src/domains/dataLayers/dataLayerMap/DataLayerMapWrapper.tsx +++ b/js/src/domains/dataLayers/dataLayerMap/DataLayerMapWrapper.tsx @@ -190,7 +190,7 @@ export const DataLayerMapWrapper: FC = ({ } > = ({ }) => { const { formatMessage } = useSafeIntl(); - if (!metricType) { + if (!metricType?.id) { return null; } diff --git a/js/src/domains/dataLayers/dataLayerWizard/DataLayerWizardMain.tsx b/js/src/domains/dataLayers/dataLayerWizard/DataLayerWizardMain.tsx new file mode 100644 index 00000000..fb61f887 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/DataLayerWizardMain.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react'; +import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; +import { ExtendedFormikProvider } from '../../../hooks/useGetExtendedFormikContext'; +import { StepDataMain } from './steps/StepDataMain'; +import { DataLayerWizardController } from './useDataLayerWizardController'; +import { WizardMapPreview } from './useWizardMapPreview'; + +type Props = { + controller: DataLayerWizardController; + preview: WizardMapPreview; + orgUnits: OrgUnit[]; +}; + +export const DataLayerWizardMain: FC = ({ + controller, + preview, + orgUnits, +}) => { + const { formik, staged, setGridValue, addGridYear, removeGridYear } = + controller; + return ( + + + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/DataLayerWizardPanel.tsx b/js/src/domains/dataLayers/dataLayerWizard/DataLayerWizardPanel.tsx new file mode 100644 index 00000000..ffae92f9 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/DataLayerWizardPanel.tsx @@ -0,0 +1,178 @@ +import React, { FC, ReactNode, useCallback } from 'react'; +import { Box, Button, Card, Divider, Stack, Typography } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; +import { SxStyles } from 'Iaso/types/general'; +import { ExtendedFormikProvider } from '../../../hooks/useGetExtendedFormikContext'; +import { MESSAGES } from '../messages'; +import { StepDataControls } from './steps/StepDataControls'; +import { StepDetails } from './steps/StepDetails'; +import { StepLegend } from './steps/StepLegend'; +import { StepType } from './steps/StepType'; +import { WIZARD_STEPS } from './useDataLayerWizard'; +import { DataLayerWizardController } from './useDataLayerWizardController'; +import { WizardMapPreview } from './useWizardMapPreview'; +import { WizardStepRail } from './WizardStepRail'; + +const styles: SxStyles = { + card: { height: '100%', display: 'flex', flexDirection: 'column' }, + header: { + p: 2, + pb: 1.5, + gap: 1.5, + }, + title: { fontWeight: 600 }, + body: { flexGrow: 1, overflow: 'auto', p: 2 }, + bodyFlush: { + flexGrow: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + }, + footer: { + p: 2, + gap: 1, + justifyContent: 'space-between', + }, + footerActions: { gap: 1 }, +}; + +type Props = { + controller: DataLayerWizardController; + preview: WizardMapPreview; + showOpenHexa: boolean; + showComposite: boolean; + orgUnits: OrgUnit[]; + compositeGraphSlot?: ReactNode; + onCompositeNext?: () => void; +}; + +export const DataLayerWizardPanel: FC = ({ + controller, + preview, + showOpenHexa, + showComposite, + orgUnits, + compositeGraphSlot, + onCompositeNext, +}) => { + const { formatMessage } = useSafeIntl(); + const { + formik, + stepLabels, + titleMessage, + activeStep, + activeStepIndex, + lastStep, + goNext, + goBack, + canAdvance, + isEditing, + layerType, + setLayerType, + staged, + onCsvFileSelected, + requestClose, + submit, + isSubmitting, + isCompositeGraphStep, + existingCodes, + categoryOptions, + } = controller; + + const isLastStep = activeStep === lastStep; + + const onPrimary = useCallback(() => { + if (isCompositeGraphStep) { + onCompositeNext?.(); + } else if (isLastStep) { + submit(); + } else { + goNext(); + } + }, [isCompositeGraphStep, isLastStep, onCompositeNext, submit, goNext]); + + return ( + + + + {formatMessage(titleMessage)} + + + + + + {isCompositeGraphStep ? ( + {compositeGraphSlot} + ) : ( + + {activeStep === WIZARD_STEPS.TYPE && ( + + )} + {activeStep === WIZARD_STEPS.DETAILS && ( + + )} + {activeStep === WIZARD_STEPS.DATA && ( + + )} + {activeStep === WIZARD_STEPS.LEGEND && ( + + )} + + )} + + + + + + + {activeStepIndex > 0 && ( + + )} + + + + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/DiscardWizardModal.tsx b/js/src/domains/dataLayers/dataLayerWizard/DiscardWizardModal.tsx new file mode 100644 index 00000000..07cef082 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/DiscardWizardModal.tsx @@ -0,0 +1,45 @@ +import React, { FC } from 'react'; +import { Typography } from '@mui/material'; +import { + ConfirmCancelModal, + IntlMessage, + useSafeIntl, +} from 'bluesquare-components'; +import { MESSAGES } from '../messages'; + +type Props = { + open: boolean; + titleMessage: IntlMessage; + message: IntlMessage; + onConfirm: () => void; + onCancel: () => void; + id?: string; +}; + +export const DiscardWizardModal: FC = ({ + open, + titleMessage, + message, + onConfirm, + onCancel, + id = 'data-layer-wizard-discard', +}) => { + const { formatMessage } = useSafeIntl(); + if (!open) return null; + return ( + + {formatMessage(message)} + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/LayerTypeCards.tsx b/js/src/domains/dataLayers/dataLayerWizard/LayerTypeCards.tsx new file mode 100644 index 00000000..5e8da77e --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/LayerTypeCards.tsx @@ -0,0 +1,93 @@ +import React, { FC } from 'react'; +import AccountTreeIcon from '@mui/icons-material/AccountTree'; +import LayersIcon from '@mui/icons-material/Layers'; +import { Box, Stack, Typography } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import { OpenHexaSvg } from 'Iaso/components/svg/OpenHexaSvg'; +import { SxStyles } from 'Iaso/types/general'; +import { MESSAGES } from '../messages'; +import { WizardLayerType } from './constants'; +import { SelectableCard } from './SelectableCard'; + +type Option = { + value: WizardLayerType; + icon: React.ReactNode; + title: typeof MESSAGES.layerTypeData; + blurb: typeof MESSAGES.layerTypeDataInfo; +}; + +const OPTIONS: Option[] = [ + { + value: 'data', + icon: , + title: MESSAGES.layerTypeData, + blurb: MESSAGES.layerTypeDataInfo, + }, + { + value: 'openhexa', + icon: , + title: MESSAGES.layerTypeOpenHexa, + blurb: MESSAGES.layerTypeOpenHexaInfo, + }, + { + value: 'composite', + icon: , + title: MESSAGES.compositeLayer, + blurb: MESSAGES.layerTypeCompositeInfo, + }, +]; + +const styles = { + list: { display: 'flex', flexDirection: 'column', gap: 1.5 }, + icon: { color: 'primary.main', mt: 0.25 }, + title: { fontWeight: 600 }, +} satisfies SxStyles; + +type Props = { + value: WizardLayerType; + onChange: (value: WizardLayerType) => void; + showOpenHexa: boolean; + showComposite: boolean; +}; + +export const LayerTypeCards: FC = ({ + value, + onChange, + showOpenHexa, + showComposite, +}) => { + const { formatMessage } = useSafeIntl(); + const options = OPTIONS.filter( + option => + (option.value !== 'openhexa' || showOpenHexa) && + (option.value !== 'composite' || showComposite), + ); + + return ( + + {React.Children.toArray( + options.map(option => ( + onChange(option.value)} + > + + {option.icon} + + + {formatMessage(option.title)} + + + {formatMessage(option.blurb)} + + + + + )), + )} + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/ManualValueGrid.tsx b/js/src/domains/dataLayers/dataLayerWizard/ManualValueGrid.tsx new file mode 100644 index 00000000..8efecb24 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/ManualValueGrid.tsx @@ -0,0 +1,311 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import AddIcon from '@mui/icons-material/Add'; +import ContentPasteIcon from '@mui/icons-material/ContentPaste'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import { + Box, + IconButton, + MenuItem, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; +import { SxStyles } from 'Iaso/types/general'; +import { DataLayerYearOptions } from '../../../constants/shared'; +import { MESSAGES } from '../messages'; +import { OrgUnitRow, toOrgUnitRows } from './orgUnitRows'; + +const styles = { + toolbar: { mb: 1, alignItems: 'center' }, + count: { flexGrow: 1 }, + container: { maxHeight: '100%', overflow: 'auto' }, + valueCell: { width: 140 }, + yearHeader: { alignItems: 'center', whiteSpace: 'nowrap' }, + addYearSelect: { fontSize: 'body2.fontSize' }, + warnIcon: { color: 'warning.main', fontSize: 18, ml: 0.5 }, +} satisfies SxStyles; + +const isPercentUnit = (units?: string, symbol?: string): boolean => + symbol === '%' || (units ?? '').toLowerCase().includes('percent'); + +const noop = () => {}; + +type GridRowProps = { + row: OrgUnitRow; + years: number[]; + valuesByYear: Record | undefined; + onChange: (orgUnitId: number, year: number, value: string) => void; + percent: boolean; + readOnly: boolean; +}; + +const GridRow: FC = ({ + row, + years, + valuesByYear, + onChange, + percent, + readOnly, +}) => { + const { formatMessage } = useSafeIntl(); + return ( + + {row.adm1Name} + {row.adm2Name} + {years.map(year => { + const raw = valuesByYear?.[year] ?? ''; + const num = Number(raw); + const aboveHundred = + percent && + raw.trim() !== '' && + Number.isFinite(num) && + num > 100; + return ( + + + + onChange( + row.orgUnitId, + year, + event.target.value, + ) + } + InputProps={{ readOnly }} + inputProps={{ + inputMode: 'decimal', + 'aria-label': `${row.adm2Name} ${year}`, + }} + /> + {aboveHundred && ( + + + + )} + + + ); + })} + + ); +}; + +const MemoGridRow = React.memo(GridRow); + +type Props = { + orgUnits: OrgUnit[]; + /** Org unit id -> year -> raw text. */ + values: Record>; + years: number[]; + /** Omit along with `onAddYear`/`onRemoveYear` for a `readOnly` grid. */ + onChange?: (orgUnitId: number, year: number, value: string) => void; + onAddYear?: (year: number) => void; + onRemoveYear?: (year: number) => void; + units?: string; + unitSymbol?: string; + readOnly?: boolean; +}; + +export const ManualValueGrid: FC = ({ + orgUnits, + values, + years, + onChange = noop, + onAddYear = noop, + onRemoveYear = noop, + units, + unitSymbol, + readOnly = false, +}) => { + const { formatMessage } = useSafeIntl(); + const rows = useMemo(() => toOrgUnitRows(orgUnits), [orgUnits]); + const percent = isPercentUnit(units, unitSymbol); + + const filled = useMemo( + () => + rows.reduce( + (count, row) => + count + + years.filter( + year => + (values[row.orgUnitId]?.[year] ?? '').trim() !== '', + ).length, + 0, + ), + [rows, years, values], + ); + const total = rows.length * years.length; + + const addableYears = useMemo( + () => + DataLayerYearOptions.filter( + option => !years.includes(option.value), + ), + [years], + ); + + const onPasteIntoYear = useCallback( + async (year: number) => { + if (readOnly || !navigator.clipboard?.readText) return; + let text: string; + try { + // Rejects in Firefox, insecure contexts, or when permission is denied. + text = await navigator.clipboard.readText(); + } catch { + return; + } + text.split(/\r?\n/) + .map(cell => cell.trim()) + .forEach((cell, index) => { + if (cell !== '' && rows[index]) { + onChange(rows[index].orgUnitId, year, cell); + } + }); + }, + [rows, onChange, readOnly], + ); + + return ( + + + + {formatMessage(MESSAGES.wizardManualFilledCount, { + filled: String(filled), + total: String(total), + })} + + {!readOnly && addableYears.length > 0 && ( + + )} + + {!readOnly && ( + + {formatMessage(MESSAGES.wizardManualPasteHelp)} + + )} + + + + + + {formatMessage(MESSAGES.wizardGridRegion)} + + + {formatMessage(MESSAGES.wizardGridDistrict)} + + {years.map(year => ( + + + + {year} + + {!readOnly && ( + <> + + + onPasteIntoYear( + year, + ) + } + > + + + + + + onRemoveYear(year) + } + > + + + + + )} + + + ))} + + + + {rows.map(row => ( + + ))} + +
+
+
+ ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/OpenHexaImportStatusMessage.tsx b/js/src/domains/dataLayers/dataLayerWizard/OpenHexaImportStatusMessage.tsx new file mode 100644 index 00000000..93d70f39 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/OpenHexaImportStatusMessage.tsx @@ -0,0 +1,76 @@ +import React, { FC } from 'react'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { Box, CircularProgress, Typography } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { useSafeIntl } from 'bluesquare-components'; +import { SxStyles } from 'Iaso/types/general'; +import { + isFailedTaskStatus, + isSuccessTaskStatus, +} from '../../../constants/taskStatus'; +import { OpenHexaImportStatus } from '../hooks/useGetOpenHexaImportStatus'; +import { MESSAGES } from '../messages'; + +const styles = { + box: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 1, + p: 1.5, + borderRadius: 2, + border: '1px solid', + }, + error: { + borderColor: 'error.main', + backgroundColor: theme => alpha(theme.palette.error.main, 0.08), + }, + success: { + borderColor: 'success.main', + backgroundColor: theme => alpha(theme.palette.success.main, 0.08), + }, + info: { + borderColor: 'primary.main', + backgroundColor: theme => alpha(theme.palette.primary.main, 0.06), + }, +} satisfies SxStyles; + +type Props = { + status?: OpenHexaImportStatus; +}; + +export const OpenHexaImportStatusMessage: FC = ({ status }) => { + const { formatMessage } = useSafeIntl(); + + if (status && isFailedTaskStatus(status.status)) { + return ( + + + + {status.progress_message || + formatMessage(MESSAGES.importFailed)} + + + ); + } + if (status && isSuccessTaskStatus(status.status)) { + return ( + + + + {formatMessage(MESSAGES.wizardOpenHexaImportComplete)} + + + ); + } + return ( + + + + {status?.progress_message || + formatMessage(MESSAGES.wizardOpenHexaImportInfo)} + + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/OpenHexaSourcePicker.tsx b/js/src/domains/dataLayers/dataLayerWizard/OpenHexaSourcePicker.tsx new file mode 100644 index 00000000..ab89709d --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/OpenHexaSourcePicker.tsx @@ -0,0 +1,135 @@ +import React, { FC, useCallback, useEffect, useMemo } from 'react'; +import { Alert, Box, Typography } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import InputComponent from 'Iaso/components/forms/InputComponent'; +import { SxStyles } from 'Iaso/types/general'; +import { useGetExtendedFormikContext } from '../../../hooks/useGetExtendedFormikContext'; +import { openHexaLayerToFormPatch } from '../dataLayerForm/openHexaAutofill'; +import { useGetOpenHexaDataLayers } from '../hooks/useGetOpenHexaDataLayers'; +import { MESSAGES } from '../messages'; +import { MetricTypeFormModel, OpenHexaDataLayer } from '../types/metrics'; + +const styles: SxStyles = { + hint: { display: 'block', mt: 0.5 }, + alert: { mt: 1, '& ul': { m: 0, pl: 2.5 } }, +}; + +type Props = { + existingCodes: Set; + disabled?: boolean; +}; + +export const OpenHexaSourcePicker: FC = ({ + existingCodes, + disabled = false, +}) => { + const { formatMessage } = useSafeIntl(); + const { values, setFieldValueAndState } = + useGetExtendedFormikContext(); + + const { + data: openHexaDataLayers, + isFetching, + isError, + } = useGetOpenHexaDataLayers(true); + + const [importable, unimportable, alreadyImported] = useMemo(() => { + const yes: OpenHexaDataLayer[] = []; + const no: OpenHexaDataLayer[] = []; + const done: OpenHexaDataLayer[] = []; + (openHexaDataLayers ?? []).forEach(layer => { + if (existingCodes.has(layer.code)) done.push(layer); + else if (layer.error) no.push(layer); + else yes.push(layer); + }); + return [yes, no, done]; + }, [openHexaDataLayers, existingCodes]); + + const applyLayer = useCallback( + (layer?: OpenHexaDataLayer) => { + if (!layer) return; + Object.entries(openHexaLayerToFormPatch(layer)).forEach( + ([field, value]) => setFieldValueAndState(field, value), + ); + }, + [setFieldValueAndState], + ); + + const onSelect = useCallback( + (_key: string, code: string) => + applyLayer(importable.find(layer => layer.code === code)), + [importable, applyLayer], + ); + + useEffect(() => { + if (!values.code && importable.length > 0) { + applyLayer(importable[0]); + } + }, [values.code, importable, applyLayer]); + + return ( + + ({ + label: layer.name, + value: layer.code, + }))} + value={values.code || null} + onChange={onSelect} + label={MESSAGES.openHexaDataLayer} + disabled={disabled} + loading={isFetching} + errors={ + isError + ? [formatMessage(MESSAGES.openHexaDataLayersError)] + : [] + } + /> + + {formatMessage(MESSAGES.openHexaDataLayerHelp)} + + {alreadyImported.length > 0 && ( + + {formatMessage(MESSAGES.openHexaLayersAlreadyImported, { + layers: alreadyImported + .map(layer => layer.name) + .join(', '), + })} + + )} + {unimportable.length > 0 && ( + + {formatMessage(MESSAGES.openHexaLayersUnimportable)} +
    + {React.Children.toArray( + unimportable.map(layer => ( +
  • + {layer.name}: {layer.error} +
  • + )), + )} +
+
+ )} + + {formatMessage(MESSAGES.wizardOpenHexaManualRefresh)} + +
+ ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/SelectableCard.tsx b/js/src/domains/dataLayers/dataLayerWizard/SelectableCard.tsx new file mode 100644 index 00000000..82d1d122 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/SelectableCard.tsx @@ -0,0 +1,54 @@ +import React, { FC, KeyboardEvent, ReactNode } from 'react'; +import { Box } from '@mui/material'; +import { SxStyles } from 'Iaso/types/general'; + +const styles = { + card: { + p: 1.5, + borderRadius: 2, + border: '1px solid', + borderColor: 'divider', + cursor: 'pointer', + transition: 'border-color 120ms, background-color 120ms', + '&:hover': { borderColor: 'primary.light' }, + }, + selected: { + borderColor: 'primary.main', + backgroundColor: 'action.hover', + }, + disabled: { opacity: 0.5, pointerEvents: 'none' }, +} satisfies SxStyles; + +type Props = { + selected: boolean; + onSelect: () => void; + disabled?: boolean; + children: ReactNode; +}; + +export const SelectableCard: FC = ({ + selected, + onSelect, + disabled = false, + children, +}) => ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(); + } + }} + sx={[ + styles.card, + selected && styles.selected, + disabled && styles.disabled, + ]} + > + {children} + +); diff --git a/js/src/domains/dataLayers/dataLayerWizard/WizardDataMapPreview.tsx b/js/src/domains/dataLayers/dataLayerWizard/WizardDataMapPreview.tsx new file mode 100644 index 00000000..ed5d68c3 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/WizardDataMapPreview.tsx @@ -0,0 +1,28 @@ +import React, { FC, ReactNode } from 'react'; +import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; +import { DataLayerMap, MapLegendConfig } from '../dataLayerMap/DataLayerMap'; +import { MetricValue } from '../types/metrics'; +import { WizardMainCard } from './WizardMainCard'; + +type Props = { + header: ReactNode; + legendConfig: MapLegendConfig; + metricValues?: MetricValue[]; + orgUnits: OrgUnit[]; +}; + +/** The map card shown once a layer's real values are ready, on the Legend step. */ +export const WizardDataMapPreview: FC = ({ + header, + legendConfig, + metricValues, + orgUnits, +}) => ( + + + +); diff --git a/js/src/domains/dataLayers/dataLayerWizard/WizardLegendPreview.tsx b/js/src/domains/dataLayers/dataLayerWizard/WizardLegendPreview.tsx new file mode 100644 index 00000000..a4c64420 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/WizardLegendPreview.tsx @@ -0,0 +1,102 @@ +import React, { FC, useMemo } from 'react'; +import { MenuItem, Select, Stack, Typography } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; +import { usePreviewYearSelection } from '../../compositeLayerEditor/hooks/usePreviewYearSelection'; +import { MESSAGES } from '../messages'; +import { OpenHexaImportStatusMessage } from './OpenHexaImportStatusMessage'; +import { DataLayerWizardController } from './useDataLayerWizardController'; +import { WizardMapPreview } from './useWizardMapPreview'; +import { WizardDataMapPreview } from './WizardDataMapPreview'; +import { WizardMainCard } from './WizardMainCard'; +import { WizardPreviewPlaceholder } from './WizardPreviewPlaceholder'; + +type Props = { + controller: DataLayerWizardController; + preview: WizardMapPreview; + orgUnits: OrgUnit[]; +}; + +export const WizardLegendPreview: FC = ({ + controller, + preview, + orgUnits, +}) => { + const { formatMessage } = useSafeIntl(); + const { formik } = controller; + const { + ready, + isOpenHexa, + isComposite, + createdMetricTypeId, + openHexaStatus, + metricValues, + legendConfig, + } = preview; + const values = formik.values; + + const years = useMemo( + () => + Array.from( + new Set( + (metricValues ?? []) + .map(mv => mv.year) + .filter( + (year): year is number => year != null && year > 0, + ), + ), + ), + [metricValues], + ); + const { isMultiYear, selectedYear, setSelectedYear, displayedValues } = + usePreviewYearSelection(years, metricValues); + + if (isOpenHexa && !ready) { + return ( + + + + ); + } + if (isComposite && !createdMetricTypeId) { + return ; + } + + return ( + + + {values.name || + formatMessage(MESSAGES.wizardStepLegend)} + + {isMultiYear && ( + + )} + + } + legendConfig={legendConfig} + metricValues={displayedValues} + orgUnits={orgUnits} + /> + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/WizardMainCard.tsx b/js/src/domains/dataLayers/dataLayerWizard/WizardMainCard.tsx new file mode 100644 index 00000000..8eba2378 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/WizardMainCard.tsx @@ -0,0 +1,34 @@ +import React, { FC, ReactNode } from 'react'; +import { Card, Stack } from '@mui/material'; +import { SxStyles } from 'Iaso/types/general'; +import { CardStyled } from '../../../components/CardStyled'; + +const styles = { + card: { height: '100%', display: 'flex', flexDirection: 'column' }, + centered: { + height: '100%', + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + gap: 1.5, + p: 4, + }, +} satisfies SxStyles; + +type Props = { + header?: ReactNode; + centered?: boolean; + children: ReactNode; +}; + +export const WizardMainCard: FC = ({ header, centered, children }) => ( + + + {centered ? ( + {children} + ) : ( + children + )} + + +); diff --git a/js/src/domains/dataLayers/dataLayerWizard/WizardPreviewPlaceholder.tsx b/js/src/domains/dataLayers/dataLayerWizard/WizardPreviewPlaceholder.tsx new file mode 100644 index 00000000..bc2a56dc --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/WizardPreviewPlaceholder.tsx @@ -0,0 +1,30 @@ +import React, { FC } from 'react'; +import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; +import { Typography } from '@mui/material'; +import { IntlMessage, useSafeIntl } from 'bluesquare-components'; +import { SxStyles } from 'Iaso/types/general'; +import { MESSAGES } from '../messages'; +import { WizardMainCard } from './WizardMainCard'; + +const styles = { + icon: { fontSize: 48, color: 'text.disabled' }, + text: { maxWidth: 360, color: 'text.secondary' }, +} satisfies SxStyles; + +type Props = { + message?: IntlMessage; +}; + +export const WizardPreviewPlaceholder: FC = ({ + message = MESSAGES.wizardPreviewPlaceholder, +}) => { + const { formatMessage } = useSafeIntl(); + return ( + + + + {formatMessage(message)} + + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/WizardStepRail.tsx b/js/src/domains/dataLayers/dataLayerWizard/WizardStepRail.tsx new file mode 100644 index 00000000..11c3750c --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/WizardStepRail.tsx @@ -0,0 +1,106 @@ +import React, { FC, Fragment } from 'react'; +import CheckIcon from '@mui/icons-material/Check'; +import { Box, Typography } from '@mui/material'; +import { SxStyles } from 'Iaso/types/general'; + +type StepState = 'completed' | 'active' | 'pending'; + +const circleColor: Record = { + completed: 'success.main', + active: 'primary.main', + pending: 'text.disabled', +}; + +const stepState = (index: number, activeStep: number): StepState => { + if (index < activeStep) return 'completed'; + if (index === activeStep) return 'active'; + return 'pending'; +}; + +const styles = { + root: { + display: 'flex', + alignItems: 'flex-start', + width: '100%', + marginTop: 2, + }, + item: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 0.5, + flex: '0 0 auto', + width: 68, + }, + circle: { + width: 26, + height: 26, + borderRadius: '50%', + color: 'common.white', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 700, + fontSize: 13, + flexShrink: 0, + }, + label: { + textAlign: 'center', + lineHeight: 1.15, + fontWeight: 600, + fontSize: 13, + }, + connector: { + flex: '1 1 auto', + minWidth: 8, + height: '2px', + mt: '13px', + bgcolor: 'text.disabled', + }, +} satisfies SxStyles; + +type Props = { + activeStep: number; + steps: string[]; +}; + +export const WizardStepRail: FC = ({ activeStep, steps }) => ( + + {steps.map((label, idx) => { + const state = stepState(idx, activeStep); + return ( + + + + {state === 'completed' ? ( + + ) : ( + idx + 1 + )} + + activeStep + ? 'text.secondary' + : 'text.primary', + }, + ]} + > + {label} + + + {idx < steps.length - 1 && } + + ); + })} + +); diff --git a/js/src/domains/dataLayers/dataLayerWizard/constants.ts b/js/src/domains/dataLayers/dataLayerWizard/constants.ts new file mode 100644 index 00000000..637a1731 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/constants.ts @@ -0,0 +1,9 @@ +/** Mirrors `iaso/api/metrics/utils.py::REQUIRED_METRIC_VALUES_HEADERS` — the org-unit + * columns shared by the CSV import template and the manual-entry grid export. */ +export const REQUIRED_METRIC_VALUES_HEADERS = [ + 'ADM1_NAME', + 'ADM2_NAME', + 'ADM2_ID', +] as const; + +export type WizardLayerType = 'data' | 'openhexa' | 'composite'; diff --git a/js/src/domains/dataLayers/dataLayerWizard/gridCsv.test.ts b/js/src/domains/dataLayers/dataLayerWizard/gridCsv.test.ts new file mode 100644 index 00000000..f5d1feea --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/gridCsv.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { parseYearlyCsv, templateCsv } from './gridCsv'; +import { OrgUnitRow } from './orgUnitRows'; + +const row = (partial: Partial): OrgUnitRow => ({ + orgUnitId: 1, + adm1Name: 'Dakar', + adm2Name: 'Dakar', + ...partial, +}); + +describe('templateCsv', () => { + it('writes the shared headers plus one column per year', () => { + const csv = templateCsv([row({})], [2024, 2025]); + expect(csv.split('\n')[0]).toBe( + 'ADM1_NAME,ADM2_NAME,ADM2_ID,2024,2025', + ); + }); + + it('leaves every value cell blank, one row per org unit', () => { + const csv = templateCsv( + [ + row({ orgUnitId: 1, adm2Name: 'Dakar' }), + row({ orgUnitId: 2, adm1Name: 'Thiès', adm2Name: 'Mbour' }), + ], + [2024], + ); + expect(csv.split('\n')).toEqual([ + 'ADM1_NAME,ADM2_NAME,ADM2_ID,2024', + 'Dakar,Dakar,1,', + 'Thiès,Mbour,2,', + ]); + }); + + it('quotes names containing a comma', () => { + const csv = templateCsv([row({ adm2Name: 'Foo, Bar' })], [2024]); + expect(csv.split('\n')[1]).toBe('Dakar,"Foo, Bar",1,'); + }); +}); + +describe('parseYearlyCsv', () => { + it('reads filled cells by ADM2_ID and treats non-required numeric headers as years', () => { + const text = [ + 'ADM1_NAME,ADM2_NAME,ADM2_ID,2024,2025', + 'Dakar,Dakar,1,18.4,', + 'Dakar,Pikine,2,,7', + 'Thiès,Mbour,3,,', + ].join('\n'); + expect(parseYearlyCsv(text)).toEqual({ + years: [2024, 2025], + valuesByOrgUnit: { + 1: { 2024: '18.4' }, + 2: { 2025: '7' }, + }, + }); + }); + + it('supports a semicolon delimiter and quoted cells', () => { + const text = 'ADM1_NAME;ADM2_NAME;ADM2_ID;2024\n"a";"b";5;"9"'; + expect(parseYearlyCsv(text)).toEqual({ + years: [2024], + valuesByOrgUnit: { 5: { 2024: '9' } }, + }); + }); + + it('keeps columns aligned when a district name is quoted and contains the delimiter', () => { + const text = [ + 'ADM1_NAME,ADM2_NAME,ADM2_ID,2024', + 'Dakar,"Foo, Bar",5,42', + ].join('\n'); + expect(parseYearlyCsv(text)).toEqual({ + years: [2024], + valuesByOrgUnit: { 5: { 2024: '42' } }, + }); + }); + + it('returns nothing when ADM2_ID is missing', () => { + const text = 'ADM1_NAME,ADM2_NAME,2024\nx,y,3'; + expect(parseYearlyCsv(text)).toEqual({ + years: [], + valuesByOrgUnit: {}, + }); + }); + + it('ignores non-year, non-required columns', () => { + const text = [ + 'ADM1_NAME,ADM2_NAME,ADM2_ID,notes,2024', + 'Dakar,Dakar,1,some note,5', + ].join('\n'); + expect(parseYearlyCsv(text)).toEqual({ + years: [2024], + valuesByOrgUnit: { 1: { 2024: '5' } }, + }); + }); +}); diff --git a/js/src/domains/dataLayers/dataLayerWizard/gridCsv.ts b/js/src/domains/dataLayers/dataLayerWizard/gridCsv.ts new file mode 100644 index 00000000..3ce7bc85 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/gridCsv.ts @@ -0,0 +1,106 @@ +import { REQUIRED_METRIC_VALUES_HEADERS } from './constants'; +import { OrgUnitRow } from './orgUnitRows'; + +const escapeCell = (cell: string | number): string => { + const text = String(cell ?? ''); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +}; + +/** `ADM1_NAME,ADM2_NAME,ADM2_ID,,,...` — one column per year, since + * the wizard only ever deals with a single layer's values. */ +const headerRow = (years: number[]): string => + [...REQUIRED_METRIC_VALUES_HEADERS, ...years.map(String)].join(','); + +/** + * An import template for a layer that does not exist yet: the shared org-unit + * headers plus one blank-value column per requested year, one row per org unit. + * The generic `/api/metricvalues/csv_template/` only lists metric types already + * saved (and columns per layer, not per year), so the wizard builds this itself. + */ +export const templateCsv = (rows: OrgUnitRow[], years: number[]): string => { + const body = rows.map(row => + [ + escapeCell(row.adm1Name), + escapeCell(row.adm2Name), + escapeCell(row.orgUnitId), + ...years.map(() => ''), + ].join(','), + ); + return [headerRow(years), ...body].join('\n'); +}; + +/** Split one CSV line, honouring double-quoted fields (which may contain the + * delimiter or escaped `""`). */ +const splitCsvLine = (line: string, delimiter: string): string[] => { + const cells: string[] = []; + let cell = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + if (inQuotes) { + if (char === '"' && line[i + 1] === '"') { + cell += '"'; + i += 1; + } else if (char === '"') { + inQuotes = false; + } else { + cell += char; + } + } else if (char === '"') { + inQuotes = true; + } else if (char === delimiter) { + cells.push(cell); + cell = ''; + } else { + cell += char; + } + } + cells.push(cell); + return cells.map(value => value.trim()); +}; + +export type ParsedYearlyCsv = { + years: number[]; + valuesByOrgUnit: Record>; +}; + +/** + * Read a template-shaped CSV (as downloaded from `templateCsv`, or hand-edited) for + * the wizard's table: any header column that parses as a whole number is a year; + * rows are matched to org units by `ADM2_ID`. Used to fill the table, not to submit + * it — nothing here talks to the backend. + */ +export const parseYearlyCsv = (text: string): ParsedYearlyCsv => { + const lines = text.split(/\r?\n/).filter(line => line.trim() !== ''); + if (lines.length < 2) return { years: [], valuesByOrgUnit: {} }; + + const delimiter = + lines[0].includes(';') && !lines[0].includes(',') ? ';' : ','; + const headers = splitCsvLine(lines[0], delimiter); + const idIndex = headers.indexOf('ADM2_ID'); + if (idIndex === -1) return { years: [], valuesByOrgUnit: {} }; + + const yearColumns = headers + .map((header, index) => ({ year: Number(header), index })) + .filter( + ({ year }, i) => headers[i].trim() !== '' && Number.isInteger(year), + ); + + const valuesByOrgUnit: Record> = {}; + for (const line of lines.slice(1)) { + const cells = splitCsvLine(line, delimiter); + const orgUnitId = Number(cells[idIndex]); + if (Number.isFinite(orgUnitId) && orgUnitId > 0) { + const rowValues: Record = {}; + yearColumns.forEach(({ year, index }) => { + const value = (cells[index] ?? '').trim(); + if (value !== '') rowValues[year] = value; + }); + if (Object.keys(rowValues).length > 0) { + valuesByOrgUnit[orgUnitId] = rowValues; + } + } + } + + return { years: yearColumns.map(({ year }) => year), valuesByOrgUnit }; +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/metricValuesToGrid.test.ts b/js/src/domains/dataLayers/dataLayerWizard/metricValuesToGrid.test.ts new file mode 100644 index 00000000..49edfdab --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/metricValuesToGrid.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { currentYear } from '../../../constants/shared'; +import { MetricValue } from '../types/metrics'; +import { metricValuesToGrid } from './metricValuesToGrid'; + +const metricValue = (partial: Partial): MetricValue => ({ + id: 1, + metric_type: 1, + org_unit: 1, + year: null, + value: 0, + string_value: '', + ...partial, +}); + +describe('metricValuesToGrid', () => { + it('buckets timeless values under the current year', () => { + const grid = metricValuesToGrid([ + metricValue({ org_unit: 1, year: null, value: 42 }), + ]); + expect(grid.years).toEqual([currentYear]); + expect(grid.values[1][currentYear]).toBe('42'); + }); + + it('buckets values by their own year when present', () => { + const grid = metricValuesToGrid([ + metricValue({ org_unit: 1, year: 2023, value: 10 }), + metricValue({ org_unit: 1, year: 2024, value: 20 }), + metricValue({ org_unit: 2, year: 2023, value: 30 }), + ]); + expect(grid.years).toEqual([2023, 2024]); + expect(grid.values[1]).toEqual({ 2023: '10', 2024: '20' }); + expect(grid.values[2]).toEqual({ 2023: '30' }); + }); + + it('falls back to string_value when value is not numeric', () => { + const grid = metricValuesToGrid([ + metricValue({ + org_unit: 1, + year: 2024, + value: null as unknown as number, + string_value: 'seasonal', + }), + ]); + expect(grid.values[1][2024]).toBe('seasonal'); + }); + + it('returns an empty grid for no metric values', () => { + expect(metricValuesToGrid(undefined)).toEqual({ + values: {}, + years: [], + }); + }); +}); diff --git a/js/src/domains/dataLayers/dataLayerWizard/metricValuesToGrid.ts b/js/src/domains/dataLayers/dataLayerWizard/metricValuesToGrid.ts new file mode 100644 index 00000000..6ad5bf66 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/metricValuesToGrid.ts @@ -0,0 +1,27 @@ +import { currentYear } from '../../../constants/shared'; +import { hasNumericMetricValue } from '../../planning/libs/metric-utils'; +import { MetricValue } from '../types/metrics'; + +export type MetricValueGrid = { + values: Record>; + years: number[]; +}; + +const metricValueText = (metricValue: MetricValue): string => + hasNumericMetricValue(metricValue) + ? String(metricValue.value) + : metricValue.string_value; + +export const metricValuesToGrid = ( + metricValues: MetricValue[] = [], +): MetricValueGrid => { + const values: Record> = {}; + const years = new Set(); + metricValues.forEach(metricValue => { + const year = metricValue.year ?? currentYear; + years.add(year); + values[metricValue.org_unit] ??= {}; + values[metricValue.org_unit][year] = metricValueText(metricValue); + }); + return { values, years: Array.from(years).sort((a, b) => a - b) }; +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/orgUnitRows.ts b/js/src/domains/dataLayers/dataLayerWizard/orgUnitRows.ts new file mode 100644 index 00000000..f756815e --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/orgUnitRows.ts @@ -0,0 +1,22 @@ +import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; + +export type OrgUnitRow = { + orgUnitId: number; + adm1Name: string; + adm2Name: string; +}; + +/** District (ADM2) rows for the manual-entry grid and the import template, sorted + * by region then district and shaped like `import_from_csv`'s columns. */ +export const toOrgUnitRows = (orgUnits: OrgUnit[]): OrgUnitRow[] => + [...orgUnits] + .map(orgUnit => ({ + orgUnitId: orgUnit.id, + adm2Name: orgUnit.name, + adm1Name: orgUnit.parent_name ?? orgUnit.parent?.name ?? '', + })) + .sort( + (a, b) => + a.adm1Name.localeCompare(b.adm1Name) || + a.adm2Name.localeCompare(b.adm2Name), + ); diff --git a/js/src/domains/dataLayers/dataLayerWizard/steps/StepDataControls.tsx b/js/src/domains/dataLayers/dataLayerWizard/steps/StepDataControls.tsx new file mode 100644 index 00000000..87eb6463 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/steps/StepDataControls.tsx @@ -0,0 +1,96 @@ +import React, { FC, useCallback } from 'react'; +import { Box, Link, Typography } from '@mui/material'; +import { FilesUpload, useSafeIntl } from 'bluesquare-components'; +import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; +import { SxStyles } from 'Iaso/types/general'; +import { OpenHexaImportStatus } from '../../hooks/useGetOpenHexaImportStatus'; +import { MESSAGES } from '../../messages'; +import { WizardLayerType } from '../constants'; +import { templateCsv } from '../gridCsv'; +import { OpenHexaImportStatusMessage } from '../OpenHexaImportStatusMessage'; +import { toOrgUnitRows } from '../orgUnitRows'; + +const styles = { + question: { fontWeight: 600, mb: 1 }, + section: { mt: 2 }, + note: { display: 'block', mt: 1 }, +} satisfies SxStyles; + +type Props = { + layerType: WizardLayerType; + onUploadCsv: (file: File) => void; + years: number[]; + orgUnits: OrgUnit[]; + openHexaStatus?: OpenHexaImportStatus; +}; + +export const StepDataControls: FC = ({ + layerType, + onUploadCsv, + years, + orgUnits, + openHexaStatus, +}) => { + const { formatMessage } = useSafeIntl(); + + const onDownloadTemplate = useCallback(() => { + const csv = templateCsv(toOrgUnitRows(orgUnits), years); + const url = URL.createObjectURL( + new Blob([csv], { type: 'text/csv;charset=utf-8' }), + ); + const link = document.createElement('a'); + link.href = url; + link.download = 'metric_import_template.csv'; + link.click(); + URL.revokeObjectURL(url); + }, [orgUnits, years]); + + if (layerType === 'openhexa') { + return ( + + + {formatMessage(MESSAGES.wizardStepData)} + + + + {formatMessage(MESSAGES.wizardOpenHexaManualRefresh)} + + + ); + } + + return ( + + + {formatMessage(MESSAGES.wizardStepData)} + + + {formatMessage(MESSAGES.wizardImportCsvHint)} + + + { + if (files[0]) onUploadCsv(files[0]); + }} + multi={false} + placeholder={formatMessage(MESSAGES.importCSV)} + /> + + {formatMessage(MESSAGES.downloadCSVTemplate)} + + + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/steps/StepDataMain.tsx b/js/src/domains/dataLayers/dataLayerWizard/steps/StepDataMain.tsx new file mode 100644 index 00000000..e20f0d73 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/steps/StepDataMain.tsx @@ -0,0 +1,88 @@ +import React, { FC, useMemo } from 'react'; +import { Typography } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; +import { MESSAGES } from '../../messages'; +import { MetricTypeFormModel, MetricValue } from '../../types/metrics'; +import { WizardLayerType } from '../constants'; +import { ManualValueGrid } from '../ManualValueGrid'; +import { metricValuesToGrid } from '../metricValuesToGrid'; +import { WizardMainCard } from '../WizardMainCard'; + +type Props = { + layerType: WizardLayerType; + orgUnits: OrgUnit[]; + gridValues: Record>; + gridYears: number[]; + onGridChange: (orgUnitId: number, year: number, value: string) => void; + onAddYear: (year: number) => void; + onRemoveYear: (year: number) => void; + values: MetricTypeFormModel; + previewReady: boolean; + previewMetricValues?: MetricValue[]; +}; + +export const StepDataMain: FC = ({ + layerType, + orgUnits, + gridValues, + gridYears, + onGridChange, + onAddYear, + onRemoveYear, + values, + previewReady, + previewMetricValues, +}) => { + const { formatMessage } = useSafeIntl(); + const header = ( + + {formatMessage(MESSAGES.wizardStepData)} + + ); + const openHexaGrid = useMemo( + () => metricValuesToGrid(previewMetricValues), + [previewMetricValues], + ); + + if (layerType === 'openhexa') { + if (previewReady) { + return ( + + + + ); + } + return ( + + {values.name} + + {values.code} + {values.units ? ` · ${values.units}` : ''} + + + ); + } + + return ( + + + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/steps/StepDetails.tsx b/js/src/domains/dataLayers/dataLayerWizard/steps/StepDetails.tsx new file mode 100644 index 00000000..cc48d572 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/steps/StepDetails.tsx @@ -0,0 +1,144 @@ +import React, { FC } from 'react'; +import { Box, Grid, Typography } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import InputComponent from 'Iaso/components/forms/InputComponent'; +import { useTranslatedErrors } from 'Iaso/libs/validation'; +import { SxStyles } from 'Iaso/types/general'; +import { useGetExtendedFormikContext } from '../../../../hooks/useGetExtendedFormikContext'; +import { MESSAGES } from '../../messages'; +import { MetricTypeFormModel } from '../../types/metrics'; +import { WizardLayerType } from '../constants'; +import { OpenHexaSourcePicker } from '../OpenHexaSourcePicker'; + +const styles: SxStyles = { + readOnlyHint: { display: 'block', mt: 2, mb: 1 }, + population: { mt: 3 }, +}; + +type Props = { + layerType: WizardLayerType; + categoryOptions: { label: string; value: string }[]; + existingCodes: Set; + isEditing?: boolean; + codeLocked?: boolean; +}; + +export const StepDetails: FC = ({ + layerType, + categoryOptions, + existingCodes, + isEditing = false, + codeLocked = false, +}) => { + const { formatMessage } = useSafeIntl(); + const { values, setFieldValueAndState, errors, touched } = + useGetExtendedFormikContext(); + const getErrors = useTranslatedErrors({ + errors, + touched, + formatMessage, + messages: MESSAGES, + }); + + const isComposite = layerType === 'composite'; + const isOpenHexa = layerType === 'openhexa'; + + return ( + + {isOpenHexa && !isEditing && ( + + )} + {isOpenHexa && ( + + {formatMessage(MESSAGES.openHexaFieldsReadOnly)} + + )} + + {!isComposite && ( + + )} + + + + + + + + + + + + + + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/steps/StepLegend.tsx b/js/src/domains/dataLayers/dataLayerWizard/steps/StepLegend.tsx new file mode 100644 index 00000000..d62644f0 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/steps/StepLegend.tsx @@ -0,0 +1,100 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import { Alert, Box } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import InputComponent from 'Iaso/components/forms/InputComponent'; +import { useTranslatedErrors } from 'Iaso/libs/validation'; +import { SxStyles } from 'Iaso/types/general'; +import { + isConcreteLegend, + LEGEND_TYPE_MAX_ITEMS, + LEGEND_TYPE_MIN_ITEMS, +} from '../../../../constants/legend'; +import { useGetExtendedFormikContext } from '../../../../hooks/useGetExtendedFormikContext'; +import { getCompositeLegendOptions } from '../../../compositeLayerEditor/utils/legendOptions'; +import { useGetLegendTypes } from '../../../planning/hooks/useGetLegendTypes'; +import { LegendConfigForm } from '../../dataLayerForm/LegendConfigForm'; +import { MESSAGES } from '../../messages'; +import { MetricTypeFormModel } from '../../types/metrics'; +import { WizardLayerType } from '../constants'; + +const styles: SxStyles = { + summary: { mt: 3 }, +}; + +type Props = { + layerType: WizardLayerType; +}; + +export const StepLegend: FC = ({ layerType }) => { + const { formatMessage } = useSafeIntl(); + const { data: legendTypeOptions, isLoading } = useGetLegendTypes(); + const { + values, + setFieldValueAndState, + setChildFieldValueAndState, + addChildValue, + removeChildValue, + errors, + touched, + } = useGetExtendedFormikContext(); + const getErrors = useTranslatedErrors({ + errors, + touched, + formatMessage, + messages: MESSAGES, + }); + + const isComposite = layerType === 'composite'; + const compositeLegendOptions = useMemo( + () => getCompositeLegendOptions(formatMessage), + [formatMessage], + ); + + const onChangeTopColor = useCallback( + (color: string) => setFieldValueAndState('legend_top_color', color), + [setFieldValueAndState], + ); + + return ( + + + {isConcreteLegend(values.legend_type) && ( + + )} + {values.category && ( + + {formatMessage(MESSAGES.wizardSummaryAddsToCategory, { + category: values.category, + })} + + )} + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/steps/StepType.tsx b/js/src/domains/dataLayers/dataLayerWizard/steps/StepType.tsx new file mode 100644 index 00000000..1fd41dec --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/steps/StepType.tsx @@ -0,0 +1,41 @@ +import React, { FC } from 'react'; +import { Box, Typography } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import { SxStyles } from 'Iaso/types/general'; +import { MESSAGES } from '../../messages'; +import { WizardLayerType } from '../constants'; +import { LayerTypeCards } from '../LayerTypeCards'; + +const styles: SxStyles = { + question: { fontWeight: 600, mb: 1.5 }, +}; + +type Props = { + layerType: WizardLayerType; + onChangeLayerType: (value: WizardLayerType) => void; + showOpenHexa: boolean; + showComposite: boolean; +}; + +export const StepType: FC = ({ + layerType, + onChangeLayerType, + showOpenHexa, + showComposite, +}) => { + const { formatMessage } = useSafeIntl(); + + return ( + + + {formatMessage(MESSAGES.wizardTypeQuestion)} + + + + ); +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/useDataLayerWizard.test.ts b/js/src/domains/dataLayers/dataLayerWizard/useDataLayerWizard.test.ts new file mode 100644 index 00000000..4b96200e --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/useDataLayerWizard.test.ts @@ -0,0 +1,178 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { + EDIT_STEPS, + useDataLayerWizard, + WIZARD_STEPS, +} from './useDataLayerWizard'; + +describe('useDataLayerWizard', () => { + it('starts on the Type step with a standard layer', () => { + const { result } = renderHook(() => useDataLayerWizard()); + expect(result.current.activeStep).toBe(WIZARD_STEPS.TYPE); + expect(result.current.staged.layerType).toBe('data'); + }); + + it('clamps navigation to the first and last step (Legend is last)', () => { + const { result } = renderHook(() => useDataLayerWizard()); + act(() => result.current.goBack()); + expect(result.current.activeStep).toBe(WIZARD_STEPS.TYPE); + act(() => { + result.current.goNext(); + result.current.goNext(); + result.current.goNext(); + result.current.goNext(); + }); + expect(result.current.activeStep).toBe(WIZARD_STEPS.LEGEND); + }); + + it('orders the steps Type, Details, Data, Legend', () => { + expect(WIZARD_STEPS.TYPE).toBeLessThan(WIZARD_STEPS.DETAILS); + expect(WIZARD_STEPS.DETAILS).toBeLessThan(WIZARD_STEPS.DATA); + expect(WIZARD_STEPS.DATA).toBeLessThan(WIZARD_STEPS.LEGEND); + }); + + it('start(EDIT_STEPS) runs only Details -> Legend, skipping Data', () => { + const { result } = renderHook(() => useDataLayerWizard()); + act(() => result.current.start(EDIT_STEPS)); + expect(result.current.activeStep).toBe(WIZARD_STEPS.DETAILS); + expect(result.current.activeStepIndex).toBe(0); + expect(result.current.lastStep).toBe(WIZARD_STEPS.LEGEND); + act(() => result.current.goBack()); + expect(result.current.activeStep).toBe(WIZARD_STEPS.DETAILS); + act(() => result.current.goNext()); + expect(result.current.activeStep).toBe(WIZARD_STEPS.LEGEND); + }); + + it("start() can seed staged state, e.g. an edit run's known layer type", () => { + const { result } = renderHook(() => useDataLayerWizard()); + act(() => result.current.start(EDIT_STEPS, { layerType: 'openhexa' })); + expect(result.current.staged.layerType).toBe('openhexa'); + }); + + it('reset() clears every staged field and returns to the first step', () => { + const { result } = renderHook(() => useDataLayerWizard()); + act(() => { + result.current.setLayerType('composite'); + result.current.setGridValue(3, 2024, '9'); + result.current.patch({ compositeLayerId: 42 }); + result.current.goNext(); + }); + act(() => result.current.reset()); + expect(result.current.activeStep).toBe(WIZARD_STEPS.TYPE); + expect(result.current.staged).toEqual({ + layerType: 'data', + gridValues: {}, + gridYears: [expect.any(Number)], + }); + }); + + it('drops staged table entries when leaving the standard type', () => { + const { result } = renderHook(() => useDataLayerWizard()); + act(() => { + result.current.setGridValue(7, 2024, '42'); + result.current.addGridYear(2025); + }); + act(() => result.current.setLayerType('composite')); + expect(result.current.staged.gridValues).toEqual({}); + expect(result.current.staged.gridYears).toEqual([expect.any(Number)]); + }); + + it('addGridYear adds a sorted, deduplicated column', () => { + const { result } = renderHook(() => useDataLayerWizard()); + const initialYear = result.current.staged.gridYears[0]; + act(() => { + result.current.addGridYear(initialYear + 2); + result.current.addGridYear(initialYear + 1); + result.current.addGridYear(initialYear + 1); + }); + expect(result.current.staged.gridYears).toEqual([ + initialYear, + initialYear + 1, + initialYear + 2, + ]); + }); + + it('removeGridYear drops the column and its values', () => { + const { result } = renderHook(() => useDataLayerWizard()); + const [year] = result.current.staged.gridYears; + act(() => { + result.current.addGridYear(year + 1); + result.current.setGridValue(1, year, '10'); + result.current.setGridValue(1, year + 1, '20'); + }); + act(() => result.current.removeGridYear(year)); + expect(result.current.staged.gridYears).toEqual([year + 1]); + expect(result.current.staged.gridValues[1]).toEqual({ + [year + 1]: '20', + }); + }); + + it('mergeGridFromCsv adds new year columns and fills values without discarding existing ones', () => { + const { result } = renderHook(() => useDataLayerWizard()); + const [year] = result.current.staged.gridYears; + act(() => result.current.setGridValue(1, year, 'kept')); + act(() => + result.current.mergeGridFromCsv([year + 1], { + 1: { [year + 1]: 'new' }, + 2: { [year + 1]: '5' }, + }), + ); + expect(result.current.staged.gridYears).toEqual([year, year + 1]); + expect(result.current.staged.gridValues[1]).toEqual({ + [year]: 'kept', + [year + 1]: 'new', + }); + expect(result.current.staged.gridValues[2]).toEqual({ + [year + 1]: '5', + }); + }); + + it('clears a staged composite shell id when switching away from composite', () => { + const { result } = renderHook(() => useDataLayerWizard()); + act(() => { + result.current.setLayerType('composite'); + result.current.patch({ compositeLayerId: 99 }); + }); + expect(result.current.staged.compositeLayerId).toBe(99); + act(() => result.current.setLayerType('data')); + expect(result.current.staged.compositeLayerId).toBeUndefined(); + }); + + it('clears a staged created MetricType id when switching to a different layer type', () => { + const { result } = renderHook(() => useDataLayerWizard()); + act(() => { + result.current.setLayerType('openhexa'); + result.current.patch({ createdMetricTypeId: 12 }); + }); + expect(result.current.staged.createdMetricTypeId).toBe(12); + act(() => result.current.setLayerType('data')); + expect(result.current.staged.createdMetricTypeId).toBeUndefined(); + }); + + it('keeps a staged created MetricType id when reselecting the same layer type', () => { + const { result } = renderHook(() => useDataLayerWizard()); + act(() => { + result.current.setLayerType('data'); + result.current.patch({ createdMetricTypeId: 7 }); + }); + act(() => result.current.setLayerType('data')); + expect(result.current.staged.createdMetricTypeId).toBe(7); + }); + + it('flags the composite graph step only on the Data step of a composite', () => { + const { result } = renderHook(() => useDataLayerWizard()); + act(() => result.current.setLayerType('composite')); + expect(result.current.isCompositeGraphStep).toBe(false); + act(() => { + result.current.goNext(); + result.current.goNext(); + }); + // On Data now, not the Legend step yet. + expect(result.current.activeStep).toBe(WIZARD_STEPS.DATA); + expect(result.current.isCompositeGraphStep).toBe(true); + act(() => result.current.goNext()); + expect(result.current.activeStep).toBe(WIZARD_STEPS.LEGEND); + expect(result.current.isCompositeGraphStep).toBe(false); + }); +}); diff --git a/js/src/domains/dataLayers/dataLayerWizard/useDataLayerWizard.ts b/js/src/domains/dataLayers/dataLayerWizard/useDataLayerWizard.ts new file mode 100644 index 00000000..9f477135 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/useDataLayerWizard.ts @@ -0,0 +1,243 @@ +import { useCallback, useMemo, useState } from 'react'; +import { currentYear } from '../../../constants/shared'; +import { MESSAGES } from '../messages'; +import { WizardLayerType } from './constants'; + +export const WIZARD_STEPS = { + TYPE: 0, + DETAILS: 1, + // Data / graph comes before Legend: the values (or composite graph) need to + // exist for the Legend step's map preview to show anything real. + DATA: 2, + LEGEND: 3, +} as const; + +export type WizardStep = (typeof WIZARD_STEPS)[keyof typeof WIZARD_STEPS]; + +const sortedUniqueYears = (years: number[]): number[] => + Array.from(new Set(years)).sort((a, b) => a - b); + +const mergeYearValues = ( + base: Record | undefined, + updates: Record, +): Record => ({ ...base, ...updates }); + +/** A standard layer's table always starts with a single column for the current year. */ +const DEFAULT_GRID_YEARS = [currentYear]; + +/** Editing an existing layer runs Details then Legend only — its type is fixed and + * its values are managed elsewhere — which skips Data, so the two runs aren't a + * contiguous span of `WIZARD_STEPS` and each is listed explicitly. */ +export const CREATE_STEPS: WizardStep[] = [ + WIZARD_STEPS.TYPE, + WIZARD_STEPS.DETAILS, + WIZARD_STEPS.DATA, + WIZARD_STEPS.LEGEND, +]; +export const EDIT_STEPS: WizardStep[] = [ + WIZARD_STEPS.DETAILS, + WIZARD_STEPS.LEGEND, +]; + +/** Step titles, index-aligned with `WIZARD_STEPS`. The Data one is overridden to + * "Graph" for a composite layer (see `stepLabels` in the controller). */ +export const WIZARD_STEP_LABELS = [ + MESSAGES.wizardStepType, + MESSAGES.wizardStepDetails, + MESSAGES.wizardStepData, + MESSAGES.wizardStepLegend, +]; + +type StagedState = { + layerType: WizardLayerType; + /** Table values for a standard layer: org unit id -> year -> raw text; blank or + * absent means no value. */ + gridValues: Record>; + /** Year columns currently shown in the table, in display order. */ + gridYears: number[]; + importedYears?: number[]; + /** Persisted composite shell id, once the graph step creates it (2a keeps the + * editor in-flow). */ + compositeLayerId?: number; + createdMetricTypeId?: number; + /** Code of the OpenHexa source `createdMetricTypeId` was last imported from, so a + * re-pick on Details can tell whether the Data step needs to cancel + redo it. */ + importedCode?: string; +}; + +const INITIAL: StagedState = { + layerType: 'data', + gridValues: {}, + gridYears: DEFAULT_GRID_YEARS, +}; + +export const useDataLayerWizard = () => { + const [steps, setSteps] = useState(CREATE_STEPS); + const [activeStep, setActiveStep] = useState(CREATE_STEPS[0]); + const [staged, setStaged] = useState(INITIAL); + + const patch = useCallback( + (next: Partial) => + setStaged(prev => ({ ...prev, ...next })), + [], + ); + + /** Start a fresh run over the given steps (create or edit), at the first one, + * with every staged field cleared (`initialStaged` seeds it, e.g. with the + * layer type an edit run already knows). */ + const start = useCallback( + (nextSteps: WizardStep[], initialStaged?: Partial) => { + setSteps(nextSteps); + setActiveStep(nextSteps[0]); + setStaged({ ...INITIAL, ...initialStaged }); + }, + [], + ); + + const reset = useCallback(() => start(CREATE_STEPS), [start]); + + const setLayerType = useCallback((layerType: WizardLayerType) => { + setStaged(prev => ({ + ...prev, + layerType, + // Table entries only apply to a standard layer; drop them otherwise. + ...(layerType === 'data' + ? {} + : { + gridValues: {}, + gridYears: DEFAULT_GRID_YEARS, + importedYears: undefined, + }), + // A composite shell no longer belongs to a layer of a different type; + // the controller deletes the persisted record first. + ...(layerType === 'composite' + ? {} + : { compositeLayerId: undefined }), + // Same for a standard/OpenHexa MetricType already created for a + // previous pick — reselecting the same type keeps it. + ...(layerType === prev.layerType + ? {} + : { createdMetricTypeId: undefined, importedCode: undefined }), + })); + }, []); + + const setGridValue = useCallback( + (orgUnitId: number, year: number, value: string) => + setStaged(prev => ({ + ...prev, + gridValues: { + ...prev.gridValues, + [orgUnitId]: mergeYearValues(prev.gridValues[orgUnitId], { + [year]: value, + }), + }, + })), + [], + ); + + const addGridYear = useCallback( + (year: number) => + setStaged(prev => + prev.gridYears.includes(year) + ? prev + : { + ...prev, + gridYears: sortedUniqueYears([ + ...prev.gridYears, + year, + ]), + }, + ), + [], + ); + + const removeGridYear = useCallback( + (year: number) => + setStaged(prev => ({ + ...prev, + gridYears: prev.gridYears.filter(y => y !== year), + gridValues: Object.fromEntries( + Object.entries(prev.gridValues).map( + ([orgUnitId, byYear]) => { + const { [year]: _removed, ...rest } = byYear; + return [orgUnitId, rest]; + }, + ), + ), + })), + [], + ); + + /** Merges CSV-parsed values into the table (adding any new year columns), so + * uploading a file fills the table without discarding what's already there. */ + const mergeGridFromCsv = useCallback( + ( + years: number[], + valuesByOrgUnit: Record>, + ) => + setStaged(prev => { + const gridYears = sortedUniqueYears([ + ...prev.gridYears, + ...years, + ]); + const gridValues = { ...prev.gridValues }; + Object.entries(valuesByOrgUnit).forEach( + ([orgUnitId, byYear]) => { + gridValues[Number(orgUnitId)] = mergeYearValues( + gridValues[Number(orgUnitId)], + byYear, + ); + }, + ); + return { ...prev, gridYears, gridValues }; + }), + [], + ); + + const goNext = useCallback( + () => + setActiveStep(step => { + const index = steps.indexOf(step); + return steps[Math.min(index + 1, steps.length - 1)]; + }), + [steps], + ); + const goBack = useCallback( + () => + setActiveStep(step => { + const index = steps.indexOf(step); + return steps[Math.max(index - 1, 0)]; + }), + [steps], + ); + + const activeStepIndex = steps.indexOf(activeStep); + const lastStep = steps[steps.length - 1]; + + const isCompositeGraphStep = useMemo( + () => + activeStep === WIZARD_STEPS.DATA && + staged.layerType === 'composite', + [activeStep, staged.layerType], + ); + + return { + steps, + activeStep, + activeStepIndex, + lastStep, + setActiveStep, + goNext, + goBack, + staged, + patch, + start, + reset, + setLayerType, + setGridValue, + addGridYear, + removeGridYear, + mergeGridFromCsv, + isCompositeGraphStep, + }; +}; diff --git a/js/src/domains/dataLayers/dataLayerWizard/useDataLayerWizardController.ts b/js/src/domains/dataLayers/dataLayerWizard/useDataLayerWizardController.ts new file mode 100644 index 00000000..fa7418a1 --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/useDataLayerWizardController.ts @@ -0,0 +1,752 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSafeIntl } from 'bluesquare-components'; +import { openSnackBar } from 'Iaso/components/snackBars/EventDispatcher'; +import { errorSnackBar } from 'Iaso/constants/snackBars'; +import { ApiError } from 'Iaso/libs/Api'; +import { isConcreteLegend, LegendTypes } from '../../../constants/legend'; +import { useDeleteCompositeLayer } from '../../compositeLayerEditor/hooks/useDeleteCompositeLayer'; +import { useSaveCompositeLayer } from '../../compositeLayerEditor/hooks/useSaveCompositeLayer'; +import { CompositeLayerListItem } from '../../compositeLayerEditor/types/compositeLayer'; +import { + initialTopColor, + legendConfigFromForm, + scaleFromDomainRange, +} from '../dataLayerForm/legendScale'; +import { useCancelOpenHexaImport } from '../hooks/useCancelOpenHexaImport'; +import { useCompleteMetricType } from '../hooks/useCompleteMetricType'; +import { useCreateOrUpdateMetricType } from '../hooks/useCreateOrUpdateMetricType'; +import { useDeleteMetricType } from '../hooks/useDeleteMetricType'; +import { useGetMetricTypes } from '../hooks/useGetMetrics'; +import { useImportMetricValuesJson } from '../hooks/useImportMetricValuesJson'; +import { useImportOpenHexaDataLayer } from '../hooks/useImportOpenHexaDataLayer'; +import { + editFormModel, + makeDefaultMetricType, + useMetricTypeFormState, +} from '../hooks/useMetricTypeFormState'; +import { MESSAGES } from '../messages'; +import { + MetricType, + MetricTypeFormModel, + ScaleDomainRange, +} from '../types/metrics'; +import { WizardLayerType } from './constants'; +import { parseYearlyCsv } from './gridCsv'; +import { + EDIT_STEPS, + useDataLayerWizard, + WIZARD_STEP_LABELS, + WIZARD_STEPS, +} from './useDataLayerWizard'; + +export type WizardMainView = + | 'compositeGraph' + | 'standardData' + | 'legendPreview' + | 'intro'; + +type ControllerArgs = { + onCreated: (metricType?: MetricType) => void; + categoryOptions: { label: string; value: string }[]; + onClosed?: () => void; +}; + +const layerTypeToFormFields = ( + layerType: WizardLayerType, +): Pick => ({ + is_composite: layerType === 'composite', + origin: layerType === 'openhexa' ? 'openhexa' : 'custom', + legend_type: + layerType === 'composite' ? LegendTypes.AUTO : LegendTypes.THRESHOLD, +}); + +const layerTypeOf = (model: MetricTypeFormModel): WizardLayerType => { + if (model.is_composite) return 'composite'; + if (model.origin === 'openhexa') return 'openhexa'; + return 'data'; +}; + +const metricTypePayload = ( + values: MetricTypeFormModel, + legend_config: ScaleDomainRange | undefined, +) => ({ + name: values.name, + code: values.code, + description: values.description, + source: values.source, + units: values.units, + unit_symbol: values.unit_symbol, + comments: values.comments, + category: values.category, + origin: values.origin, + legend_type: values.legend_type, + legend_config, + metric_kind: values.is_population ? 'population' : 'any', +}); + +const compositeLayerPayload = ( + values: MetricTypeFormModel, + legend_config: ScaleDomainRange | undefined, +) => ({ + name: values.name, + category: values.category, + description: values.description, + units: values.units, + unit_symbol: values.unit_symbol, + is_population: !!values.is_population, + legend_type: values.legend_type, + legend_config, +}); + +export const useDataLayerWizardController = ({ + onCreated, + categoryOptions, + onClosed, +}: ControllerArgs) => { + const { formatMessage } = useSafeIntl(); + const [isOpen, setIsOpen] = useState(false); + const [discardOpen, setDiscardOpen] = useState(false); + const [backConfirmOpen, setBackConfirmOpen] = useState(false); + /** The layer being edited (Details + Legend only); undefined for a create run. */ + const [editing, setEditing] = useState<{ + metricType: MetricType; + compositeLayer?: CompositeLayerListItem; + }>(); + + const wizard = useDataLayerWizard(); + const { staged, patch, reset: resetStaged } = wizard; + + const { data: allMetricTypes } = useGetMetricTypes( + true, + isOpen, + ); + // Excludes the layer this wizard run already imported (if any): otherwise, once + // the Data step's import creates its MetricType, re-picking a source on Details + // would find its own code already "taken" and drop it from the picker's options. + const existingCodes = useMemo( + () => + new Set( + (allMetricTypes ?? []) + .filter(mt => mt.id !== staged.createdMetricTypeId) + .map(mt => mt.code), + ), + [allMetricTypes, staged.createdMetricTypeId], + ); + + const formik = useMetricTypeFormState(undefined, () => undefined); + + const onCreateMetricTypeError = useCallback( + (error: ApiError) => { + if (error.status !== 400) return; + const code = error.details?.code?.[0]; + if (code === 'uniqueCode') { + formik.setFieldTouched('code', true, false); + formik.setFieldError( + 'code', + formatMessage(MESSAGES.uniqueCodeError), + ); + } else { + openSnackBar( + errorSnackBar(undefined, MESSAGES.genericError, error), + ); + } + }, + [formik, formatMessage], + ); + + const { mutateAsync: createMetricType } = useCreateOrUpdateMetricType({ + onError: onCreateMetricTypeError, + onSuccess: () => undefined, + }); + const { mutateAsync: importGridValues } = useImportMetricValuesJson(); + const { mutateAsync: importOpenHexa } = useImportOpenHexaDataLayer(); + const { mutateAsync: completeMetricType } = useCompleteMetricType(); + const { mutateAsync: saveComposite } = useSaveCompositeLayer(true); + const { mutateAsync: updateComposite } = useSaveCompositeLayer(); + const { mutate: deleteMetricType } = useDeleteMetricType(); + const { mutate: deleteCompositeLayer } = useDeleteCompositeLayer(); + const { mutate: cancelOpenHexaImport } = useCancelOpenHexaImport(); + + // Discarding an OpenHexa layer's shell should also stop its background value + // import; the task is looked up server-side by the metric type id. A composite + // shell is deleted through its own endpoint instead: `metric_type` is a + // SET_NULL FK, so deleting the MetricType directly would leave the + // CompositeLayer row (and its graph) orphaned rather than cleaning it up. + const discardMetricType = useCallback( + ( + metricTypeId: number, + layerType: WizardLayerType, + compositeLayerId?: number, + ) => { + if (layerType === 'composite' && compositeLayerId) { + deleteCompositeLayer(compositeLayerId); + return; + } + if (layerType === 'openhexa') { + cancelOpenHexaImport({ metric_type_id: metricTypeId }); + } + deleteMetricType(metricTypeId); + }, + [deleteMetricType, deleteCompositeLayer, cancelOpenHexaImport], + ); + + const resetAll = useCallback(() => { + formik.resetForm({ values: makeDefaultMetricType() }); + resetStaged(); + setEditing(undefined); + }, [formik, resetStaged]); + + const open = useCallback(() => { + resetAll(); + setIsOpen(true); + }, [resetAll]); + + const openForEdit = useCallback( + (metricType: MetricType, compositeLayer?: CompositeLayerListItem) => { + const model = editFormModel(metricType, compositeLayer); + formik.resetForm({ values: model }); + wizard.start(EDIT_STEPS, { layerType: layerTypeOf(model) }); + setEditing({ metricType, compositeLayer }); + setIsOpen(true); + }, + [formik, wizard], + ); + + const close = useCallback(() => { + setIsOpen(false); + setDiscardOpen(false); + resetAll(); + onClosed?.(); + }, [resetAll, onClosed]); + + const hasProgress = useMemo( + () => formik.dirty || wizard.activeStepIndex > 0, + [formik.dirty, wizard.activeStepIndex], + ); + + const requestClose = useCallback(() => { + if (hasProgress) { + setDiscardOpen(true); + return; + } + close(); + }, [hasProgress, close]); + + const confirmDiscard = useCallback(() => { + if (staged.createdMetricTypeId) { + discardMetricType( + staged.createdMetricTypeId, + staged.layerType, + staged.compositeLayerId, + ); + } + close(); + }, [ + staged.createdMetricTypeId, + staged.layerType, + staged.compositeLayerId, + discardMetricType, + close, + ]); + + const cancelDiscard = useCallback(() => setDiscardOpen(false), []); + + const requestBack = useCallback(() => { + if (wizard.isCompositeGraphStep) { + setBackConfirmOpen(true); + return; + } + wizard.goBack(); + }, [wizard]); + + const confirmBack = useCallback(() => { + setBackConfirmOpen(false); + wizard.goBack(); + }, [wizard]); + + const cancelBack = useCallback(() => setBackConfirmOpen(false), []); + + const setLayerType = useCallback( + (layerType: WizardLayerType) => { + const isChanging = layerType !== staged.layerType; + if (isChanging && staged.createdMetricTypeId) { + discardMetricType( + staged.createdMetricTypeId, + staged.layerType, + staged.compositeLayerId, + ); + } + wizard.setLayerType(layerType); + if (isChanging) { + formik.resetForm({ + values: { + ...makeDefaultMetricType(), + ...layerTypeToFormFields(layerType), + category: layerType === 'composite' ? 'Composite' : '', + }, + }); + } + }, + [ + wizard, + formik, + staged.layerType, + staged.createdMetricTypeId, + staged.compositeLayerId, + discardMetricType, + ], + ); + + const { errors: formikErrors, values: formikValues } = formik; + let canAdvance: boolean; + switch (wizard.activeStep) { + case WIZARD_STEPS.DETAILS: + canAdvance = + !formikErrors.name && + !formikErrors.category && + (formikValues.is_composite || !formikErrors.code) && + Boolean(formikValues.name) && + Boolean(formikValues.category); + break; + case WIZARD_STEPS.LEGEND: + canAdvance = formik.isValid; + break; + default: + canAdvance = true; + } + + const [isSubmitting, setIsSubmitting] = useState(false); + // Guards the composite-shell save against a double-click while it's in flight + // (the button's `isSubmitting` disable is a render behind an async click). + const busyRef = useRef(false); + + const legendPayload = useCallback(() => { + const { legend_type, legend_config, legend_top_color } = formik.values; + return isConcreteLegend(legend_type) + ? (legendConfigFromForm( + legend_type, + legend_config, + legend_top_color, + ) as ScaleDomainRange) + : undefined; + }, [formik.values]); + + const submitGridValues = useCallback( + async (metricTypeId: number) => { + if (staged.gridYears.length === 0) return; + const years = Array.from( + new Set([...(staged.importedYears ?? []), ...staged.gridYears]), + ); + if (years.length === 0) return; + const values: { + org_unit_id: number; + year: number; + value: string; + }[] = []; + Object.entries(staged.gridValues).forEach(([orgUnitId, byYear]) => { + Object.entries(byYear).forEach(([year, value]) => { + if (value.trim() !== '') { + values.push({ + org_unit_id: Number(orgUnitId), + year: Number(year), + value: value.trim(), + }); + } + }); + }); + const response = await importGridValues({ + metric_type_id: metricTypeId, + years, + values, + }); + patch({ importedYears: staged.gridYears }); + return response; + }, + [ + staged.gridYears, + staged.gridValues, + staged.importedYears, + importGridValues, + patch, + ], + ); + + const onCsvFileSelected = useCallback( + async (file: File) => { + const text = await file.text(); + const { years, valuesByOrgUnit } = parseYearlyCsv(text); + wizard.mergeGridFromCsv(years, valuesByOrgUnit); + }, + [wizard], + ); + + const goNext = useCallback(async () => { + // Steps aren't always a contiguous run of `WIZARD_STEPS` (an edit skips + // Data), so the actual next step is a lookup, not `activeStep + 1`. + const nextStep = wizard.steps[wizard.activeStepIndex + 1]; + const leavingDetails = nextStep === WIZARD_STEPS.DATA; + const leavingData = + wizard.activeStep === WIZARD_STEPS.DATA && + nextStep === WIZARD_STEPS.LEGEND; + // Re-entering Data after picking a different OpenHexa source on Details: + // discard the previous pick's import (and staged reference to it) right + // now, before Data opens, instead of leaving it to the Data step's import + // effect — otherwise the map preview briefly shows the old source's + // already-imported values under the newly picked one. + if ( + leavingDetails && + staged.layerType === 'openhexa' && + staged.createdMetricTypeId && + staged.importedCode !== formik.values.code + ) { + discardMetricType(staged.createdMetricTypeId, staged.layerType); + patch({ createdMetricTypeId: undefined, importedCode: undefined }); + } + const needsCompositeShell = + leavingDetails && + staged.layerType === 'composite' && + !staged.compositeLayerId; + const needsStandardCreate = + leavingDetails && + staged.layerType === 'data' && + !staged.createdMetricTypeId; + const createdMetricTypeId = staged.createdMetricTypeId; + const needsStandardImport = + leavingData && + staged.layerType === 'data' && + Boolean(createdMetricTypeId) && + (staged.gridYears.length > 0 || + (staged.importedYears?.length ?? 0) > 0); + if ( + !needsCompositeShell && + !needsStandardCreate && + !needsStandardImport + ) { + wizard.goNext(); + return; + } + if (busyRef.current) return; + busyRef.current = true; + setIsSubmitting(true); + try { + if (needsCompositeShell) { + // Persist the shell so the node editor (keyed by a real id) can run. + // The legend step comes after the graph now, so it isn't known yet — + // the shell's legend is filled in later, at final submit. + const saved = await saveComposite( + compositeLayerPayload(formik.values, legendPayload()), + ); + patch({ + compositeLayerId: saved.id, + createdMetricTypeId: saved.metric_type ?? undefined, + }); + } else if (needsStandardCreate) { + const created = (await createMetricType( + metricTypePayload(formik.values, legendPayload()) as any, + )) as MetricType; + patch({ createdMetricTypeId: created.id }); + } else if (needsStandardImport && createdMetricTypeId) { + const response = await submitGridValues(createdMetricTypeId); + const { suggested_legend_type, suggested_legend_config } = + response ?? {}; + if (suggested_legend_type && suggested_legend_config) { + formik.setValues({ + ...formik.values, + legend_type: suggested_legend_type, + legend_config: scaleFromDomainRange( + suggested_legend_config, + ), + legend_top_color: initialTopColor( + suggested_legend_config, + ), + }); + } + } + wizard.goNext(); + } catch { + // The failing mutation already surfaced its own error snackbar; + // this just stops the wizard from advancing. + } finally { + busyRef.current = false; + setIsSubmitting(false); + } + }, [ + wizard, + formik, + staged.layerType, + staged.compositeLayerId, + staged.createdMetricTypeId, + staged.importedCode, + staged.gridYears, + staged.importedYears, + saveComposite, + createMetricType, + submitGridValues, + discardMetricType, + legendPayload, + patch, + ]); + + // Fires once the wizard is actually showing the Data step, instead of blocking + // the Details -> Data transition on the import call. `goNext` already discards + // a stale previous pick's metric type (and clears `createdMetricTypeId` / + // `importedCode`) before Data opens, so by the time this runs it only ever + // sees a fresh pick to import. + const openHexaImportInFlight = useRef(false); + useEffect(() => { + if ( + wizard.activeStep !== WIZARD_STEPS.DATA || + staged.layerType !== 'openhexa' + ) { + return; + } + const code = formik.values.code; + if (!code || code === staged.importedCode) return; + if (openHexaImportInFlight.current) return; + openHexaImportInFlight.current = true; + setIsSubmitting(true); + (async () => { + try { + const { metric_type_id: metricTypeId } = await importOpenHexa({ + code, + legend_config: legendPayload(), + }); + patch({ + createdMetricTypeId: metricTypeId, + importedCode: code, + }); + } catch { + // `importOpenHexa` already surfaced its own error snackbar. + } finally { + openHexaImportInFlight.current = false; + setIsSubmitting(false); + } + })(); + }, [ + wizard.activeStep, + staged.layerType, + staged.importedCode, + formik.values.code, + importOpenHexa, + legendPayload, + patch, + ]); + + // Saves (or finalises) a standard/OpenHexa layer's metadata, then explicitly completes it - + // this is what turns a wizard-created shell into a real, listed layer. Skipped when the + // layer is already complete (e.g. OpenHexa's import task got there first), to avoid an + // unnecessary round trip. + const saveAndCompleteMetricType = useCallback( + async (payload: Record) => { + const updated = (await createMetricType( + payload as any, + )) as MetricType; + if (!updated.is_complete) { + try { + await completeMetricType(updated.id); + } catch { + // Already surfaced its own error snackbar. The metric type itself was + // saved successfully, so this best-effort, idempotent step failing must + // not stop the wizard from finishing - worst case it stays flagged + // incomplete until the next edit or a values import completes it. + } + } + return updated; + }, + [createMetricType, completeMetricType], + ); + + const submitStandard = useCallback(async () => { + const updated = await saveAndCompleteMetricType({ + id: staged.createdMetricTypeId, + ...metricTypePayload(formik.values, legendPayload()), + }); + onCreated(updated); + close(); + }, [ + staged.createdMetricTypeId, + formik.values, + legendPayload, + saveAndCompleteMetricType, + onCreated, + close, + ]); + + const submitEdit = useCallback(async () => { + const values = formik.values; + const legend_config = legendPayload(); + if (editing?.compositeLayer) { + const saved = await updateComposite({ + id: editing.compositeLayer.id, + ...compositeLayerPayload(values, legend_config), + }); + onCreated(saved.metric_type_detail ?? undefined); + } else { + const updated = await saveAndCompleteMetricType({ + id: editing?.metricType.id, + ...metricTypePayload(values, legend_config), + }); + onCreated(updated); + } + close(); + }, [ + editing, + formik.values, + legendPayload, + updateComposite, + saveAndCompleteMetricType, + onCreated, + close, + ]); + + /** Finalise a newly-created composite: the shell was minted before its graph + * step (with no legend yet) and the graph itself is already saved, so this + * just fills in the metadata + legend now that both are known. */ + const submitCompositeCreate = useCallback(async () => { + const saved = await updateComposite({ + id: staged.compositeLayerId, + ...compositeLayerPayload(formik.values, legendPayload()), + }); + onCreated(saved.metric_type_detail ?? undefined); + close(); + }, [ + staged.compositeLayerId, + formik.values, + legendPayload, + updateComposite, + onCreated, + close, + ]); + + const submit = useCallback(async () => { + setIsSubmitting(true); + try { + if (editing) { + await submitEdit(); + } else if (staged.layerType === 'composite') { + await submitCompositeCreate(); + } else { + // Standard is created by `goNext`, OpenHexa by the Data-step import + // effect; both are already created by this point, so this just + // finalises their legend. + await submitStandard(); + } + } catch { + // The failing mutation already surfaced its own error snackbar. + } finally { + setIsSubmitting(false); + } + }, [ + editing, + submitEdit, + staged.layerType, + submitCompositeCreate, + submitStandard, + ]); + + /** The composite node editor persisted the graph and, since it ran, the backend + * resolved a concrete legend from it (an "auto"/"reference" request becomes a + * real threshold/linear/ordinal one) — seed the Legend step's fields with it so + * it starts from what the graph actually produced instead of generic defaults, + * same as an OpenHexa pick autofills Details. Then move on to Legend instead of + * finishing; `submitCompositeCreate` does that once the user confirms it. */ + const onCompositeGraphSaved = useCallback( + (metricType?: MetricType) => { + if (metricType) { + // A single `setValues` call so Formik validates the fully merged legend + // fields once — three separate `setFieldValue` calls each validate + // against a stale snapshot missing the other two, which can leave + // `isValid` (and the Submit button) stuck on a spurious error. + formik.setValues({ + ...formik.values, + legend_type: metricType.legend_type, + legend_config: scaleFromDomainRange( + metricType.legend_config, + ), + legend_top_color: initialTopColor(metricType.legend_config), + }); + } + goNext(); + }, + [formik, goNext], + ); + + const stepLabels = useMemo( + () => + wizard.steps.map(step => + formatMessage( + step === WIZARD_STEPS.DATA && + staged.layerType === 'composite' + ? MESSAGES.wizardStepGraph + : WIZARD_STEP_LABELS[step], + ), + ), + [formatMessage, wizard.steps, staged.layerType], + ); + + const resolveMainView = (): WizardMainView => { + if (wizard.isCompositeGraphStep) return 'compositeGraph'; + if (wizard.activeStep === WIZARD_STEPS.DATA) return 'standardData'; + if (wizard.activeStep === WIZARD_STEPS.LEGEND) return 'legendPreview'; + return 'intro'; // Type / Details + }; + const wizardMainView = resolveMainView(); + + const isEditing = Boolean(editing); + // Resolved once here (the only place that knows both `isEditing` and the + // layer type) so the panel and the discard modal don't each re-derive it. + const titleMessage = isEditing ? MESSAGES.editLayer : MESSAGES.wizardTitle; + const discardMessage = + (isEditing && MESSAGES.wizardDiscardEditConfirm) || + (staged.layerType === 'composite' && + MESSAGES.wizardDiscardGraphConfirm) || + (staged.createdMetricTypeId && MESSAGES.wizardDiscardCreatedConfirm) || + MESSAGES.wizardDiscardLayerConfirm; + + return { + isOpen, + open, + openForEdit, + close, + requestClose, + discardOpen, + confirmDiscard, + cancelDiscard, + formik, + stepLabels, + titleMessage, + discardMessage, + activeStep: wizard.activeStep, + activeStepIndex: wizard.activeStepIndex, + lastStep: wizard.lastStep, + goNext, + goBack: requestBack, + backConfirmOpen, + confirmBack, + cancelBack, + canAdvance, + isEditing, + editingMetricType: editing?.metricType, + layerType: staged.layerType, + setLayerType, + staged, + onCsvFileSelected, + setGridValue: wizard.setGridValue, + addGridYear: wizard.addGridYear, + removeGridYear: wizard.removeGridYear, + isCompositeGraphStep: wizard.isCompositeGraphStep, + /** Which component the main column shows while the wizard is open; the page + * falls back to the map when it is closed. */ + wizardMainView, + compositeLayerId: staged.compositeLayerId, + onCompositeGraphSaved, + submit, + isSubmitting, + existingCodes, + categoryOptions, + }; +}; + +export type DataLayerWizardController = ReturnType< + typeof useDataLayerWizardController +>; diff --git a/js/src/domains/dataLayers/dataLayerWizard/useWizardMapPreview.ts b/js/src/domains/dataLayers/dataLayerWizard/useWizardMapPreview.ts new file mode 100644 index 00000000..1bc2db8b --- /dev/null +++ b/js/src/domains/dataLayers/dataLayerWizard/useWizardMapPreview.ts @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import { isConcreteLegend } from '../../../constants/legend'; +import { isSuccessTaskStatus } from '../../../constants/taskStatus'; +import { legendConfigFromForm } from '../dataLayerForm/legendScale'; +import { MapLegendConfig } from '../dataLayerMap/DataLayerMap'; +import { useGetMetricValues } from '../hooks/useGetMetrics'; +import { + OpenHexaImportStatus, + useGetOpenHexaImportStatus, +} from '../hooks/useGetOpenHexaImportStatus'; +import { MetricValue, ScaleDomainRange } from '../types/metrics'; +import { DataLayerWizardController } from './useDataLayerWizardController'; + +export type WizardMapPreview = { + ready: boolean; + isOpenHexa: boolean; + isComposite: boolean; + createdMetricTypeId?: number; + openHexaStatus?: OpenHexaImportStatus; + metricValues?: MetricValue[]; + legendConfig: MapLegendConfig; +}; + +export const useWizardMapPreview = ( + controller: DataLayerWizardController, +): WizardMapPreview => { + const { formik, staged } = controller; + const values = formik.values; + + const isComposite = staged.layerType === 'composite'; + const isOpenHexa = staged.layerType === 'openhexa'; + const createdMetricTypeId = staged.createdMetricTypeId; + + const { data: openHexaImportStatuses } = useGetOpenHexaImportStatus( + isOpenHexa && Boolean(createdMetricTypeId), + ); + const openHexaStatus = + isOpenHexa && createdMetricTypeId + ? openHexaImportStatuses?.[createdMetricTypeId] + : undefined; + const openHexaReady = isSuccessTaskStatus(openHexaStatus?.status); + const ready = isOpenHexa ? openHexaReady : Boolean(createdMetricTypeId); + + const previewMetricTypeId = (ready && createdMetricTypeId) || undefined; + const { data: metricValues } = useGetMetricValues({ + metricTypeId: previewMetricTypeId, + }); + + const legendConfig: MapLegendConfig = useMemo( + () => ({ + units: values.units, + unit_symbol: values.unit_symbol, + legend_type: values.legend_type, + legend_config: (isConcreteLegend(values.legend_type) + ? legendConfigFromForm( + values.legend_type, + values.legend_config, + values.legend_top_color, + ) + : { domain: [], range: [] }) as ScaleDomainRange, + }), + [ + values.units, + values.unit_symbol, + values.legend_type, + values.legend_config, + values.legend_top_color, + ], + ); + + return { + ready, + isOpenHexa, + isComposite, + createdMetricTypeId, + openHexaStatus, + metricValues, + legendConfig, + }; +}; diff --git a/js/src/domains/dataLayers/hooks/useCancelOpenHexaImport.ts b/js/src/domains/dataLayers/hooks/useCancelOpenHexaImport.ts new file mode 100644 index 00000000..b14feb05 --- /dev/null +++ b/js/src/domains/dataLayers/hooks/useCancelOpenHexaImport.ts @@ -0,0 +1,24 @@ +import { UseMutationResult } from 'react-query'; +import { postRequest } from 'Iaso/libs/Api'; +import { useSnackMutation } from 'Iaso/libs/apiHooks'; + +type CancelOpenHexaImportPayload = { + metric_type_id: number; +}; + +/** Asks a data layer's in-flight OpenHexa value import to stop, e.g. when the wizard + * discards or re-picks the layer before the background task has finished. */ +export const useCancelOpenHexaImport = (): UseMutationResult< + unknown, + unknown, + CancelOpenHexaImportPayload +> => + useSnackMutation({ + mutationFn: (body: CancelOpenHexaImportPayload) => + postRequest( + '/api/snt_malaria/openhexa/data_layers/cancel_import/', + body, + ), + invalidateQueryKey: ['openHexaImportStatus'], + showSuccessSnackBar: false, + }); diff --git a/js/src/domains/dataLayers/hooks/useCompleteMetricType.ts b/js/src/domains/dataLayers/hooks/useCompleteMetricType.ts new file mode 100644 index 00000000..5f5d7118 --- /dev/null +++ b/js/src/domains/dataLayers/hooks/useCompleteMetricType.ts @@ -0,0 +1,19 @@ +import { UseMutationResult } from 'react-query'; +import { postRequest } from 'Iaso/libs/Api'; +import { useSnackMutation } from 'Iaso/libs/apiHooks'; +import { MetricType } from '../types/metrics'; + +/** Flips a wizard-created shell to `is_complete=true` once it has usable values/legend. + * A no-op server-side if the metric type is already complete, so this is safe to call + * unconditionally at the end of the wizard. */ +export const useCompleteMetricType = (): UseMutationResult< + MetricType, + unknown, + number +> => + useSnackMutation({ + invalidateQueryKey: ['metricTypes', 'metricCategories'], + mutationFn: (metricTypeId: number) => + postRequest(`/api/metrictypes/${metricTypeId}/complete/`), + showSuccessSnackBar: false, + }); diff --git a/js/src/domains/dataLayers/hooks/useCreateOrUpdateMetricType.ts b/js/src/domains/dataLayers/hooks/useCreateOrUpdateMetricType.ts index db9795af..4cbd46db 100644 --- a/js/src/domains/dataLayers/hooks/useCreateOrUpdateMetricType.ts +++ b/js/src/domains/dataLayers/hooks/useCreateOrUpdateMetricType.ts @@ -23,18 +23,21 @@ type MetricTypePayload = { export const useCreateOrUpdateMetricType = ({ onError, onSuccess, -}): UseMutationResult => +}: { + onError: (error: ApiError) => void; + onSuccess: () => void; +}): UseMutationResult => useSnackMutation({ invalidateQueryKey: ['metricTypes', 'metricCategories'], mutationFn: (body: MetricTypePayload) => body.id ? patchRequest(`/api/metrictypes/${body.id}/`, body) : postRequest(`/api/metrictypes/`, body), + // The `code` field's "already taken" error is shown inline next to that + // field rather than as a snackbar; other 4xx errors are handled below. ignoreErrorCodes: [400], options: { - onError: (error: ApiError) => { - onError(error.details.code?.[0] || 'generic'); - }, - onSuccess: onSuccess, + onError, + onSuccess, }, }); diff --git a/js/src/domains/dataLayers/hooks/useDeleteMetricType.ts b/js/src/domains/dataLayers/hooks/useDeleteMetricType.ts index 8484676b..1c8d3e09 100644 --- a/js/src/domains/dataLayers/hooks/useDeleteMetricType.ts +++ b/js/src/domains/dataLayers/hooks/useDeleteMetricType.ts @@ -7,4 +7,8 @@ export const useDeleteMetricType = (): UseMutationResult => invalidateQueryKey: ['metricTypes', 'metricCategories'], mutationFn: (metricTypeId: number) => deleteRequest(`/api/metrictypes/${metricTypeId}/`), + // Used both for an explicit discard and for silently dropping a draft + // shell the wizard is replacing (e.g. re-picking an OpenHexa source); + // neither is worth a "Saved successfully" toast. + showSuccessSnackBar: false, }); diff --git a/js/src/domains/dataLayers/hooks/useGetMetrics.tsx b/js/src/domains/dataLayers/hooks/useGetMetrics.tsx index 16d7d178..e4c586c6 100644 --- a/js/src/domains/dataLayers/hooks/useGetMetrics.tsx +++ b/js/src/domains/dataLayers/hooks/useGetMetrics.tsx @@ -22,7 +22,8 @@ export const flattenMetricTypes = ( export const useGetMetricTypes: ( includeUtility?: boolean, -) => UseQueryResult = (includeUtility = false) => { + enabled?: boolean, +) => UseQueryResult = (includeUtility = false, enabled = true) => { const url = includeUtility ? '/api/metrictypes/?include_utility=true' : '/api/metrictypes/'; @@ -30,6 +31,7 @@ export const useGetMetricTypes: ( queryKey: ['metricTypes', { includeUtility }], queryFn: () => getRequest(url), options: { + enabled, cacheTime: Infinity, // disable auto fetch on cache expiration }, }); diff --git a/js/src/domains/dataLayers/hooks/useImportMetricValuesJson.ts b/js/src/domains/dataLayers/hooks/useImportMetricValuesJson.ts new file mode 100644 index 00000000..6e9e2d2d --- /dev/null +++ b/js/src/domains/dataLayers/hooks/useImportMetricValuesJson.ts @@ -0,0 +1,32 @@ +import { UseMutationResult } from 'react-query'; +import { postRequest } from 'Iaso/libs/Api'; +import { useSnackMutation } from 'Iaso/libs/apiHooks'; +import { MESSAGES } from '../messages'; +import { ScaleDomainRange } from '../types/metrics'; + +export type ImportMetricValuesJsonPayload = { + metric_type_id: number; + years: number[]; + values: { org_unit_id: number; year: number; value: string }[]; +}; + +export type ImportMetricValuesJsonResponse = { + total_imported: number; + suggested_legend_type?: string; + suggested_legend_config?: ScaleDomainRange; +}; + +export const useImportMetricValuesJson = (): UseMutationResult< + ImportMetricValuesJsonResponse, + unknown, + ImportMetricValuesJsonPayload +> => + useSnackMutation({ + mutationFn: (body: ImportMetricValuesJsonPayload) => + postRequest('/api/metricvalues/import_values/', body), + invalidateQueryKey: ['metricCategories', 'metricValues'], + // This runs on the wizard's Data -> Legend transition; the wizard moving + // on to the next step is feedback enough, so a success toast is noise. + showSuccessSnackBar: false, + snackErrorMsg: MESSAGES.metricValuesImportError, + }); diff --git a/js/src/domains/dataLayers/hooks/useImportOpenHexaDataLayer.ts b/js/src/domains/dataLayers/hooks/useImportOpenHexaDataLayer.ts index 611ff239..ca85af1e 100644 --- a/js/src/domains/dataLayers/hooks/useImportOpenHexaDataLayer.ts +++ b/js/src/domains/dataLayers/hooks/useImportOpenHexaDataLayer.ts @@ -2,7 +2,6 @@ import { UseMutationResult } from 'react-query'; import { Task } from 'Iaso/domains/tasks/types'; import { postRequest } from 'Iaso/libs/Api'; import { useSnackMutation } from 'Iaso/libs/apiHooks'; -import { MESSAGES } from '../messages'; import { ScaleDomainRange } from '../types/metrics'; type ImportOpenHexaDataLayerPayload = { @@ -28,9 +27,11 @@ export const useImportOpenHexaDataLayer = ({ mutationFn: (body: ImportOpenHexaDataLayerPayload) => postRequest('/api/snt_malaria/openhexa/data_layers/', body), // The new layer is an empty shell at this point; `openHexaImportStatus` drives the - // row badge and, on task completion, the layer list + values refetch. + // row badge and, on task completion, the layer list + values refetch. Its progress + // is already shown live by `OpenHexaImportStatusMessage`/`ImportStatusIndicator`, + // so a "started" snackbar here would just duplicate that. invalidateQueryKey: ['metricTypes', 'openHexaImportStatus'], - snackSuccessMessage: MESSAGES.openHexaImportStarted, + showSuccessSnackBar: false, options: { onSuccess: () => onSuccess?.(), }, diff --git a/js/src/domains/dataLayers/hooks/useMetricTypeFormState.tsx b/js/src/domains/dataLayers/hooks/useMetricTypeFormState.tsx index 0efaa6f4..cdf626a0 100644 --- a/js/src/domains/dataLayers/hooks/useMetricTypeFormState.tsx +++ b/js/src/domains/dataLayers/hooks/useMetricTypeFormState.tsx @@ -9,9 +9,14 @@ import { LEGEND_TYPE_MIN_ITEMS, LegendTypes, } from '../../../constants/legend'; -import { DEFAULT_COLOR } from '../dataLayerForm/legendScale'; +import { CompositeLayerListItem } from '../../compositeLayerEditor/types/compositeLayer'; +import { + DEFAULT_COLOR, + initialTopColor, + scaleFromDomainRange, +} from '../dataLayerForm/legendScale'; import { MESSAGES } from '../messages'; -import { MetricTypeFormModel } from '../types/metrics'; +import { MetricType, MetricTypeFormModel } from '../types/metrics'; export const DEFAULT_LEGEND_CONFIG_ITEM = { color: DEFAULT_COLOR, @@ -35,6 +40,45 @@ const DEFAULT_METRIC_TYPE: MetricTypeFormModel = { is_composite: false, }; +export const makeDefaultMetricType = (): MetricTypeFormModel => ({ + ...DEFAULT_METRIC_TYPE, + legend_config: [ + { ...DEFAULT_LEGEND_CONFIG_ITEM }, + { ...DEFAULT_LEGEND_CONFIG_ITEM }, + ], +}); + +export const editFormModel = ( + metricType: MetricType, + compositeLayer?: Pick< + CompositeLayerListItem, + 'legend_config' | 'legend_type' + >, +): MetricTypeFormModel => { + const legendSource = compositeLayer + ? compositeLayer.legend_config + : metricType.legend_config; + return { + id: metricType.id, + name: metricType.name, + code: metricType.code, + description: metricType.description, + source: metricType.source, + units: metricType.units, + unit_symbol: metricType.unit_symbol, + comments: metricType.comments, + category: metricType.category, + legend_type: compositeLayer + ? compositeLayer.legend_type + : metricType.legend_type, + origin: metricType.origin, + is_population: metricType.metric_kind === 'population', + is_composite: Boolean(compositeLayer), + legend_config: scaleFromDomainRange(legendSource), + legend_top_color: initialTopColor(legendSource), + }; +}; + const HEX_COLOR = /^#([0-9A-F]{3}){1,2}$/i; const useValidationSchema = () => { diff --git a/js/src/domains/dataLayers/index.tsx b/js/src/domains/dataLayers/index.tsx index 965e4057..14c4f0f3 100644 --- a/js/src/domains/dataLayers/index.tsx +++ b/js/src/domains/dataLayers/index.tsx @@ -48,10 +48,16 @@ import { useGetAccountSettings } from '../planning/hooks/useGetAccountSettings'; import { useGetOrgUnits } from '../planning/hooks/useGetOrgUnits'; import { DataLayerComparisonProvider } from './contexts/DataLayerComparisonContext'; import { DataLayerComparisonContainer } from './dataLayerComparison/dataLayerComparisonContainer'; -import { DataLayerDialog } from './dataLayerForm/DataLayerDialog'; import { DataLayerList } from './dataLayerList/DataLayerList'; import { DataLayerListHeader } from './dataLayerList/DataLayerListHeader'; import { DataLayerMapWrapper } from './dataLayerMap/DataLayerMapWrapper'; +import { DataLayerWizardMain } from './dataLayerWizard/DataLayerWizardMain'; +import { DataLayerWizardPanel } from './dataLayerWizard/DataLayerWizardPanel'; +import { DiscardWizardModal } from './dataLayerWizard/DiscardWizardModal'; +import { useDataLayerWizardController } from './dataLayerWizard/useDataLayerWizardController'; +import { useWizardMapPreview } from './dataLayerWizard/useWizardMapPreview'; +import { WizardLegendPreview } from './dataLayerWizard/WizardLegendPreview'; +import { WizardPreviewPlaceholder } from './dataLayerWizard/WizardPreviewPlaceholder'; import { useDeleteMetricType } from './hooks/useDeleteMetricType'; import { useGetMetricCategories } from './hooks/useGetMetrics'; import { useGetOpenHexaDataLayers } from './hooks/useGetOpenHexaDataLayers'; @@ -133,9 +139,6 @@ export const DataLayers: FC = () => { [importOpenHexaDataLayer], ); - const [isMetricTypeFormOpen, setIsMetricTypeFormOpen] = - useState(false); - const [isCompositeEditorOpen, setIsCompositeEditorOpen] = useState(false); const [editingCompositeLayerId, setEditingCompositeLayerId] = useState< @@ -195,15 +198,6 @@ export const DataLayers: FC = () => { [compositeLayerByMetricType], ); - // Keeps the edited composite selected in the list while the editor is open. - const editedCompositeMetricTypeId = useMemo( - () => - (compositeLayers ?? []).find( - layer => layer.id === editingCompositeLayerId, - )?.metric_type ?? undefined, - [compositeLayers, editingCompositeLayerId], - ); - // Collapsible data layers sidebar (mirrors the scenario editor's rules-panel toggle). const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const toggleSidebar = useCallback(() => { @@ -217,38 +211,11 @@ export const DataLayers: FC = () => { const [nodeSearchTerm, setNodeSearchTerm] = useState(''); const isAiChatTab = sidebarTab === 'ai' && hasAiApiKey; - const [selectedMetricType, setSelectedMetricType] = useState(); - - const onDialogClose = useCallback(() => { - setIsMetricTypeFormOpen(false); - setSelectedMetricType(undefined); - }, [setIsMetricTypeFormOpen, setSelectedMetricType]); - - const onCreateMetricType = useCallback(() => { - setSelectedMetricType(undefined); - setIsMetricTypeFormOpen(true); - }, [setSelectedMetricType, setIsMetricTypeFormOpen]); - - // "Edit Layer" always opens the legend editor, for composites and regular layers alike. - const onEditMetricType = useCallback((metricType: MetricType) => { - setSelectedMetricType(metricType); - setIsMetricTypeFormOpen(true); - }, []); - const onEditCompositeLayer = useCallback((compositeLayerId: number) => { setEditingCompositeLayerId(compositeLayerId); setIsCompositeEditorOpen(true); }, []); - // The dialogue persists the new composite; we then open the editor to build its graph. - const onCompositeCreated = useCallback( - (compositeLayerId: number) => { - onDialogClose(); - onEditCompositeLayer(compositeLayerId); - }, - [onDialogClose, onEditCompositeLayer], - ); - const onCloseCompositeEditor = useCallback(() => { setIsCompositeEditorOpen(false); setEditingCompositeLayerId(undefined); @@ -269,6 +236,99 @@ export const DataLayers: FC = () => { [onCloseCompositeEditor], ); + // Four-step creation wizard (Type -> Details -> Data/Graph -> Legend); editing + // an existing layer runs its Details + Legend steps pre-filled. + const onWizardCreated = useCallback((metricType?: MetricType) => { + if (metricType) { + setDisplayedMetricType(metricType); + } + }, []); + const wizard = useDataLayerWizardController({ + onCreated: onWizardCreated, + categoryOptions: existingCategoryOptions, + onClosed: onCloseCompositeEditor, + }); + const wizardPreview = useWizardMapPreview(wizard); + + const onEditMetricType = useCallback( + (metricType: MetricType) => + wizard.openForEdit( + metricType, + compositeLayerByMetricType.get(metricType.id), + ), + [wizard, compositeLayerByMetricType], + ); + + const activeCompositeLayerId = + editingCompositeLayerId ?? wizard.compositeLayerId; + const isCompositeEditorActive = + isCompositeEditorOpen || wizard.isCompositeGraphStep; + + const onWizardGraphSaved = useCallback( + (metricType?: MetricType) => { + setSidebarCollapsed(false); + wizard.onCompositeGraphSaved(metricType); + }, + [wizard], + ); + + // Keeps the edited composite selected in the list while the editor is open. + const editedCompositeMetricTypeId = useMemo( + () => + (compositeLayers ?? []).find( + layer => layer.id === activeCompositeLayerId, + )?.metric_type ?? undefined, + [compositeLayers, activeCompositeLayerId], + ); + + // Wizard "Next: Legend" on the composite graph step: persist the graph via the + // editor, whose onSaved advances the wizard. + const handleCompositeNext = useCallback(() => { + compositeLayerEditorRef.current?.saveGraph(); + }, []); + + // Node library + AI tabs — the sidebar content for the composite node editor, + // shared by the standalone "Edit graph" flow and the creation wizard's graph step. + const compositeSidebar = !isCompositeEditorActive ? null : ( + + + {!isAiChatTab && ( + + )} + + } + > + {isAiChatTab ? ( + + ) : ( + + )} + + ); + // Two-step spotlight when the account has no layers yet const hasNoLayers = useMemo( () => @@ -303,6 +363,139 @@ export const DataLayers: FC = () => { steps: onboardingSteps, }); + const layerListCard = ( + + + } + > + + + + ); + + const renderSidebarColumn = () => { + if (isCompositeEditorOpen) { + return {compositeSidebar}; + } + if (wizard.isOpen) { + return ( + + ); + } + return layerListCard; + }; + + const compositeLayerIdFor = ( + metricType?: MetricType, + ): number | undefined => + metricType + ? compositeLayerIdByMetricType.get(metricType.id) + : undefined; + + const mapColumn = ( + + + + + ); + + const editorProps = wizard.isCompositeGraphStep + ? { + onClose: wizard.requestClose, + onSaved: onWizardGraphSaved, + hideActions: true, + onToggleSidebar: undefined, + } + : { + onClose: onCloseCompositeEditor, + onSaved: onCompositeSaved, + hideActions: false, + onToggleSidebar: toggleSidebar, + }; + + const renderMainColumn = () => { + if (isCompositeEditorActive && activeCompositeLayerId) { + return ( + + ); + } + if (!wizard.isOpen) return mapColumn; + if (wizard.isEditing) { + // Editing shows the real layer as a reference; the panel handles the form. + return ( + + ); + } + switch (wizard.wizardMainView) { + case 'standardData': + return ( + + ); + case 'legendPreview': + return ( + + ); + case 'intro': + return ; + default: + // 'compositeGraph' before the shell exists — show the map briefly. + return mapColumn; + } + }; + return ( {isLoadingMetricLayers && } @@ -316,174 +509,29 @@ export const DataLayers: FC = () => { {!sidebarCollapsed && ( - {isCompositeEditorOpen ? ( - - - - {!isAiChatTab && ( - - )} - - } - > - {isAiChatTab ? ( - - ) : ( - - )} - - - ) : ( - - - } - > - - - - )} + {renderSidebarColumn()} )} - - {isCompositeEditorOpen && - editingCompositeLayerId ? ( - - ) : ( - - - - - )} - + {renderMainColumn()} - {isMetricTypeFormOpen && ( - - )} + + {onboarding.element} diff --git a/js/src/domains/dataLayers/messages.ts b/js/src/domains/dataLayers/messages.ts index 07af7a3e..76287c95 100644 --- a/js/src/domains/dataLayers/messages.ts +++ b/js/src/domains/dataLayers/messages.ts @@ -5,6 +5,10 @@ export const MESSAGES = defineMessages({ defaultMessage: 'Cancel', id: 'iaso.label.cancel', }, + confirm: { + defaultMessage: 'Confirm', + id: 'iaso.label.confirm', + }, description: { id: 'iaso.snt_malaria.label.description', defaultMessage: 'Description', @@ -33,22 +37,6 @@ export const MESSAGES = defineMessages({ id: 'iaso.snt_malaria.settings.dataLayers.createLayer', defaultMessage: 'Create layer', }, - continueToEditor: { - id: 'iaso.snt_malaria.settings.dataLayers.continueToEditor', - defaultMessage: 'Continue', - }, - generalSectionTitle: { - id: 'iaso.snt_malaria.settings.dataLayers.generalSectionTitle', - defaultMessage: 'General', - }, - legendSectionTitle: { - id: 'iaso.snt_malaria.settings.dataLayers.legendSectionTitle', - defaultMessage: 'Legend & scale', - }, - layerType: { - id: 'iaso.snt_malaria.settings.dataLayers.layerType', - defaultMessage: 'Layer type', - }, layerTypeData: { id: 'iaso.snt_malaria.settings.dataLayers.layerTypeData', defaultMessage: 'Standard data layer', @@ -83,11 +71,7 @@ export const MESSAGES = defineMessages({ }, openHexaFieldsReadOnly: { id: 'iaso.snt_malaria.settings.dataLayers.openHexaFieldsReadOnly', - defaultMessage: 'read-only · from data layer', - }, - openHexaColorsEditable: { - id: 'iaso.snt_malaria.settings.dataLayers.openHexaColorsEditable', - defaultMessage: 'colors editable', + defaultMessage: 'Filled in from OpenHexa — read-only.', }, openHexaDataLayersError: { id: 'iaso.snt_malaria.settings.dataLayers.openHexaDataLayersError', @@ -102,11 +86,6 @@ export const MESSAGES = defineMessages({ id: 'iaso.snt_malaria.settings.dataLayers.openHexaLayersAlreadyImported', defaultMessage: 'Already imported (hidden): {layers}', }, - openHexaImportStarted: { - id: 'iaso.snt_malaria.settings.dataLayers.openHexaImportStarted', - defaultMessage: - 'Import started. The layer values will appear once the task completes.', - }, refreshFromOpenHexa: { id: 'iaso.snt_malaria.settings.dataLayers.refreshFromOpenHexa', defaultMessage: 'Refresh from OpenHexa', @@ -119,6 +98,10 @@ export const MESSAGES = defineMessages({ id: 'iaso.snt_malaria.settings.dataLayers.importFailed', defaultMessage: 'OpenHexa import failed', }, + layerSetupIncomplete: { + id: 'iaso.snt_malaria.settings.dataLayers.layerSetupIncomplete', + defaultMessage: 'Setup was not completed for this layer', + }, deleteLayer: { id: 'iaso.snt_malaria.settings.dataLayers.deleteLayer', defaultMessage: 'Delete Layer', @@ -143,18 +126,10 @@ export const MESSAGES = defineMessages({ id: 'ias.snt_malaria.settings.dataLayers.addToComparison', defaultMessage: 'Add to comparison maps', }, - codeImmutableErrorHeadline: { - id: 'iaso.snt_malaria.settings.dataLayers.errors.codeImmutableHeadline', - defaultMessage: 'Layer code is immutable.', - }, genericError: { id: 'iaso.snt_malaria.settings.dataLayers.errors.genericError', defaultMessage: 'Please try again.', }, - genericErrorHeadline: { - id: 'iaso.snt_malaria.settings.dataLayers.errors.genericErrorHeadline', - defaultMessage: 'An unexpected error occurred.', - }, invalidColor: { id: 'iaso.snt_malaria.settings.dataLayers.errors.invalidColor', defaultMessage: 'Invalid color format. Use hex code like #A1B2C3.', @@ -172,10 +147,6 @@ export const MESSAGES = defineMessages({ defaultMessage: 'A Layer with this code already exists. Please choose a different code.', }, - uniqueCodeErrorHeadline: { - id: 'iaso.snt_malaria.settings.dataLayers.errors.uniqueCodeErrorHeadline', - defaultMessage: 'Layer with this code already exists.', - }, label: { id: 'iaso.snt_malaria.settings.dataLayers.label', defaultMessage: 'Name', @@ -286,4 +257,128 @@ export const MESSAGES = defineMessages({ defaultMessage: 'The year for which the metric values will be imported.', }, + wizardTitle: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.title', + defaultMessage: 'New data layer', + }, + wizardSaveChanges: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.saveChanges', + defaultMessage: 'Save changes', + }, + wizardStepType: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.stepType', + defaultMessage: 'Type', + }, + wizardStepDetails: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.stepDetails', + defaultMessage: 'Details', + }, + wizardStepData: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.stepData', + defaultMessage: 'Data', + }, + wizardStepGraph: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.stepGraph', + defaultMessage: 'Graph', + }, + wizardStepLegend: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.stepLegend', + defaultMessage: 'Legend', + }, + wizardBack: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.back', + defaultMessage: 'Back', + }, + wizardNext: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.next', + defaultMessage: 'Next: {step}', + }, + wizardTypeQuestion: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.typeQuestion', + defaultMessage: 'What kind of layer is this?', + }, + wizardImportCsvHint: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.importCsvHint', + defaultMessage: + 'Upload a CSV to fill the table below, then fine-tune it there before saving.', + }, + wizardAddYear: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.addYear', + defaultMessage: 'Add year', + }, + wizardRemoveYear: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.removeYear', + defaultMessage: 'Remove {year}', + }, + wizardManualPaste: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.manualPaste', + defaultMessage: 'Paste from clipboard', + }, + wizardManualFilledCount: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.manualFilledCount', + defaultMessage: '{filled} of {total} values filled', + }, + wizardManualPasteHelp: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.manualPasteHelp', + defaultMessage: + 'Paste one value per line, in the district order shown, to fill a year column.', + }, + wizardValueAboveHundred: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.valueAboveHundred', + defaultMessage: 'Above 100 while units are percent', + }, + wizardGridRegion: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.gridRegion', + defaultMessage: 'Region', + }, + wizardGridDistrict: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.gridDistrict', + defaultMessage: 'District', + }, + wizardSummaryAddsToCategory: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.summaryAddsToCategory', + defaultMessage: 'Creating this layer adds it to {category}.', + }, + wizardOpenHexaManualRefresh: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.openHexaManualRefresh', + defaultMessage: + 'This layer keeps the snapshot you import until someone refreshes it from OpenHexa.', + }, + wizardOpenHexaImportInfo: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.openHexaImportInfo', + defaultMessage: + 'The layer was created and its values are loading from OpenHexa in the background — you can carry on through the wizard while it runs.', + }, + wizardOpenHexaImportComplete: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.openHexaImportComplete', + defaultMessage: 'Import complete — check the result table.', + }, + wizardPreviewPlaceholder: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.previewPlaceholder', + defaultMessage: + 'A preview of your layer appears here once you add its data.', + }, + wizardDiscardLayerConfirm: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.discardLayerConfirm', + defaultMessage: + 'Nothing is saved yet — leaving now discards this layer.', + }, + wizardDiscardGraphConfirm: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.discardGraphConfirm', + defaultMessage: 'Leaving now discards this layer and its graph.', + }, + wizardDiscardCreatedConfirm: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.discardCreatedConfirm', + defaultMessage: + 'Leaving now removes this layer and any values already imported for it.', + }, + wizardDiscardEditConfirm: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.discardEditConfirm', + defaultMessage: 'Discard your unsaved changes to this layer?', + }, + wizardBackGraphConfirm: { + id: 'iaso.snt_malaria.settings.dataLayers.wizard.backGraphConfirm', + defaultMessage: + 'Going back now will discard any unsaved changes to the composite graph.', + }, }); diff --git a/js/src/domains/dataLayers/types/metrics.ts b/js/src/domains/dataLayers/types/metrics.ts index 792a6e6e..04c6783b 100644 --- a/js/src/domains/dataLayers/types/metrics.ts +++ b/js/src/domains/dataLayers/types/metrics.ts @@ -19,6 +19,7 @@ export type MetricType = { legend_type: string; origin: string; metric_kind?: MetricKind; + is_complete?: boolean; }; export type MetricTypeFormModel = { diff --git a/js/src/domains/planning/components/scenarioRule/scenarioRuleForm/MatchingCriteriaForm.tsx b/js/src/domains/planning/components/scenarioRule/scenarioRuleForm/MatchingCriteriaForm.tsx index a5db9089..94de4fff 100644 --- a/js/src/domains/planning/components/scenarioRule/scenarioRuleForm/MatchingCriteriaForm.tsx +++ b/js/src/domains/planning/components/scenarioRule/scenarioRuleForm/MatchingCriteriaForm.tsx @@ -52,12 +52,14 @@ export const MatchingCriteriaForm: FC = ({ () => metricTypeCategories.flatMap( mtc => - mtc.items.map(mt => ({ - value: mt.id, - label: mt.name, - groupKey: mtc.name, - groupLabel: mtc.name, - })) || [], + mtc.items + .filter(mt => mt.is_complete !== false) + .map(mt => ({ + value: mt.id, + label: mt.name, + groupKey: mtc.name, + groupLabel: mtc.name, + })) || [], ), [metricTypeCategories], ); diff --git a/js/src/domains/planning/libs/map-utils.tsx b/js/src/domains/planning/libs/map-utils.tsx index cb59ccc0..b273aa09 100644 --- a/js/src/domains/planning/libs/map-utils.tsx +++ b/js/src/domains/planning/libs/map-utils.tsx @@ -10,7 +10,7 @@ import { } from '../../dataLayers/types/metrics'; import { MESSAGES } from '../../messages'; import { defaultLegend } from './color-utils'; -import { formatMetricValue } from './metric-utils'; +import { formatMetricValue, hasNumericMetricValue } from './metric-utils'; export const defaultZoomSnap = 0.25; export const defaultZoomDelta = 0.5; @@ -135,7 +135,7 @@ export const useGetOrgUnitMetric = ( return undefined; } - if (metricValue.value || metricValue.value === 0) { + if (hasNumericMetricValue(metricValue)) { return { label: metricValue.value, value: metricValue.value.toString(), @@ -157,13 +157,13 @@ export const useGetOrgUnitMetric = ( }; export const getMapStyleForOrgUnit = ( - metricType: MetricType, + legend: Pick, metric?: { label: string; value: string | number }, ) => { const color = getColorForShape( metric?.value, - metricType?.legend_type, - metricType?.legend_config, + legend?.legend_type, + legend?.legend_config, ); return { color, label: formatMetricValue(metric?.label) }; }; diff --git a/js/src/domains/planning/libs/metric-utils.tsx b/js/src/domains/planning/libs/metric-utils.tsx index 850a0aec..ca348160 100644 --- a/js/src/domains/planning/libs/metric-utils.tsx +++ b/js/src/domains/planning/libs/metric-utils.tsx @@ -1,3 +1,5 @@ +import { MetricValue } from '../../dataLayers/types/metrics'; + export const formatMetricValue = (metricValue?: string | number) => { if (metricValue === undefined || metricValue === null) return 'N/A'; @@ -6,3 +8,7 @@ export const formatMetricValue = (metricValue?: string | number) => { } return metricValue; }; + +export const hasNumericMetricValue = ( + metricValue: Pick, +): boolean => Boolean(metricValue.value) || metricValue.value === 0; diff --git a/locale/fr/LC_MESSAGES/django.mo b/locale/fr/LC_MESSAGES/django.mo index 79feb152..6cf411a6 100644 Binary files a/locale/fr/LC_MESSAGES/django.mo and b/locale/fr/LC_MESSAGES/django.mo differ diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 545f4d33..903132bc 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-21 13:58+0000\n" +"POT-Creation-Date: 2026-09-22 12:46+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -73,61 +73,6 @@ msgstr "" "Il est impossible de supprimer cette intervention car elle est utilisée par " "une ou plusieurs règles de scénario." -#: plugins/snt_malaria/api/openhexa_data_layers/client.py -#, python-brace-format -msgid "The OpenHexa workspace configuration is missing the '{key}' key." -msgstr "" -"La configuration de l'espace de travail OpenHexa ne contient pas la clé " -"« {key} »." - -#: plugins/snt_malaria/api/openhexa_data_layers/client.py -#, python-brace-format -msgid "OpenHexa dataset '{slug}' was not found in workspace '{workspace}'." -msgstr "" -"Le jeu de données OpenHexa « {slug} » est introuvable dans l'espace de " -"travail « {workspace} »." - -#: plugins/snt_malaria/api/openhexa_data_layers/client.py -#, python-brace-format -msgid "OpenHexa dataset '{slug}' has no versions." -msgstr "Le jeu de données OpenHexa « {slug} » n'a aucune version." - -#: plugins/snt_malaria/api/openhexa_data_layers/client.py -#, python-brace-format -msgid "File '{filename}' was not found in OpenHexa dataset '{slug}'." -msgstr "" -"Le fichier « {filename} » est introuvable dans le jeu de données OpenHexa " -"« {slug} »." - -#: plugins/snt_malaria/api/openhexa_data_layers/client.py -#, python-brace-format -msgid "Could not get a download URL for '{filename}'." -msgstr "Impossible d'obtenir une URL de téléchargement pour « {filename} »." - -#: plugins/snt_malaria/api/openhexa_data_layers/client.py -#, python-brace-format -msgid "File '{filename}' is not valid JSON: {error}" -msgstr "Le fichier « {filename} » n'est pas un JSON valide : {error}" - -#: plugins/snt_malaria/api/openhexa_data_layers/importer.py -#, python-brace-format -msgid "The source file has no '{column}' column." -msgstr "Le fichier source ne contient pas de colonne « {column} »." - -#: plugins/snt_malaria/api/openhexa_data_layers/importer.py -#, python-brace-format -msgid "None of the {rows} source rows matched an org unit (by {column})." -msgstr "" -"Aucune des {rows} lignes du fichier source ne correspond à une unité " -"d'organisation (via {column})." - -#: plugins/snt_malaria/api/openhexa_data_layers/importer.py -#, python-brace-format -msgid "{rows} rows read, {matched} org units matched, writing {count} values" -msgstr "" -"{rows} lignes lues, {matched} unités d'organisation correspondantes, " -"écriture de {count} valeurs" - #: plugins/snt_malaria/api/openhexa_data_layers/metadata.py #, python-format msgid "" @@ -248,10 +193,66 @@ msgstr "Configuration - Lecture" msgid "Settings - Write" msgstr "Configuration - Écriture" +#: plugins/snt_malaria/providers/openhexa_data_layers.py +#, python-brace-format +msgid "The OpenHexa workspace configuration is missing the '{key}' key." +msgstr "" +"La configuration de l'espace de travail OpenHexa ne contient pas la clé " +"« {key} »." + +#: plugins/snt_malaria/providers/openhexa_data_layers.py +#, python-brace-format +msgid "OpenHexa dataset '{slug}' was not found in workspace '{workspace}'." +msgstr "" +"Le jeu de données OpenHexa « {slug} » est introuvable dans l'espace de " +"travail « {workspace} »." + +#: plugins/snt_malaria/providers/openhexa_data_layers.py +#, python-brace-format +msgid "OpenHexa dataset '{slug}' has no versions." +msgstr "Le jeu de données OpenHexa « {slug} » n'a aucune version." + +#: plugins/snt_malaria/providers/openhexa_data_layers.py +#, python-brace-format +msgid "File '{filename}' was not found in OpenHexa dataset '{slug}'." +msgstr "" +"Le fichier « {filename} » est introuvable dans le jeu de données OpenHexa " +"« {slug} »." + +#: plugins/snt_malaria/providers/openhexa_data_layers.py +#, python-brace-format +msgid "Could not get a download URL for '{filename}'." +msgstr "Impossible d'obtenir une URL de téléchargement pour « {filename} »." + +#: plugins/snt_malaria/providers/openhexa_data_layers.py +#, python-brace-format +msgid "File '{filename}' is not valid JSON: {error}" +msgstr "Le fichier « {filename} » n'est pas un JSON valide : {error}" + +#: plugins/snt_malaria/services/openhexa_data_layers.py +#, python-brace-format +msgid "The source file has no '{column}' column." +msgstr "Le fichier source ne contient pas de colonne « {column} »." + +#: plugins/snt_malaria/services/openhexa_data_layers.py +#, python-brace-format +msgid "None of the {rows} source rows matched an org unit (by {column})." +msgstr "" +"Aucune des {rows} lignes du fichier source ne correspond à une unité " +"d'organisation (via {column})." + +#: plugins/snt_malaria/services/openhexa_data_layers.py +#, python-brace-format +msgid "{rows} rows read, {matched} org units matched, writing {count} values" +msgstr "" +"{rows} lignes lues, {matched} unités d'organisation correspondantes, " +"écriture de {count} valeurs" + #: plugins/snt_malaria/tasks/import_openhexa_data_layer.py #, python-brace-format msgid "Data layer '{code}' is no longer defined in SNT_metadata.json" -msgstr "La couche de données '{code}' n'est plus définie dans SNT_metadata.json" +msgstr "" +"La couche de données '{code}' n'est plus définie dans SNT_metadata.json" #, python-brace-format #~ msgid "Writing {count} values" diff --git a/services/composite/legends.py b/services/composite/legends.py index 6e403604..bc993bb2 100644 --- a/services/composite/legends.py +++ b/services/composite/legends.py @@ -9,23 +9,15 @@ import copy -from typing import Iterable, List, Tuple +from typing import Tuple from iaso.models.metric import MetricType -from iaso.utils.legend import SEVEN_SHADES, get_range_from_count +from iaso.utils.legend import SEVEN_SHADES, build_auto_legend_config, is_categorical_values, resolve_auto_legend_type -from .evaluator import CompositeGraphEvaluator, Value, ValuesByYear, iter_all_values +from .evaluator import CompositeGraphEvaluator, ValuesByYear, iter_all_values from .graph import CONCRETE_LEGEND_TYPES, REFERENCE_LEGEND, is_valid_legend_config -# Number of colours used for an auto-generated threshold legend (=> num_colors - 1 breakpoints). -NUM_THRESHOLD_COLORS = len(SEVEN_SHADES) - - -def _is_categorical(values: Iterable[Value]) -> bool: - return any(isinstance(value, str) for value in values) - - def placeholder_legend(legend_type=None, legend_config=None) -> Tuple[str, dict]: """Legend for a composite that has no values yet: the requested one when it is concrete enough to stand on its own, else an empty threshold legend until the first successful run.""" @@ -34,79 +26,6 @@ def placeholder_legend(legend_type=None, legend_config=None) -> Tuple[str, dict] return MetricType.LegendType.THRESHOLD, {"domain": [], "range": list(SEVEN_SHADES)} -def _ordinal_legend_config(domain: List) -> dict: - palette = list(SEVEN_SHADES) - colors = [palette[index % len(palette)] for index in range(len(domain))] - return {"domain": list(domain), "range": colors} - - -def _linear_legend_config(low: float, high: float) -> dict: - # d3's ``scaleLinear`` needs ``len(domain) == len(range)``, so emit exactly two of each and let - # the colours be interpolated between ``low`` and ``high``. - return {"domain": [low, high], "range": [SEVEN_SHADES[0], SEVEN_SHADES[-1]]} - - -def _threshold_legend_config(low: float, high: float, num_colors: int = NUM_THRESHOLD_COLORS) -> dict: - """Equal-interval threshold classification. - - d3's ``scaleThreshold`` expects ``len(range) == len(domain) + 1`` (one extra colour for the - bucket above the last breakpoint), so ``num_colors`` colours need ``num_colors - 1`` *interior* - breakpoints dividing ``[low, high]`` into equal buckets. - """ - if low is None or high is None or high <= low: - # Degenerate range (all values equal / single value): one breakpoint, two colours. - return {"domain": [low], "range": [SEVEN_SHADES[0], SEVEN_SHADES[-1]]} - - round_digits = 2 if low < 1 else 0 - step = (high - low) / num_colors - breakpoints: List[float] = [] - for index in range(1, num_colors): - value = round(low + step * index, round_digits) - # Rounding can collapse neighbouring breakpoints on a small range; keep them distinct. - if not breakpoints or value != breakpoints[-1]: - breakpoints.append(value) - return {"domain": breakpoints, "range": get_range_from_count(len(breakpoints))} - - -def _build_legend_config(legend_type: str, values: Iterable[Value], category_order: List[str] | None = None) -> dict: - non_null = [value for value in values if value is not None] - - if legend_type == MetricType.LegendType.ORDINAL: - if _is_categorical(non_null): - domain = list(category_order) if category_order else sorted({str(value) for value in non_null}) - else: - # Numeric values treated as discrete ordinal categories. - domain = sorted({value for value in non_null}) - return _ordinal_legend_config(domain) - - if not non_null: - return {"domain": [], "range": list(SEVEN_SHADES)} - - low, high = min(non_null), max(non_null) - if legend_type == MetricType.LegendType.LINEAR: - return _linear_legend_config(low, high) - - return _threshold_legend_config(low, high) - - -def _resolve_legend_type(selected: str | None, values: Iterable[Value]) -> str: - """Pick the effective legend type from the user's choice and the resolved values. - - Categorical results are always ordinal (numeric legends can't render strings). For numeric - results, honour an explicit ``linear``/``threshold``/``ordinal`` choice; ``auto`` (or anything - unrecognised) defaults to a threshold legend. - """ - if _is_categorical(values): - return MetricType.LegendType.ORDINAL - - selected = (selected or "auto").lower() - if selected == MetricType.LegendType.LINEAR: - return MetricType.LegendType.LINEAR - if selected == MetricType.LegendType.ORDINAL: - return MetricType.LegendType.ORDINAL - return MetricType.LegendType.THRESHOLD - - def _get_reference_metric_type(account, reference_id) -> MetricType | None: """The account's ``MetricType`` to copy a legend from, or ``None`` if unusable.""" try: @@ -130,7 +49,7 @@ def resolve_output_legend( """ category_order = evaluator.output_category_order all_values = list(iter_all_values(values_by_year)) - categorical = _is_categorical(all_values) + categorical = is_categorical_values(all_values) # A reference legend is honoured first (even for categorical output), so ordinal base layers # keep their exact colours instead of being rebuilt with default shades. @@ -154,7 +73,7 @@ def resolve_output_legend( if categorical: legend_type = MetricType.LegendType.ORDINAL - return legend_type, _build_legend_config(legend_type, all_values, category_order) + return legend_type, build_auto_legend_config(legend_type, all_values, category_order) - legend_type = _resolve_legend_type(selected, all_values) - return legend_type, _build_legend_config(legend_type, all_values, category_order) + legend_type = resolve_auto_legend_type(selected, all_values) + return legend_type, build_auto_legend_config(legend_type, all_values, category_order) diff --git a/services/composite/persistence.py b/services/composite/persistence.py index 4388ad8b..053afae1 100644 --- a/services/composite/persistence.py +++ b/services/composite/persistence.py @@ -100,6 +100,7 @@ def _create_metric_type( units: str = "", unit_symbol: str = "", is_population: bool = False, + is_complete: bool = False, ) -> MetricType: return MetricType.objects.create( account=account, @@ -114,6 +115,7 @@ def _create_metric_type( legend_type=legend_type, legend_config=legend_config, source="composite-layer-editor", + is_complete=is_complete, ) @@ -127,7 +129,8 @@ def create_composite_metric_type( """Create the ``MetricType`` backing a composite layer, without any ``MetricValue``. Used when the graph cannot run yet, so that the layer exists as soon as it is created; the values - and the resolved legend follow on the first successful run. + and the resolved legend follow on the first successful run. Stays hidden from data-layer lists + (``is_complete=False``) until then. """ resolved_type, resolved_config = placeholder_legend(legend_type, legend_config) return _create_metric_type(account, name, resolved_type, resolved_config, **metadata) @@ -201,6 +204,7 @@ def run_and_persist_composite_layer( units=units, unit_symbol=unit_symbol, is_population=is_population, + is_complete=True, ) _write_metric_values(metric_type, values_by_year) return metric_type @@ -238,7 +242,10 @@ def update_composite_metric_type( ) metric_type.legend_type, metric_type.legend_config = resolve_output_legend(account, evaluator, values_by_year) - metric_type.save(update_fields=["legend_type", "legend_config", "updated_at"]) + # The graph just ran successfully, producing real values, so the layer is now usable - + # relevant when this re-run is what first turns a shell into a real layer. + metric_type.is_complete = True + metric_type.save(update_fields=["legend_type", "legend_config", "is_complete", "updated_at"]) MetricValue.objects.filter(metric_type=metric_type).delete() _write_metric_values(metric_type, values_by_year) diff --git a/services/openhexa_data_layers.py b/services/openhexa_data_layers.py index c9349cf4..00bc535a 100644 --- a/services/openhexa_data_layers.py +++ b/services/openhexa_data_layers.py @@ -131,5 +131,6 @@ def import_metric_values(metric_type, csv_text: str, column: str, task=None) -> with transaction.atomic(): MetricValue.objects.filter(metric_type=metric_type).delete() MetricValue.objects.bulk_create(list(values_by_key.values()), batch_size=BULK_CREATE_BATCH_SIZE) + metric_type.mark_complete_if_has_values() return len(values_by_key) diff --git a/tasks/import_openhexa_data_layer.py b/tasks/import_openhexa_data_layer.py index b65e9299..b638363c 100644 --- a/tasks/import_openhexa_data_layer.py +++ b/tasks/import_openhexa_data_layer.py @@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _ from beanstalk_worker import task_decorator -from iaso.models import MetricType, Task +from iaso.models import KilledException, MetricType, MetricValue, Task from plugins.snt_malaria.api.openhexa_data_layers.constants import IMPORT_TASK_NAME from plugins.snt_malaria.api.openhexa_data_layers.source import resolve_source_file from plugins.snt_malaria.providers.openhexa_data_layers import ( @@ -27,10 +27,12 @@ @task_decorator(task_name=IMPORT_TASK_NAME) def import_openhexa_data_layer(metric_type_id: int, task: Task = None): - task.report_progress_and_stop_if_killed(progress_message="Starting OpenHexa data layer import") - metric_type = MetricType.objects.select_related("account").get(id=metric_type_id) account = metric_type.account + # Independent of `is_complete`, which the wizard's explicit finalise step can flip True + # before this run has written anything - checking actual values is what tells us whether + # cancelling this run would leave behind an empty shell. + is_first_import = not MetricValue.objects.filter(metric_type=metric_type).exists() logger.info( "import_openhexa_data_layer: metric type %s (code '%s'), account %s (%s)", metric_type_id, @@ -39,38 +41,57 @@ def import_openhexa_data_layer(metric_type_id: int, task: Task = None): account.name, ) - openhexa_url, openhexa_token, workspace_slug, dataset_slug = resolve_config_dataset(account) - logger.info("import_openhexa_data_layer: workspace '%s', configuration dataset '%s'", workspace_slug, dataset_slug) + try: + task.report_progress_and_stop_if_killed(progress_message="Starting OpenHexa data layer import") - task.report_progress_and_stop_if_killed(progress_message="Reading the OpenHexa configuration") - config_files = fetch_dataset_jsons( - openhexa_url, openhexa_token, workspace_slug, dataset_slug, [METADATA_FILENAME, CONFIG_FILENAME] - ) - metadata, snt_config = config_files[METADATA_FILENAME], config_files[CONFIG_FILENAME] + openhexa_url, openhexa_token, workspace_slug, dataset_slug = resolve_config_dataset(account) + logger.info( + "import_openhexa_data_layer: workspace '%s', configuration dataset '%s'", workspace_slug, dataset_slug + ) - definition = metadata.get(metric_type.code) - if not isinstance(definition, dict): - raise ValueError( - _("Data layer '{code}' is no longer defined in SNT_metadata.json").format(code=metric_type.code) + task.report_progress_and_stop_if_killed(progress_message="Reading the OpenHexa configuration") + config_files = fetch_dataset_jsons( + openhexa_url, openhexa_token, workspace_slug, dataset_slug, [METADATA_FILENAME, CONFIG_FILENAME] + ) + metadata, snt_config = config_files[METADATA_FILENAME], config_files[CONFIG_FILENAME] + + definition = metadata.get(metric_type.code) + if not isinstance(definition, dict): + raise ValueError( + _("Data layer '{code}' is no longer defined in SNT_metadata.json").format(code=metric_type.code) + ) + logger.info( + "import_openhexa_data_layer: '%s' SOURCE_DATA=%s, SNT_config COUNTRY_CODE=%s, dataset identifiers=%s", + metric_type.code, + definition.get("SOURCE_DATA"), + (snt_config.get("SNT_CONFIG") or {}).get("COUNTRY_CODE"), + sorted((snt_config.get("SNT_DATASET_IDENTIFIERS") or {}).keys()), ) - logger.info( - "import_openhexa_data_layer: '%s' SOURCE_DATA=%s, SNT_config COUNTRY_CODE=%s, dataset identifiers=%s", - metric_type.code, - definition.get("SOURCE_DATA"), - (snt_config.get("SNT_CONFIG") or {}).get("COUNTRY_CODE"), - sorted((snt_config.get("SNT_DATASET_IDENTIFIERS") or {}).keys()), - ) - source = resolve_source_file(definition, snt_config) - plan = f"column '{source.column}' from file '{source.filename}' in OpenHexa dataset '{source.dataset_slug}'" - logger.info("import_openhexa_data_layer: will load %s", plan) - task.report_progress_and_stop_if_killed(progress_message=f"Loading {plan}") + source = resolve_source_file(definition, snt_config) + plan = f"column '{source.column}' from file '{source.filename}' in OpenHexa dataset '{source.dataset_slug}'" + logger.info("import_openhexa_data_layer: will load %s", plan) + task.report_progress_and_stop_if_killed(progress_message=f"Loading {plan}") - csv_bytes = download_dataset_file( - openhexa_url, openhexa_token, workspace_slug, source.dataset_slug, source.filename - ) + csv_bytes = download_dataset_file( + openhexa_url, openhexa_token, workspace_slug, source.dataset_slug, source.filename + ) - count = import_metric_values(metric_type, csv_bytes.decode("utf-8"), source.column, task=task) + # Marks the metric type complete as soon as it has values (see + # `import_metric_values`), so the layer is usable even if the wizard tab closes + # before the user reaches the wizard's explicit POST .../complete/. + count = import_metric_values(metric_type, csv_bytes.decode("utf-8"), source.column, task=task) + except KilledException: + if is_first_import: + # Nothing usable existed for this layer before this run started, so cancelling it + # shouldn't leave behind an empty, permanently-incomplete MetricType shell. + logger.warning( + "import_openhexa_data_layer: killed before metric type %s ('%s') had any values, deleting it", + metric_type_id, + metric_type.code, + ) + metric_type.delete() + raise logger.info( "import_openhexa_data_layer: wrote %d values for metric type %s ('%s')", count, metric_type_id, metric_type.code diff --git a/tests/api/composite_layers/test_views.py b/tests/api/composite_layers/test_views.py index 33dd078f..0e8fdb86 100644 --- a/tests/api/composite_layers/test_views.py +++ b/tests/api/composite_layers/test_views.py @@ -216,6 +216,7 @@ def test_create_with_concrete_legend_stores_manual_config(self): self.assertEqual(composite_layer.metric_type.legend_config, legend_config) def test_create_with_invalid_legend_config_returns_400(self): + """An ordinal legend needs one colour per domain entry, so a shorter 'range' is rejected.""" self.client.force_authenticate(user=self.user) payload = { "name": "Bad legend", @@ -226,6 +227,32 @@ def test_create_with_invalid_legend_config_returns_400(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("legend_config", response.data) + def test_create_with_threshold_legend_stores_open_ended_range(self): + """A threshold legend's 'range' carries one extra colour for the bucket above the last + breakpoint, so domain=N/range=N+1 must be accepted rather than requiring equal lengths.""" + self.client.force_authenticate(user=self.user) + legend_config = {"domain": [10, 20], "range": ["#aaaaaa", "#bbbbbb", "#cccccc"]} + payload = {"name": "Threshold legend", "legend_type": "threshold", "legend_config": legend_config} + response = self.client.post(self.BASE_URL, payload, format="json") + result = self.assertJSONResponse(response, status.HTTP_201_CREATED) + + composite_layer = CompositeLayer.objects.get(id=result["id"]) + self.assertEqual(composite_layer.legend_type, MetricType.LegendType.THRESHOLD) + self.assertEqual(composite_layer.legend_config, legend_config) + + def test_create_with_threshold_legend_and_equal_length_range_returns_400(self): + """A threshold legend missing its open-ended top-bucket colour (equal domain/range lengths) + is a genuine mismatch, not the valid shape — it must still be rejected.""" + self.client.force_authenticate(user=self.user) + payload = { + "name": "Bad threshold legend", + "legend_type": "threshold", + "legend_config": {"domain": [10, 20], "range": ["#aaaaaa", "#bbbbbb"]}, + } + response = self.client.post(self.BASE_URL, payload, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("legend_config", response.data) + def test_create_with_incomplete_graph_is_saved_without_values(self): self.client.force_authenticate(user=self.user) graph = { @@ -368,7 +395,7 @@ def test_patch_legend_only_merges_into_graph_and_reruns(self): def test_patch_legend_auto_clears_manual_config(self): composite_layer = self._create_composite_layer() - legend_config = {"domain": [10, 20], "range": ["#aaaaaa", "#bbbbbb"]} + legend_config = {"domain": [10, 20], "range": ["#aaaaaa", "#bbbbbb", "#cccccc"]} self.client.patch( f"{self.BASE_URL}{composite_layer.id}/", {"legend_type": "threshold", "legend_config": legend_config}, diff --git a/tests/api/openhexa_data_layers/test_views.py b/tests/api/openhexa_data_layers/test_views.py index 4e903a88..3b3cd3e5 100644 --- a/tests/api/openhexa_data_layers/test_views.py +++ b/tests/api/openhexa_data_layers/test_views.py @@ -383,3 +383,80 @@ def test_import_status_returns_latest_task_per_metric_type(self): self.assertEqual(body[str(mt.id)]["status"], "SUCCESS") self.assertEqual(body[str(mt.id)]["task_id"], latest.id) self.assertEqual(body[str(mt.id)]["progress_message"], "12 rows read") + + def test_cancel_import_marks_the_running_task_for_kill(self): + from iaso.models import Task + from plugins.snt_malaria.api.openhexa_data_layers.constants import IMPORT_TASK_NAME + + mt = MetricType.objects.create( + account=self.account, + code="INCIDENCE_CRUDE", + name="Crude incidence", + legend_type="threshold", + origin=MetricType.MetricTypeOrigin.OPENHEXA, + ) + running = Task.objects.create( + name=IMPORT_TASK_NAME, + account=self.account, + launcher=self.user, + status="RUNNING", + params={"kwargs": {"metric_type_id": mt.id}}, + ) + + self.client.force_authenticate(self.user) + response = self.client.post(f"{self.BASE_URL}cancel_import/", data={"metric_type_id": mt.id}, format="json") + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + running.refresh_from_db() + self.assertTrue(running.should_be_killed) + + def test_cancel_import_does_not_touch_an_already_finished_task(self): + from iaso.models import Task + from plugins.snt_malaria.api.openhexa_data_layers.constants import IMPORT_TASK_NAME + + mt = MetricType.objects.create( + account=self.account, + code="INCIDENCE_CRUDE", + name="Crude incidence", + legend_type="threshold", + origin=MetricType.MetricTypeOrigin.OPENHEXA, + ) + done = Task.objects.create( + name=IMPORT_TASK_NAME, + account=self.account, + launcher=self.user, + status="SUCCESS", + params={"kwargs": {"metric_type_id": mt.id}}, + ) + + self.client.force_authenticate(self.user) + response = self.client.post(f"{self.BASE_URL}cancel_import/", data={"metric_type_id": mt.id}, format="json") + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + done.refresh_from_db() + self.assertFalse(done.should_be_killed) + + def test_cancel_import_does_not_leak_across_accounts(self): + from iaso.models import Task + from plugins.snt_malaria.api.openhexa_data_layers.constants import IMPORT_TASK_NAME + + other_account = self.create_account_datasource_version_project("s3", "Other2", "p3")[0] + other_task = Task.objects.create( + name=IMPORT_TASK_NAME, + account=other_account, + launcher=self.user, + status="RUNNING", + params={"kwargs": {"metric_type_id": 4242}}, + ) + + self.client.force_authenticate(self.user) + response = self.client.post(f"{self.BASE_URL}cancel_import/", data={"metric_type_id": 4242}, format="json") + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + other_task.refresh_from_db() + self.assertFalse(other_task.should_be_killed) + + def test_reader_cannot_cancel_import(self): + self.client.force_authenticate(self.reader) + response = self.client.post(f"{self.BASE_URL}cancel_import/", data={"metric_type_id": 1}, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/tests/tasks/test_import_openhexa_data_layer.py b/tests/tasks/test_import_openhexa_data_layer.py index 3c4d4401..618e344a 100644 --- a/tests/tasks/test_import_openhexa_data_layer.py +++ b/tests/tasks/test_import_openhexa_data_layer.py @@ -1,7 +1,7 @@ from unittest import mock from iaso.models import MetricType, MetricValue, OrgUnit, Task -from iaso.models.base import ERRORED, SUCCESS +from iaso.models.base import ERRORED, KILLED, SUCCESS from plugins.snt_malaria.providers.openhexa_data_layers import CONFIG_FILENAME, METADATA_FILENAME from plugins.snt_malaria.tasks.import_openhexa_data_layer import import_openhexa_data_layer from plugins.snt_malaria.tests.common_base import SNTMalariaTestCase @@ -63,6 +63,7 @@ def setUp(self): name="Crude incidence", legend_type="threshold", origin=MetricType.MetricTypeOrigin.OPENHEXA, + is_complete=False, ) self.task = Task.objects.create(name="import_openhexa_data_layer", launcher=self.user, account=self.account) @@ -89,8 +90,58 @@ def test_downloads_the_resolved_source_file_and_writes_values(self): self.task.refresh_from_db() self.assertEqual(self.task.status, SUCCESS) + def test_successful_import_marks_an_incomplete_metric_type_as_complete(self): + """A first import flips `is_complete` itself, instead of relying on the wizard's + later PATCH to the metrics API - so a shell isn't stuck incomplete forever if the + tab closes before that PATCH happens.""" + self._run() + + self.metric_type.refresh_from_db() + self.assertTrue(self.metric_type.is_complete) + def test_fails_when_layer_no_longer_defined(self): self._run(metadata={}) self.task.refresh_from_db() self.assertEqual(self.task.status, ERRORED) + + def test_cancelling_first_import_deletes_the_metric_type_shell(self): + self.task.should_be_killed = True + self.task.save() + + self._run() + + self.task.refresh_from_db() + self.assertEqual(self.task.status, KILLED) + self.assertFalse(MetricType.objects.filter(id=self.metric_type.id).exists()) + self.assertFalse(MetricValue.objects.filter(metric_type_id=self.metric_type.id).exists()) + + def test_cancelling_still_deletes_an_explicitly_completed_but_empty_shell(self): + """`is_complete=True` alone doesn't mean this run has written anything - e.g. the + wizard's explicit finalise step can flip it before the import ever runs - so the + empty-shell cleanup on kill must key off actual values, not that flag.""" + self.metric_type.is_complete = True + self.metric_type.save(update_fields=["is_complete"]) + self.task.should_be_killed = True + self.task.save() + + self._run() + + self.task.refresh_from_db() + self.assertEqual(self.task.status, KILLED) + self.assertFalse(MetricType.objects.filter(id=self.metric_type.id).exists()) + + def test_cancelling_refresh_keeps_the_existing_metric_type_and_values(self): + self.metric_type.is_complete = True + self.metric_type.save(update_fields=["is_complete"]) + org_unit = OrgUnit.objects.get(source_ref="OU1") + MetricValue.objects.create(metric_type=self.metric_type, org_unit=org_unit, value=1) + self.task.should_be_killed = True + self.task.save() + + self._run() + + self.task.refresh_from_db() + self.assertEqual(self.task.status, KILLED) + self.assertTrue(MetricType.objects.filter(id=self.metric_type.id).exists()) + self.assertEqual(MetricValue.objects.filter(metric_type=self.metric_type).count(), 1)