From 45bd3c25371cd7fedc5a984a26bfa50708659623 Mon Sep 17 00:00:00 2001 From: Joseph John Aas Cooper <33054985+cooper-joe@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:38:20 +0200 Subject: [PATCH] feat: layer catalog prototype --- .../2026-08-27-add-layer-source-modal.md | 1046 ++++++++++++++ .../2026-08-28-basemaps-in-layer-catalog.md | 1215 +++++++++++++++++ ...026-08-27-add-layer-source-modal-design.md | 164 +++ ...-08-28-basemaps-in-layer-catalog-design.md | 272 ++++ i18n/en.pot | 170 ++- .../layerSources/AddLayerSourceForm.jsx | 297 ++++ src/components/layerSources/LayerSource.jsx | 78 +- .../layerSources/ManageLayerSourcesButton.jsx | 19 +- .../layerSources/ManageLayerSourcesModal.jsx | 478 ++++++- .../styles/AddLayerSourceForm.module.css | 22 + .../styles/LayerSource.module.css | 105 +- .../ManageLayerSourcesButton.module.css | 3 +- .../styles/ManageLayerSourcesModal.module.css | 87 ++ .../layers/basemaps/BasemapList.jsx | 15 +- .../layers/overlays/AddLayerPopover.jsx | 149 +- src/components/layers/overlays/Layer.jsx | 27 +- src/components/layers/overlays/LayerList.jsx | 150 +- .../layers/overlays/LayerSourceDetails.jsx | 142 ++ .../layers/overlays/LayerSourceList.jsx | 59 + .../layers/overlays/LayerSourceRow.jsx | 89 ++ src/components/layers/overlays/PinIcon.jsx | 32 + .../layers/overlays/SortByButton.jsx | 44 + .../styles/AddLayerPopover.module.css | 50 + .../layers/overlays/styles/Layer.module.css | 89 +- .../overlays/styles/LayerList.module.css | 20 +- .../styles/LayerSourceDetails.module.css | 135 ++ .../styles/LayerSourceList.module.css | 17 + .../overlays/styles/LayerSourceRow.module.css | 108 ++ src/constants/mockLayerSources.js | 177 +++ src/hooks/prototypeStore.js | 36 + src/hooks/useAddedLayerSources.js | 26 + src/hooks/useBasemapConfig.js | 6 +- src/hooks/useCatalogBasemaps.js | 32 + src/hooks/useLayerCatalogPrefs.js | 65 + src/util/getDefaultLayerTypes.js | 11 + src/util/layerSources.js | 219 +++ 36 files changed, 5498 insertions(+), 156 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-27-add-layer-source-modal.md create mode 100644 docs/superpowers/plans/2026-08-28-basemaps-in-layer-catalog.md create mode 100644 docs/superpowers/specs/2026-08-27-add-layer-source-modal-design.md create mode 100644 docs/superpowers/specs/2026-08-28-basemaps-in-layer-catalog-design.md create mode 100644 src/components/layerSources/AddLayerSourceForm.jsx create mode 100644 src/components/layerSources/styles/AddLayerSourceForm.module.css create mode 100644 src/components/layers/overlays/LayerSourceDetails.jsx create mode 100644 src/components/layers/overlays/LayerSourceList.jsx create mode 100644 src/components/layers/overlays/LayerSourceRow.jsx create mode 100644 src/components/layers/overlays/PinIcon.jsx create mode 100644 src/components/layers/overlays/SortByButton.jsx create mode 100644 src/components/layers/overlays/styles/AddLayerPopover.module.css create mode 100644 src/components/layers/overlays/styles/LayerSourceDetails.module.css create mode 100644 src/components/layers/overlays/styles/LayerSourceList.module.css create mode 100644 src/components/layers/overlays/styles/LayerSourceRow.module.css create mode 100644 src/constants/mockLayerSources.js create mode 100644 src/hooks/prototypeStore.js create mode 100644 src/hooks/useAddedLayerSources.js create mode 100644 src/hooks/useCatalogBasemaps.js create mode 100644 src/hooks/useLayerCatalogPrefs.js diff --git a/docs/superpowers/plans/2026-08-27-add-layer-source-modal.md b/docs/superpowers/plans/2026-08-27-add-layer-source-modal.md new file mode 100644 index 0000000000..2d68f624c2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-add-layer-source-modal.md @@ -0,0 +1,1046 @@ +# Add Layer Source Modal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the disabled "Add source" button in the layer source catalog modal with an in-modal form that mocks up registering a new external layer source. + +**Architecture:** `ManageLayerSourcesModal` gains a `view` state and swaps its own content between the existing list and a new `AddLayerSourceForm`. The form owns presentation and validation; the modal owns the form data and the list of added sources. On submit the form model is passed through the app's real `createExternalOverlayLayer()` transform and appended to session state - no API writes. + +**Tech Stack:** React 18, `@dhis2/ui` (Modal, InputField, SingleSelectField, Button), `@dhis2/app-runtime` (`useDataQuery` for legend sets), CSS modules, `@dhis2/d2-i18n`. + +**Spec:** `docs/superpowers/specs/2026-08-27-add-layer-source-modal-design.md` + +**No automated tests.** This is a throwaway prototype on the `layer-catalog-prototype` branch; the surrounding prototype components (`mockLayerSources.js`, `useLayerCatalogPrefs.js`, `LayerSourceRow.jsx`) have none either. Verification is lint + a scripted manual pass (Task 4). Do not add Jest or Cypress specs for this. + +--- + +## File Structure + +**Create:** + +- `src/components/layerSources/AddLayerSourceForm.jsx` - the form view. Renders the fields, owns per-field "touched" state, and exports three pure helpers (`EMPTY_FORM`, `getFormErrors`, `getExternalLayerModel`) so the modal can compute validity and build the API-shaped model without duplicating field knowledge. +- `src/components/layerSources/styles/AddLayerSourceForm.module.css` - two-column form grid. + +**Modify:** + +- `src/components/layerSources/ManageLayerSourcesModal.jsx` - view switching, form data, added sources, the info notice, and enabling the `Add source` button. +- `src/components/layerSources/styles/ManageLayerSourcesModal.module.css` - `.back` and `.notice`. +- `src/components/layerSources/LayerSource.jsx` - optional `isNew` pill. +- `src/components/layerSources/styles/LayerSource.module.css` - `.newPill`. + +**Do not touch:** `src/util/external.js`, `src/util/app.js`, `src/util/requests.js`, `src/util/layerSources.js`. The form consumes `supportedMapServices` and `createExternalOverlayLayer` as they already are - that is what keeps this honest about backend support. + +--- + +## Task 1: The form component + +**Files:** + +- Create: `src/components/layerSources/AddLayerSourceForm.jsx` +- Create: `src/components/layerSources/styles/AddLayerSourceForm.module.css` + +Background you need: `supportedMapServices` in `src/util/external.js` is `['WMS', 'TMS', 'XYZ', 'VECTOR_STYLE', 'GEOJSON_URL']` - the map services the app can actually render. Deriving the dropdown from it means the form cannot offer something unsupported. `MAP_LAYER_POSITION_BASEMAP` / `MAP_LAYER_POSITION_OVERLAY` are `'BASEMAP'` / `'OVERLAY'` in `src/constants/layers.js`. + +The local `core/TextField` wrapper does not expose `error` / `validationText` / `required`, so use `@dhis2/ui` `InputField` directly. + +- [ ] **Step 1: Create the form component** + +Write `src/components/layerSources/AddLayerSourceForm.jsx`: + +```jsx +import { useDataQuery } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { InputField, SingleSelectField, SingleSelectOption } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useState } from 'react' +import { + MAP_LAYER_POSITION_BASEMAP, + MAP_LAYER_POSITION_OVERLAY, +} from '../../constants/layers.js' +import { supportedMapServices } from '../../util/external.js' +import styles from './styles/AddLayerSourceForm.module.css' + +// PROTOTYPE ONLY - mocks the "Add source" flow of the layer source catalog. +// The field set mirrors the External map layer form in the Maintenance app and +// every field maps 1:1 to an externalMapLayers property, but nothing is ever +// written to the API - see ManageLayerSourcesModal.onAddSource(). + +const LEGEND_SETS_QUERY = { + legendSets: { + resource: 'legendSets', + params: { + fields: ['id', 'displayName~rename(name)'], + paging: false, + }, + }, +} + +const WMS = 'WMS' +const RASTER_SERVICES = ['WMS', 'XYZ', 'TMS'] + +// Labels for the services the app supports - the option list itself is derived +// from supportedMapServices so it can never offer an unsupported one +const MAP_SERVICE_LABELS = { + WMS: i18n.t('WMS'), + XYZ: i18n.t('XYZ tiles'), + TMS: i18n.t('TMS tiles'), + VECTOR_STYLE: i18n.t('Vector style'), + GEOJSON_URL: i18n.t('GeoJSON URL'), +} + +const MAP_SERVICE_OPTIONS = supportedMapServices.map((value) => ({ + value, + label: MAP_SERVICE_LABELS[value] || value, +})) + +const IMAGE_FORMAT_OPTIONS = [ + { value: 'PNG', label: i18n.t('PNG') }, + { value: 'JPG', label: i18n.t('JPG') }, +] + +const POSITION_OPTIONS = [ + { value: MAP_LAYER_POSITION_OVERLAY, label: i18n.t('Overlay') }, + { value: MAP_LAYER_POSITION_BASEMAP, label: i18n.t('Basemap') }, +] + +const URL_PLACEHOLDER = { + WMS: 'https://example.org/geoserver/wms', + XYZ: 'https://example.org/tiles/{z}/{x}/{y}.png', + TMS: 'https://example.org/tiles/{z}/{x}/{y}.png', + VECTOR_STYLE: 'https://example.org/styles/style.json', + GEOJSON_URL: 'https://example.org/data/districts.geojson', +} + +const URL_HELP = { + WMS: i18n.t('Base URL of the WMS service, without query parameters'), + XYZ: i18n.t('Tile URL template with {z}/{x}/{y} placeholders'), + TMS: i18n.t('Tile URL template, using TMS tile ordering'), + VECTOR_STYLE: i18n.t('URL of a vector style JSON document'), + GEOJSON_URL: i18n.t('URL of a GeoJSON FeatureCollection'), +} + +export const EMPTY_FORM = { + name: '', + code: '', + mapService: 'XYZ', + url: '', + layers: '', + imageFormat: 'PNG', + mapLayerPosition: MAP_LAYER_POSITION_OVERLAY, + attribution: '', + legendSet: null, + legendSetUrl: '', +} + +const isValidUrl = (value) => { + try { + return Boolean(new URL(value)) + } catch { + return false + } +} + +// Keyed by field name so the form can show each message on its own field +export const getFormErrors = (form) => { + const errors = {} + + if (!form.name.trim()) { + errors.name = i18n.t('A name is required') + } + + if (!form.url.trim()) { + errors.url = i18n.t('A URL is required') + } else if (!isValidUrl(form.url.trim())) { + errors.url = i18n.t('Enter a full URL, including https://') + } + + if (form.mapService === WMS && !form.layers.trim()) { + errors.layers = i18n.t('WMS services need at least one layer name') + } + + return errors +} + +// An API-shaped externalMapLayer, ready for createExternalOverlayLayer(). +// Fields the app ignores for the chosen service are left out entirely. +export const getExternalLayerModel = (form, id) => ({ + id, + name: form.name.trim(), + code: form.code.trim() || undefined, + mapService: form.mapService, + url: form.url.trim(), + layers: form.mapService === WMS ? form.layers.trim() : undefined, + imageFormat: RASTER_SERVICES.includes(form.mapService) + ? form.imageFormat + : undefined, + mapLayerPosition: form.mapLayerPosition, + attribution: form.attribution.trim() || undefined, + legendSet: form.legendSet || undefined, + legendSetUrl: form.legendSetUrl.trim() || undefined, +}) + +const AddLayerSourceForm = ({ form, errors, onChange }) => { + // Errors only show once a field has been visited, so an untouched form + // isn't covered in red the moment it opens + const [touched, setTouched] = useState({}) + const { loading, data } = useDataQuery(LEGEND_SETS_QUERY) + + const legendSets = data?.legendSets?.legendSets ?? [] + const isWms = form.mapService === WMS + const isRaster = RASTER_SERVICES.includes(form.mapService) + + const textField = (field) => ({ + dense: true, + value: form[field], + error: Boolean(touched[field] && errors[field]), + validationText: touched[field] ? errors[field] : undefined, + onBlur: () => setTouched((prev) => ({ ...prev, [field]: true })), + onChange: ({ value }) => onChange(field, value), + }) + + return ( +
{description}
-{descriptionComplement}
-