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}
-