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 ( +
+
{i18n.t('Service')}
+ + onChange('mapService', selected)} + dataTest="addlayersource-mapservice" + > + {MAP_SERVICE_OPTIONS.map(({ value, label }) => ( + + ))} + + + + {isWms && ( + + )} + {isRaster && ( + + onChange('imageFormat', selected) + } + dataTest="addlayersource-imageformat" + > + {IMAGE_FORMAT_OPTIONS.map(({ value, label }) => ( + + ))} + + )} + + onChange('mapLayerPosition', selected) + } + dataTest="addlayersource-position" + > + {POSITION_OPTIONS.map(({ value, label }) => ( + + ))} + +
+ {i18n.t('Attribution and legend')} +
+ + + onChange( + 'legendSet', + legendSets.find((ls) => ls.id === selected) ?? null + ) + } + dataTest="addlayersource-legendset" + > + {legendSets.map(({ id, name }) => ( + + ))} + + +
+ ) +} + +AddLayerSourceForm.propTypes = { + errors: PropTypes.object.isRequired, + form: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, +} + +export default AddLayerSourceForm +``` + +- [ ] **Step 2: Create the form styles** + +Write `src/components/layerSources/styles/AddLayerSourceForm.module.css`: + +```css +.form { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: start; + gap: var(--spacers-dp8) var(--spacers-dp16); + max-width: 760px; + padding-block-end: var(--spacers-dp16); +} + +.full { + grid-column: 1 / -1; +} + +.sectionTitle { + grid-column: 1 / -1; + margin-block-start: var(--spacers-dp8); + padding-block-end: var(--spacers-dp4); + border-bottom: 1px solid var(--colors-grey300); + font-size: 14px; + font-weight: 500; + color: var(--colors-grey700); +} +``` + +- [ ] **Step 3: Lint the new files** + +Run: + +```bash +npx prettier --write src/components/layerSources/AddLayerSourceForm.jsx src/components/layerSources/styles/AddLayerSourceForm.module.css && npx eslint src/components/layerSources --ext .js,.jsx +``` + +Expected: prettier lists the two files, eslint exits 0 with no output. If eslint reports `react/jsx-sort-props` or `import/order`, fix by reordering as instructed - do not add eslint-disable comments. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/layerSources/AddLayerSourceForm.jsx src/components/layerSources/styles/AddLayerSourceForm.module.css +git commit -m "feat: add layer source form (prototype)" +``` + +--- + +## Task 2: Wire the form into the modal + +**Files:** + +- Modify: `src/components/layerSources/ManageLayerSourcesModal.jsx` (full replacement below) +- Modify: `src/components/layerSources/styles/ManageLayerSourcesModal.module.css` (append two rules) + +This task does the view switching **and** the save behaviour together - they share the same state block and splitting them would leave the modal in a half-wired state that cannot be checked by hand. + +What changes, and why: + +- `view` state swaps `ModalTitle`, content and `ModalActions`. The list JSX moves into a `listContent` const so the `return` stays readable. +- `Tooltip` import is dropped (the "Coming soon" wrapper is gone); `IconArrowLeft16` and `IconInfo16` are added. +- `useKeyDown('Escape', ...)` now calls `onEscape`, which backs out of the add view instead of closing the whole modal. +- `addedSources` are appended to `allSources`, so they flow through the existing grouping, filtering and counting untouched. They are enabled automatically: built-in and external sources use the deny-list in `useLayerCatalogPrefs`, and a source that was never disabled is enabled. +- A `BASEMAP` position adds nothing to the list and explains why, matching the real filter in `getDefaultLayerSources` (`src/util/app.js:37`). + +- [ ] **Step 1: Replace `ManageLayerSourcesModal.jsx`** + +Write the file in full: + +```jsx +import i18n from '@dhis2/d2-i18n' +import { + Modal, + ModalTitle, + ModalContent, + ModalActions, + Button, + ButtonStrip, + Input, + SingleSelect, + SingleSelectOption, + IconAdd16, + IconArrowLeft16, + IconInfo16, + IconSearch16, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useCallback, useState } from 'react' +import getEarthEngineLayers from '../../constants/earthEngineLayers/index.js' +import { MAP_LAYER_POSITION_BASEMAP } from '../../constants/layers.js' +import { mockLayerSources } from '../../constants/mockLayerSources.js' +import useKeyDown from '../../hooks/useKeyDown.js' +import useLayerCatalogPrefs from '../../hooks/useLayerCatalogPrefs.js' +import useManagedLayerSourcesStore from '../../hooks/useManagedLayerSourcesStore.js' +import { createExternalOverlayLayer } from '../../util/external.js' +import { + getLayerSourceKind, + getLayerSourceKindLabel, + getManagedLayerSourceId, + matchesLayerSourceFilter, + KIND_BUILT_IN, + KIND_EARTH_ENGINE, + KIND_EXTERNAL, +} from '../../util/layerSources.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import AddLayerSourceForm, { + EMPTY_FORM, + getExternalLayerModel, + getFormErrors, +} from './AddLayerSourceForm.jsx' +import LayerSource from './LayerSource.jsx' +import styles from './styles/ManageLayerSourcesModal.module.css' + +const byName = (a, b) => + (a.name || a.type || '').localeCompare(b.name || b.type || '') + +const nonLegacyEarthEngineLayers = getEarthEngineLayers() + .filter((l) => !l.legacy) + .sort(byName) + +const ALL = 'all' +const STATUS_ENABLED = 'enabled' +const STATUS_DISABLED = 'disabled' + +const STATUS_OPTIONS = [ + { value: ALL, label: i18n.t('All') }, + { value: STATUS_ENABLED, label: i18n.t('Enabled') }, + { value: STATUS_DISABLED, label: i18n.t('Disabled') }, +] + +const KIND_OPTIONS = [ + { value: ALL, label: i18n.t('All') }, + { value: KIND_BUILT_IN, label: getLayerSourceKindLabel(KIND_BUILT_IN) }, + { + value: KIND_EARTH_ENGINE, + label: getLayerSourceKindLabel(KIND_EARTH_ENGINE), + }, + { value: KIND_EXTERNAL, label: getLayerSourceKindLabel(KIND_EXTERNAL) }, +] + +const VIEW_LIST = 'list' +const VIEW_ADD = 'add' + +const ManageLayerSourcesModal = ({ onClose }) => { + const { managedLayerSources, showLayerSource, hideLayerSource } = + useManagedLayerSourcesStore() + const { isDisabled, toggleDisabled } = useLayerCatalogPrefs() + const { defaultLayerSources } = useCachedData() + const [filter, setFilter] = useState('') + const [statusFilter, setStatusFilter] = useState(ALL) + const [kindFilter, setKindFilter] = useState(ALL) + const [view, setView] = useState(VIEW_LIST) + // PROTOTYPE ONLY - sources added through the form live in component state + // for the session. The real thing would POST to externalMapLayers. + const [form, setForm] = useState(EMPTY_FORM) + const [addedSources, setAddedSources] = useState([]) + const [notice, setNotice] = useState(null) + + const isAddView = view === VIEW_ADD + + const closeAddView = useCallback(() => { + setView(VIEW_LIST) + setForm(EMPTY_FORM) + }, []) + + // Escape backs out of the form first, and only then closes the modal + const onEscape = useCallback(() => { + if (view === VIEW_ADD) { + closeAddView() + } else { + onClose() + } + }, [view, closeAddView, onClose]) + + useKeyDown('Escape', onEscape) + + // PROTOTYPE ONLY - mock sources are appended so the dialog is worth scrolling + const allSources = [ + ...defaultLayerSources, + ...mockLayerSources(), + ...addedSources, + ] + + const groups = [ + { + kind: KIND_BUILT_IN, + sources: allSources.filter( + (l) => getLayerSourceKind(l) === KIND_BUILT_IN + ), + }, + { kind: KIND_EARTH_ENGINE, sources: nonLegacyEarthEngineLayers }, + { + kind: KIND_EXTERNAL, + sources: allSources + .filter((l) => getLayerSourceKind(l) === KIND_EXTERNAL) + .sort(byName), + }, + ] + + // Earth Engine visibility is an allow-list held in the dataStore, while + // built-in and external sources use the prototype deny-list + const isEnabled = (kind, id) => + kind === KIND_EARTH_ENGINE + ? managedLayerSources.includes(id) + : !isDisabled(id) + + const onToggle = (kind, id, enabled) => { + if (kind !== KIND_EARTH_ENGINE) { + toggleDisabled(id) + } else if (enabled) { + hideLayerSource(id) + } else { + showLayerSource(id) + } + } + + const matchesStatus = (groupKind, source) => { + if (statusFilter === ALL) { + return true + } + const enabled = isEnabled(groupKind, getManagedLayerSourceId(source)) + return statusFilter === STATUS_ENABLED ? enabled : !enabled + } + + const filteredGroups = groups + .filter((group) => kindFilter === ALL || group.kind === kindFilter) + .map((group) => ({ + ...group, + sources: group.sources.filter( + (l) => + matchesLayerSourceFilter(l, filter) && + matchesStatus(group.kind, l) + ), + })) + .filter((group) => group.sources.length) + + const enabledCount = groups.reduce( + (count, { kind, sources }) => + count + + sources.filter((l) => isEnabled(kind, getManagedLayerSourceId(l))) + .length, + 0 + ) + const totalCount = groups.reduce((n, g) => n + g.sources.length, 0) + const visibleCount = filteredGroups.reduce( + (n, g) => n + g.sources.length, + 0 + ) + + const formErrors = getFormErrors(form) + const canAddSource = Object.keys(formErrors).length === 0 + + const onFormChange = (field, value) => + setForm((prev) => ({ ...prev, [field]: value })) + + const openAddView = () => { + setNotice(null) + setView(VIEW_ADD) + } + + const onAddSource = () => { + const model = getExternalLayerModel( + form, + `prototype-${addedSources.length + 1}` + ) + + if (model.mapLayerPosition === MAP_LAYER_POSITION_BASEMAP) { + // Matches getDefaultLayerSources(), which drops basemap entries + setNotice( + i18n.t( + '"{{name}}" was added as a basemap, so it is not listed here. Basemaps are chosen on the map itself.', + { name: model.name } + ) + ) + } else { + setAddedSources((prev) => [ + ...prev, + { ...createExternalOverlayLayer(model), isNew: true }, + ]) + setNotice( + i18n.t('"{{name}}" was added and is enabled for all users.', { + name: model.name, + }) + ) + } + + // Clear the filters so the new row is definitely visible + setFilter('') + setStatusFilter(ALL) + setKindFilter(ALL) + closeAddView() + } + + const listContent = ( + <> +
+ {i18n.t( + 'Choose which layer sources are available to add to maps. This selection applies to all users.' + )} +
+ {notice && ( +
+ + {notice} +
+ )} +
+ + {i18n.t('{{count}} layer sources', { + count: visibleCount, + })} + + +
+
+
+ } + value={filter} + clearable + placeholder={i18n.t('Filter layer sources')} + onChange={({ value }) => setFilter(value)} + dataTest="managelayersources-filter" + /> +
+
+ setStatusFilter(selected)} + dataTest="managelayersources-status" + > + {STATUS_OPTIONS.map(({ value, label }) => ( + + ))} + +
+
+ setKindFilter(selected)} + dataTest="managelayersources-kind" + > + {KIND_OPTIONS.map(({ value, label }) => ( + + ))} + +
+
+ {filteredGroups.length === 0 && ( +
+ {i18n.t('No layer sources match these filters.')} +
+ )} + {filteredGroups.map(({ kind, sources }) => ( +
+
+ {getLayerSourceKindLabel(kind)} +
+ {sources.map((layerSource) => { + const id = getManagedLayerSourceId(layerSource) + const enabled = isEnabled(kind, id) + return ( + onToggle(kind, id, enabled)} + /> + ) + })} +
+ ))} + + ) + + const addContent = ( + <> +
+ +
+
+ {i18n.t( + 'Register an external map service. Once added it is available to all users, and can be disabled again from the list.' + )} +
+ + + ) + + return ( + + + {isAddView + ? i18n.t('Add layer source') + : i18n.t('Configure available layer sources')} + + + {isAddView ? addContent : listContent} + + + {isAddView ? ( + + + + + ) : ( +
+ + {i18n.t('{{count}} of {{total}} sources enabled', { + count: enabledCount, + total: totalCount, + })} + + + + +
+ )} +
+
+ ) +} + +ManageLayerSourcesModal.propTypes = { + onClose: PropTypes.func.isRequired, +} + +export default ManageLayerSourcesModal +``` + +- [ ] **Step 2: Append the two new rules to the modal stylesheet** + +Append to `src/components/layerSources/styles/ManageLayerSourcesModal.module.css`: + +```css +.back { + margin-block-end: var(--spacers-dp12); +} + +.notice { + display: flex; + align-items: center; + gap: var(--spacers-dp8); + margin-block-end: var(--spacers-dp12); + padding: var(--spacers-dp8) var(--spacers-dp12); + border-radius: 4px; + background-color: var(--colors-teal050); + color: var(--colors-grey800); + font-size: 13px; +} +``` + +- [ ] **Step 3: Lint** + +Run: + +```bash +npx prettier --write src/components/layerSources && npx eslint src/components/layerSources --ext .js,.jsx +``` + +Expected: eslint exits 0. `Tooltip` must no longer be imported - if eslint reports it unused, the import block was not replaced correctly. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/layerSources/ManageLayerSourcesModal.jsx src/components/layerSources/styles/ManageLayerSourcesModal.module.css +git commit -m "feat: add source view in layer sources modal (prototype)" +``` + +--- + +## Task 3: "Added in this session" pill + +**Files:** + +- Modify: `src/components/layerSources/LayerSource.jsx` +- Modify: `src/components/layerSources/styles/LayerSource.module.css` + +`LayerSource.jsx` currently has no i18n import - add one. `propTypes` in this repo list required props alphabetically first, then optional ones, so `isNew` goes after `onToggle`. + +- [ ] **Step 1: Add the `isNew` prop** + +In `src/components/layerSources/LayerSource.jsx`, add the i18n import above the `prop-types` import: + +```jsx +import i18n from '@dhis2/d2-i18n' +``` + +Change the signature from: + +```jsx +const LayerSource = ({ layerSource, isAdded, onToggle }) => { +``` + +to: + +```jsx +const LayerSource = ({ layerSource, isAdded, onToggle, isNew }) => { +``` + +Change the name element from: + +```jsx +
{label}
+``` + +to: + +```jsx +
+ {label} + {isNew && ( + + {i18n.t('Added in this session')} + + )} +
+``` + +And extend `propTypes` from: + +```jsx +LayerSource.propTypes = { + isAdded: PropTypes.bool.isRequired, + layerSource: PropTypes.object.isRequired, + onToggle: PropTypes.func.isRequired, +} +``` + +to: + +```jsx +LayerSource.propTypes = { + isAdded: PropTypes.bool.isRequired, + layerSource: PropTypes.object.isRequired, + onToggle: PropTypes.func.isRequired, + isNew: PropTypes.bool, +} +``` + +- [ ] **Step 2: Add the pill style** + +Append to `src/components/layerSources/styles/LayerSource.module.css`: + +```css +.newPill { + display: inline-block; + margin-inline-start: var(--spacers-dp8); + padding: 1px 6px; + border-radius: 8px; + background-color: var(--colors-teal050); + color: var(--colors-teal700); + font-size: 11px; + font-weight: 400; + vertical-align: middle; +} +``` + +- [ ] **Step 3: Lint** + +Run: + +```bash +npx prettier --write src/components/layerSources && npx eslint src/components/layerSources --ext .js,.jsx +``` + +Expected: exits 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/layerSources/LayerSource.jsx src/components/layerSources/styles/LayerSource.module.css +git commit -m "feat: mark newly added layer sources in the list (prototype)" +``` + +--- + +## Task 4: Manual verification pass + +**Files:** none + +The app needs a DHIS2 backend. Start it with the same instance you have been using for the prototype: + +```bash +yarn start +``` + +- [ ] **Step 1: Walk the flow** + +Open http://localhost:3000, click `Add layer` in the left panel, and click `Manage layer sources`. Work through this list and note anything that does not match: + +- [ ] `Add source` in the list header is enabled (no "Coming soon" tooltip) and opens the form. +- [ ] The modal title reads "Add layer source"; the footer shows `Cancel` and a disabled `Add source`. +- [ ] `Back to all sources` returns to the list. So does `Cancel`. So does Escape (and Escape does **not** close the whole modal from the form). +- [ ] Tabbing through Name and URL without typing shows "A name is required" / "A URL is required"; typing `not-a-url` in URL shows "Enter a full URL, including https://". +- [ ] Choosing `WMS` reveals `Layers` (required) and `Image format`. Choosing `XYZ` hides `Layers` but keeps `Image format`. Choosing `Vector style` or `GeoJSON URL` hides both. +- [ ] The URL placeholder and help text change with the map service. +- [ ] The `Legend set` dropdown lists the instance's legend sets and can be cleared. +- [ ] Add an XYZ source (Name `Test XYZ`, URL `https://example.org/tiles/{z}/{x}/{y}.png`): the modal returns to the list, shows the info notice, and the row appears under **External data**, checked, with the "Added in this session" pill and `Service: XYZ tiles` / `Host: example.org` meta. +- [ ] Add a WMS source with `Layers` filled: same, with `Service: WMS`. +- [ ] Unchecking then rechecking a newly added row works like any other row, and the footer's "N of M sources enabled" count includes it. +- [ ] Set `Layer position` to `Basemap` and add: no new row, and the notice explains that basemaps are not listed here. +- [ ] The search box and the Status/Type filters still work with added sources present. + +- [ ] **Step 2: Report** + +Report which checks passed and which did not. Do not fix unrelated pre-existing issues; note them instead. + +--- + +## Task 5: Final lint and i18n + +**Files:** possibly `i18n/en.pot` + +- [ ] **Step 1: Full lint** + +Run: + +```bash +yarn lint +``` + +Expected: exits 0. If it reports problems in files this plan did not touch, leave them alone and say so. + +- [ ] **Step 2: Regenerate translation strings (optional)** + +The prototype branch has been committing `i18n/en.pot` updates. If you want to keep that consistent: + +```bash +yarn build +``` + +Then commit the pot change only if it is non-empty: + +```bash +git add i18n/en.pot && git commit -m "chore: update i18n strings" +``` + +If `yarn build` fails for reasons unrelated to this change, skip this step - the pot file is not needed to demo the prototype. + +--- + +## Notes for the implementer + +- Everything here is throwaway. Keep the `PROTOTYPE ONLY` comments; they are how the branch tracks what must be deleted before any of this becomes real. +- Do not add a `POST` to `externalMapLayers`, a URL reachability check, a WMS `GetCapabilities` probe, or a layer picker. All were considered and cut deliberately: nothing in the API supports them today, and the point of this mock is to show only what the backend already offers. diff --git a/docs/superpowers/plans/2026-08-28-basemaps-in-layer-catalog.md b/docs/superpowers/plans/2026-08-28-basemaps-in-layer-catalog.md new file mode 100644 index 0000000000..484f10bd15 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-basemaps-in-layer-catalog.md @@ -0,0 +1,1215 @@ +# Basemaps in the Layer Catalog 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:** List basemaps alongside overlays in the admin "Configure available layer sources" modal, filterable by a new Placement filter, with the admin's enable/disable decision visibly driving the map's Basemap card. + +**Architecture:** Placement (`overlay` / `basemap`) is a new attribute orthogonal to the existing `kind` (provenance). The modal tags entries from `useCachedData().basemaps` as basemaps and merges them into the existing kind groups, so `getLayerSourceKind()` sorts them with no change. Two prototype stores (`addedSources`, layer catalog prefs) move from per-component `useState` to module-level stores read via `useSyncExternalStore`, so the Basemap card and the modal - siblings that are mounted at the same time - stay in sync. + +**Tech Stack:** React 18, `@dhis2/ui`, CSS modules, `@dhis2/d2-i18n`. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-08-28-basemaps-in-layer-catalog-design.md` + +**Testing note:** This is a throwaway prototype. The repo has no tests for any of the +surrounding prototype components, and the spec explicitly rules out adding any. So there is +no TDD loop here - each task verifies with `yarn lint` plus a named manual check in the +running app (`yarn start`, DHIS2 dev instance, Maps app, the layers panel on the left). +Do not add Jest or Cypress tests for this work. + +**Commit convention:** commits in this branch are signed off with +`git -c commit.gpgsign=false commit` because signing is not configured in this environment. + +--- + +### Task 1: Placement helpers and two id/meta fixes + +Adds the placement vocabulary everything else depends on, and fixes two existing bugs in +`src/util/layerSources.js` that basemaps expose. + +**Files:** + +- Modify: `src/util/layerSources.js` + +- [ ] **Step 1: Add `BING_LAYER` and `AZURE_LAYER` to the layer constant imports** + +In `src/util/layerSources.js`, the import block at the top pulls layer type constants from +`../constants/layers.js`. Add the two basemap-only types to it: + +```js +import { + THEMATIC_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, + FACILITY_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + EXTERNAL_LAYER, + GEOJSON_URL_LAYER, + TILE_LAYER, + WMS_LAYER, + GEOJSON_LAYER, + VECTOR_STYLE, + BING_LAYER, + AZURE_LAYER, +} from '../constants/layers.js' +``` + +- [ ] **Step 2: Add the placement constants and helpers** + +Add this block immediately after the existing `getLayerSourceKindLabel` definition (just +before the `DEFAULT_PINNED_IDS` export): + +```js +// PROTOTYPE ONLY - placement is orthogonal to kind: OSM Light is built-in + +// basemap, a registered WMS basemap is external + basemap. Entries are tagged +// with `placement` where the catalog composes them; anything untagged is an +// overlay, which is what every pre-existing caller assumes. +export const PLACEMENT_OVERLAY = 'overlay' +export const PLACEMENT_BASEMAP = 'basemap' + +export const getLayerSourcePlacement = (entry) => + entry?.placement ?? PLACEMENT_OVERLAY + +export const getLayerSourcePlacementLabel = (placement) => + ({ + [PLACEMENT_OVERLAY]: i18n.t('Overlay'), + [PLACEMENT_BASEMAP]: i18n.t('Basemap'), + }[placement] || placement) +``` + +- [ ] **Step 3: Rename the built-in kind label** + +The built-in group now holds both data layer types and basemaps, so "Built-in data sources" +is wrong. In `getLayerSourceKindLabel`, change the `KIND_BUILT_IN` entry only: + +```js +export const getLayerSourceKindLabel = (kind) => + ({ + [KIND_BUILT_IN]: i18n.t('Built-in'), + [KIND_EARTH_ENGINE]: i18n.t('Earth Engine'), + [KIND_EXTERNAL]: i18n.t('External data'), + }[kind] || kind) +``` + +- [ ] **Step 4: Fix `getManagedLayerSourceId` for built-in basemaps** + +A `defaultBasemaps()` entry has no `layerId`, no `config.id` and no `layer` - it carries a +top-level `id` (`'osmLight'`, `'bingAerial'`). Today this function returns `undefined` for +all of them, which would collide in the disabled list. Add `entry.id` as the final fallback. +External basemaps set both `id` and `config.id` to the same value, so nothing else shifts. + +Replace the existing definition: + +```js +// Id used by the manage dialog, where Earth Engine layers are listed +// individually rather than collapsed into their group. Deliberately skips the +// grouping branch of resolveGroupKey so sibling layers stay distinct. +// Built-in basemaps carry none of the first three and fall back to `id`. +export const getManagedLayerSourceId = (entry) => + entry?.layerId ?? entry?.config?.id ?? entry?.layer ?? entry?.id +``` + +- [ ] **Step 5: Give built-in basemaps their Service/Host meta chips** + +Two changes in the same area of the file. + +First, extend `EXTERNAL_TYPE_LABELS` with the two basemap-only types: + +```js +const EXTERNAL_TYPE_LABELS = { + [TILE_LAYER]: i18n.t('XYZ tiles'), + [WMS_LAYER]: i18n.t('WMS'), + [GEOJSON_LAYER]: i18n.t('GeoJSON'), + [VECTOR_STYLE]: i18n.t('Vector style'), + [BING_LAYER]: i18n.t('Bing'), + [AZURE_LAYER]: i18n.t('Azure'), +} +``` + +Second, in `getLayerSourceMeta`, the external branch is currently gated on +`kind === KIND_EXTERNAL`, so built-in basemaps get no chips at all. Broaden it to any entry +that carries a `config.type`. Replace: + +```js + if (kind === KIND_EXTERNAL) { +``` + +with: + +```js + // Any entry with a renderable config gets Service/Host chips - that covers + // external layers and built-in basemaps alike + if (entry?.config?.type) { +``` + +Leave the body of that branch, and the trailing `return []`, exactly as they are. Bing and +Azure entries have no `url`, so `getUrlHost('')` returns `''` and the Host chip is dropped +by the existing `.filter(Boolean)`. + +- [ ] **Step 6: Verify lint passes** + +Run: `yarn lint` +Expected: no errors. (Pre-existing warnings elsewhere in the repo are fine; there must be +no new error in `src/util/layerSources.js`.) + +- [ ] **Step 7: Verify nothing regressed in the app** + +Run: `yarn start`, open the Maps app, click **Add layer**. +Expected: the catalog popover looks exactly as before, and the group heading in **Configure +available layer sources** now reads "Built-in" instead of "Built-in data sources". + +- [ ] **Step 8: Commit** + +```bash +git add src/util/layerSources.js +git -c commit.gpgsign=false commit -m "feat: add layer source placement helpers (prototype)" +``` + +--- + +### Task 2: Module-level prototype stores + +`addedSources` lives in `AddLayerButton` local state and `useLayerCatalogPrefs` re-reads +localStorage per component instance. `BasemapCard` is a sibling of `AddLayerButton` and stays +mounted while the modal is open, so neither piece of state can reach it. Both become +module-level stores read through `useSyncExternalStore`. + +**Files:** + +- Create: `src/hooks/prototypeStore.js` +- Create: `src/hooks/useAddedLayerSources.js` +- Modify: `src/hooks/useLayerCatalogPrefs.js` +- Modify: `src/components/layers/overlays/AddLayerButton.jsx` +- Modify: `src/components/layers/overlays/AddLayerPopover.jsx` +- Modify: `src/components/layerSources/ManageLayerSourcesModal.jsx` + +- [ ] **Step 1: Create the shared store helper** + +Create `src/hooks/prototypeStore.js`: + +```js +// PROTOTYPE ONLY - a minimal localStorage-backed store with subscribers, so +// sibling components (the Add layer button and the Basemap card) see each +// other's changes without a remount. The real thing belongs in the dataStore, +// next to the Earth Engine allow-list managed by useManagedLayerSourcesStore. +export const createPrototypeStore = ({ key, initial }) => { + const read = () => { + try { + const stored = JSON.parse(window.localStorage.getItem(key)) + return stored === null ? initial : { ...initial, ...stored } + } catch (error) { + return initial + } + } + + let state = read() + const listeners = new Set() + + const get = () => state + + const set = (updater) => { + state = typeof updater === 'function' ? updater(state) : updater + try { + window.localStorage.setItem(key, JSON.stringify(state)) + } catch (error) { + // ignore - prototype only + } + listeners.forEach((listener) => listener()) + } + + const subscribe = (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + + return { get, set, subscribe } +} +``` + +- [ ] **Step 2: Create the added-sources store hook** + +Create `src/hooks/useAddedLayerSources.js`: + +```js +import { useCallback, useSyncExternalStore } from 'react' +import { createPrototypeStore } from './prototypeStore.js' + +// PROTOTYPE ONLY - sources registered through the manage dialog. The real +// thing would POST to externalMapLayers and come back through useCachedData. +const store = createPrototypeStore({ + key: 'maps-prototype-added-layer-sources', + initial: { sources: [] }, +}) + +const useAddedLayerSources = () => { + const state = useSyncExternalStore(store.subscribe, store.get) + + const addSource = useCallback( + (source) => + store.set((prev) => ({ + ...prev, + sources: [...prev.sources, source], + })), + [] + ) + + return { addedSources: state.sources, addSource } +} + +export default useAddedLayerSources +``` + +- [ ] **Step 3: Back `useLayerCatalogPrefs` with the shared store** + +Replace the whole body of `src/hooks/useLayerCatalogPrefs.js` with the version below. The +public API (`pinnedIds`, `disabledIds`, `isPinned`, `isDisabled`, `togglePinned`, +`toggleDisabled`) is unchanged, so no caller needs editing. + +```js +import { useCallback, useSyncExternalStore } from 'react' +import { DEFAULT_PINNED_IDS } from '../util/layerSources.js' +import { createPrototypeStore } from './prototypeStore.js' + +// PROTOTYPE ONLY - pinned layer sources, and the enabled/disabled state for +// built-in and external sources, kept in localStorage so it survives a reload +// while testing. The real thing belongs in the dataStore, next to the Earth +// Engine allow-list managed by useManagedLayerSourcesStore. +const store = createPrototypeStore({ + key: 'maps-prototype-layer-catalog', + initial: { pinned: DEFAULT_PINNED_IDS, disabled: [] }, +}) + +const toggle = (field, id) => + store.set((prev) => ({ + ...prev, + [field]: prev[field].includes(id) + ? prev[field].filter((item) => item !== id) + : [...prev[field], id], + })) + +const useLayerCatalogPrefs = () => { + const state = useSyncExternalStore(store.subscribe, store.get) + + const togglePinned = useCallback((id) => toggle('pinned', id), []) + const toggleDisabled = useCallback((id) => toggle('disabled', id), []) + + return { + pinnedIds: state.pinned, + disabledIds: state.disabled, + isPinned: useCallback( + (id) => state.pinned.includes(id), + [state.pinned] + ), + isDisabled: useCallback( + (id) => state.disabled.includes(id), + [state.disabled] + ), + togglePinned, + toggleDisabled, + } +} + +export default useLayerCatalogPrefs +``` + +- [ ] **Step 4: Drop the added-sources state and props from `AddLayerButton`** + +Replace the whole of `src/components/layers/overlays/AddLayerButton.jsx` with: + +```jsx +import i18n from '@dhis2/d2-i18n' +import { IconAddCircle24 } from '@dhis2/ui' +import React, { useState, useRef } from 'react' +import ManageLayerSourcesModal from '../../layerSources/ManageLayerSourcesModal.jsx' +import AddLayerPopover from './AddLayerPopover.jsx' +import styles from './styles/AddLayerButton.module.css' + +const AddLayerButton = () => { + const [isOpen, setIsOpen] = useState(false) + const [isManaging, setIsManaging] = useState(false) + const buttonRef = useRef() + + const toggleDialog = () => setIsOpen(!isOpen) + + const onManaging = () => { + setIsManaging(true) + setIsOpen(false) + } + + return ( + <> +
+ +
+ {isOpen && ( + + )} + {isManaging && ( + setIsManaging(false)} /> + )} + + ) +} + +export default AddLayerButton +``` + +- [ ] **Step 5: Read added overlays from the hook in `AddLayerPopover`** + +In `src/components/layers/overlays/AddLayerPopover.jsx`: + +Add the imports (keeping the existing import order - hooks after constants): + +```js +import useAddedLayerSources from '../../../hooks/useAddedLayerSources.js' +``` + +and extend the existing `../../../util/layerSources.js` import with the placement helpers: + +```js +import { + groupLayerSources, + getLayerSourceId, + getLayerSourcePlacement, + matchesLayerSourceFilter, + PLACEMENT_OVERLAY, +} from '../../../util/layerSources.js' +``` + +Change the component signature from: + +```js +const AddLayerPopover = ({ anchorEl, addedSources, onClose, onManaging }) => { +``` + +to: + +```js +const AddLayerPopover = ({ anchorEl, onClose, onManaging }) => { +``` + +Add the hook call next to the other hooks, after `const { isPinned, isDisabled, togglePinned } = useLayerCatalogPrefs()`: + +```js +const { addedSources } = useAddedLayerSources() +``` + +Change the `layerSources` composition so added basemaps never reach the Add layer catalog: + +```js +// Basemaps are chosen on the Basemap card, never added as a layer here +const addedOverlays = addedSources.filter( + (source) => getLayerSourcePlacement(source) === PLACEMENT_OVERLAY +) +const layerSources = includeEarthEngineLayers( + defaultLayerSources, + managedLayerSources +).concat(addedOverlays) +``` + +Finally remove `addedSources: PropTypes.array,` from `AddLayerPopover.propTypes`. + +- [ ] **Step 6: Read the hook in `ManageLayerSourcesModal`** + +In `src/components/layerSources/ManageLayerSourcesModal.jsx`: + +Add the import: + +```js +import useAddedLayerSources from '../../hooks/useAddedLayerSources.js' +``` + +Change the signature from: + +```js +const ManageLayerSourcesModal = ({ addedSources, onSourceAdded, onClose }) => { +``` + +to: + +```js +const ManageLayerSourcesModal = ({ onClose }) => { +``` + +Add the hook next to the other hook calls at the top of the component: + +```js +const { addedSources, addSource } = useAddedLayerSources() +``` + +Replace the single call to `onSourceAdded(...)` inside `onAddSource` with `addSource(...)` +(the argument is unchanged for now - Task 5 rewrites this function). + +Replace the propTypes block at the bottom with: + +```js +ManageLayerSourcesModal.propTypes = { + onClose: PropTypes.func.isRequired, +} +``` + +- [ ] **Step 7: Verify lint passes** + +Run: `yarn lint` +Expected: no errors in any of the five touched files. + +- [ ] **Step 8: Verify the add flow still works end to end** + +Run: `yarn start`, open Maps, **Add layer** → **Configure available layer sources** → +**Add source**. Register an XYZ source with Layer position **Overlay**. +Expected: the new row appears with the "Added in this session" pill, exactly as before this +task. Close the modal, reopen it - the row is still there (it now survives a page reload +too, because it is in localStorage). + +- [ ] **Step 9: Commit** + +```bash +git add src/hooks/prototypeStore.js src/hooks/useAddedLayerSources.js src/hooks/useLayerCatalogPrefs.js src/components/layers/overlays/AddLayerButton.jsx src/components/layers/overlays/AddLayerPopover.jsx src/components/layerSources/ManageLayerSourcesModal.jsx +git -c commit.gpgsign=false commit -m "refactor: share prototype catalog state across components" +``` + +--- + +### Task 3: Mock basemap sources + +So the Placement filter has external basemaps to show on a dev instance that has none +configured. + +**Files:** + +- Modify: `src/constants/mockLayerSources.js` + +- [ ] **Step 1: Add the `mockBasemapSources` export** + +In `src/constants/mockLayerSources.js`, extend the imports: + +```js +import { + EXTERNAL_LAYER, + TILE_LAYER, + WMS_LAYER, + VECTOR_STYLE, +} from './layers.js' +import { PLACEMENT_BASEMAP } from '../util/layerSources.js' +``` + +(Keep the import order the linter expects: `@dhis2/d2-i18n` first, then `../util/...`, then +`./layers.js`. Run `yarn format` if the order is flagged.) + +Then append this to the end of the file, after the existing `mockLayerSources` export: + +```js +// Shaped like createExternalBasemapLayer() output, plus the placement tag the +// catalog filters on. No `img`, so they render the "External basemap" +// placeholder tile on the Basemap card, like real external basemaps do. +const mockBasemap = ({ id, name, description, type = TILE_LAYER, url }) => ({ + layer: EXTERNAL_LAYER, + id, + name, + description, + placement: PLACEMENT_BASEMAP, + config: { + id, + type, + name, + url, + tms: false, + format: 'image/png', + }, +}) + +export const mockBasemapSources = () => [ + mockBasemap({ + id: 'mockOrthophoto22', + name: i18n.t('National orthophoto 2022'), + description: i18n.t( + 'Aerial imagery flown at 25cm resolution by the national mapping agency.' + ), + type: WMS_LAYER, + url: 'https://example.org/geoserver/wms', + }), + mockBasemap({ + id: 'mockDarkMatter', + name: i18n.t('Dark cartographic base'), + description: i18n.t( + 'Low contrast dark basemap, intended as a backdrop for bright thematic layers.' + ), + url: 'https://example.org/tiles/dark/{z}/{x}/{y}.png', + }), + mockBasemap({ + id: 'mockVectorStreets', + name: i18n.t('Vector streets'), + description: i18n.t( + 'Vector tile street map with labels in the national languages.' + ), + type: VECTOR_STYLE, + url: 'https://example.org/styles/streets.json', + }), +] +``` + +- [ ] **Step 2: Verify lint passes** + +Run: `yarn lint` +Expected: no errors. Nothing imports `mockBasemapSources` yet, so the app is unchanged. + +- [ ] **Step 3: Commit** + +```bash +git add src/constants/mockLayerSources.js +git -c commit.gpgsign=false commit -m "feat: add mock basemap sources (prototype)" +``` + +--- + +### Task 4: Basemaps in the manage modal + +The core of the feature: basemaps in the list, the Placement filter, the Basemap pill and +the last-basemap guard. + +**Files:** + +- Modify: `src/components/layerSources/LayerSource.jsx` +- Modify: `src/components/layerSources/styles/LayerSource.module.css` +- Modify: `src/components/layerSources/ManageLayerSourcesModal.jsx` + +- [ ] **Step 1: Add the placement pill to `LayerSource`** + +In `src/components/layerSources/LayerSource.jsx`, extend the util import: + +```js +import { + getLayerSourceLabel, + getLayerSourceDescription, + getLayerSourceMeta, + getLayerSourcePlacementLabel, + PLACEMENT_BASEMAP, +} from '../../util/layerSources.js' +``` + +Change the signature to accept the new props: + +```jsx +const LayerSource = ({ + layerSource, + isAdded, + onToggle, + isNew, + placement, + isLocked, + lockedReason, +}) => { +``` + +Replace the outer `
` and the `Checkbox` with the locked-aware versions: + +```jsx + return ( +
+ {}} + /> +``` + +Add the pill next to the existing `isNew` pill inside `
`, before +it, so category reads before status: + +```jsx +
+ {label} + {placement === PLACEMENT_BASEMAP && ( + + {getLayerSourcePlacementLabel(placement)} + + )} + {isNew && ( + + {i18n.t('Added in this session')} + + )} +
+``` + +Extend the propTypes: + +```js +LayerSource.propTypes = { + isAdded: PropTypes.bool.isRequired, + layerSource: PropTypes.object.isRequired, + onToggle: PropTypes.func.isRequired, + isLocked: PropTypes.bool, + isNew: PropTypes.bool, + lockedReason: PropTypes.string, + placement: PropTypes.string, +} +``` + +- [ ] **Step 2: Add the pill style** + +Append to `src/components/layerSources/styles/LayerSource.module.css`: + +```css +.placementPill { + display: inline-block; + margin-inline-start: var(--spacers-dp8); + padding: 1px 6px; + border-radius: 8px; + background-color: var(--colors-grey200); + color: var(--colors-grey800); + font-size: 11px; + font-weight: 400; + vertical-align: middle; +} +``` + +- [ ] **Step 3: Pull basemaps into the modal's source list** + +In `src/components/layerSources/ManageLayerSourcesModal.jsx`, extend the two relevant imports: + +```js +import { + mockLayerSources, + mockBasemapSources, +} from '../../constants/mockLayerSources.js' +``` + +```js +import { + getLayerSourceKind, + getLayerSourceKindLabel, + getLayerSourcePlacement, + getLayerSourcePlacementLabel, + getManagedLayerSourceId, + matchesLayerSourceFilter, + KIND_BUILT_IN, + KIND_EARTH_ENGINE, + KIND_EXTERNAL, + PLACEMENT_BASEMAP, + PLACEMENT_OVERLAY, +} from '../../util/layerSources.js' +``` + +Read `basemaps` alongside `defaultLayerSources` from the cached data: + +```js +const { defaultLayerSources, basemaps } = useCachedData() +``` + +Replace the `allSources` composition with one that tags basemaps. `useCachedData().basemaps` +is already `defaultBasemaps()` filtered by API key validation plus the external basemaps, so +it is exactly the set an author can use: + +```js +// PROTOTYPE ONLY - mock sources are appended so the dialog is worth scrolling +const basemapSources = [...basemaps, ...mockBasemapSources()].map( + (basemap) => ({ ...basemap, placement: PLACEMENT_BASEMAP }) +) + +const allSources = [ + ...defaultLayerSources, + ...mockLayerSources(), + ...basemapSources, + ...addedSources, +] +``` + +`getLayerSourceKind()` needs no change: external basemaps carry `layer: EXTERNAL_LAYER` and +land in `KIND_EXTERNAL`; `defaultBasemaps()` entries carry no `layer` and fall through to +`KIND_BUILT_IN`. The existing `groups` definition therefore picks them up as is - but the +built-in group is unsorted today, so basemaps land after the data layer types, which is the +order the spec asks for. Leave `groups` alone. + +- [ ] **Step 4: Add the Placement filter options and state** + +Add the options constant next to `STATUS_OPTIONS` and `KIND_OPTIONS`: + +```js +const PLACEMENT_OPTIONS = [ + { value: ALL, label: i18n.t('All') }, + { + value: PLACEMENT_OVERLAY, + label: getLayerSourcePlacementLabel(PLACEMENT_OVERLAY), + }, + { + value: PLACEMENT_BASEMAP, + label: getLayerSourcePlacementLabel(PLACEMENT_BASEMAP), + }, +] +``` + +Add the state next to the other filters: + +```js +const [placementFilter, setPlacementFilter] = useState(ALL) +``` + +- [ ] **Step 5: Apply the filter** + +Extend the `filteredGroups` source filter with a placement check: + +```js +const matchesPlacement = (source) => + placementFilter === ALL || + getLayerSourcePlacement(source) === placementFilter + +const filteredGroups = groups + .filter((group) => kindFilter === ALL || group.kind === kindFilter) + .map((group) => ({ + ...group, + sources: group.sources.filter( + (l) => + matchesLayerSourceFilter(l, filter) && + matchesStatus(group.kind, l) && + matchesPlacement(l) + ), + })) + .filter((group) => group.sources.length) +``` + +Earth Engine sources are always overlays, so selecting Basemap empties that group and the +existing trailing `.filter` drops it. + +- [ ] **Step 6: Reset the new filter after adding a source** + +In `onAddSource`, next to the existing three resets: + +```js +// Clear the filters so the new row is definitely visible +setFilter('') +setStatusFilter(ALL) +setKindFilter(ALL) +setPlacementFilter(ALL) +``` + +- [ ] **Step 7: Render the third select** + +In the `listContent` toolbar, after the existing Type select block, add: + +```jsx +
+ setPlacementFilter(selected)} + dataTest="managelayersources-placement" + > + {PLACEMENT_OPTIONS.map(({ value, label }) => ( + + ))} + +
+``` + +- [ ] **Step 8: Add the last-basemap guard and pass the new props through** + +Above the `listContent` definition, compute how many basemaps are still enabled: + +```js +// The map needs somewhere to render - never let the admin switch the last +// basemap off +const enabledBasemapCount = basemapSources.filter((basemap) => + isEnabled(getLayerSourceKind(basemap), getManagedLayerSourceId(basemap)) +).length + +const lockedReason = i18n.t('At least one basemap must stay enabled') +``` + +Then replace the `` call inside the group render with: + +```jsx +{ + sources.map((layerSource) => { + const id = getManagedLayerSourceId(layerSource) + const enabled = isEnabled(kind, id) + const placement = getLayerSourcePlacement(layerSource) + const isLocked = + placement === PLACEMENT_BASEMAP && + enabled && + enabledBasemapCount === 1 + return ( + onToggle(kind, id, enabled)} + /> + ) + }) +} +``` + +Note the `key`: built-in and external basemaps have distinct ids, and the `${kind}-` prefix +already keeps them apart from any overlay that happens to share one. + +- [ ] **Step 9: Give the toolbar room for a third select** + +In `src/components/layerSources/styles/ManageLayerSourcesModal.module.css`, the search input +currently takes most of the row. Loosen it so three selects fit without wrapping awkwardly: + +```css +.search { + flex: 3 1 auto; + min-width: 160px; +} + +.select { + flex: 1 1 140px; +} +``` + +- [ ] **Step 10: Verify lint passes** + +Run: `yarn lint` +Expected: no errors in the three touched files. + +- [ ] **Step 11: Manual check** + +Run: `yarn start`, Maps → **Add layer** → **Configure available layer sources**. +Expected: + +- Built-in basemaps (OSM Light, OSM Detailed, Sentinel-2 EOX, and whichever Bing/Azure + entries the instance has keys for) appear under **Built-in**, after the data layer types, + each with a grey **Basemap** pill and Service/Host meta chips. +- The three mock basemaps appear under **External data** with the same pill. +- **Placement: Basemap** shows only basemap rows and hides the Earth Engine group entirely. +- **Placement: Overlay** hides every basemap row. +- Placement combines with Status and Type - e.g. Type: Built-in + Placement: Basemap shows + only the built-in basemaps. +- Disable basemaps one by one; when one is left, its checkbox is greyed out and clicking the + row does nothing. Hovering it shows "At least one basemap must stay enabled". +- The footer count grows to include the basemaps. + +- [ ] **Step 12: Commit** + +```bash +git add src/components/layerSources/LayerSource.jsx src/components/layerSources/styles/LayerSource.module.css src/components/layerSources/ManageLayerSourcesModal.jsx src/components/layerSources/styles/ManageLayerSourcesModal.module.css +git -c commit.gpgsign=false commit -m "feat: manage basemaps in the layer sources modal (prototype)" +``` + +--- + +### Task 5: Adding a source as a basemap + +Turns the Add source form's existing "Layer position: Basemap" option from a dead end into a +real basemap. + +**Files:** + +- Modify: `src/components/layerSources/ManageLayerSourcesModal.jsx` + +- [ ] **Step 1: Import the basemap factory** + +Change the external util import: + +```js +import { + createExternalBasemapLayer, + createExternalOverlayLayer, +} from '../../util/external.js' +``` + +- [ ] **Step 2: Rewrite `onAddSource`** + +Replace the whole function with: + +```js +const onAddSource = () => { + // Unique per add: the disabled list is persisted to localStorage, so a + // reused id would inherit a stale "disabled" flag from an earlier source + const model = getExternalLayerModel( + form, + `prototype-${Date.now().toString(36)}` + ) + const isBasemap = model.mapLayerPosition === MAP_LAYER_POSITION_BASEMAP + + addSource( + isBasemap + ? { + ...createExternalBasemapLayer(model), + placement: PLACEMENT_BASEMAP, + isNew: true, + } + : { ...createExternalOverlayLayer(model), isNew: true } + ) + + setNotice( + isBasemap + ? i18n.t( + '"{{name}}" was added as a basemap and is available on the Basemap card.', + { name: model.name } + ) + : i18n.t('"{{name}}" was added and is enabled for all users.', { + name: model.name, + }) + ) + + // Clear the filters so the new row is definitely visible + setFilter('') + setStatusFilter(ALL) + setKindFilter(ALL) + setPlacementFilter(ALL) + closeAddView() +} +``` + +`createExternalBasemapLayer(model)` returns `{ layer, id, name, config }` - it keeps the +top-level `id`, which is what `getManagedLayerSourceId` and the Basemap card's `selectedID` +comparison both need. `createExternalOverlayLayer(model)` deliberately has no top-level `id` +(overlays are identified by `config.id`), so the two branches are not symmetric and should +not be made so. + +- [ ] **Step 3: Confirm nothing still references the removed basemap dead-end** + +Run: `grep -n "not listed here\|basemap layers are configured" src/components/layerSources/ManageLayerSourcesModal.jsx` +Expected: no output. + +- [ ] **Step 4: Verify lint passes** + +Run: `yarn lint` +Expected: no errors. + +- [ ] **Step 5: Manual check** + +Run: `yarn start`, Maps → **Add layer** → **Configure available layer sources** → +**Add source**. Fill in Name "Test basemap", Map service **XYZ**, URL +`https://example.org/tiles/test/{z}/{x}/{y}.png`, Layer position **Basemap**. Submit. +Expected: the list returns with a green-ish info notice saying it is available on the Basemap +card, and a new row under **External data** carrying both the **Basemap** and **Added in this +session** pills. Repeat with Layer position **Overlay** and confirm that row has no Basemap +pill. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/layerSources/ManageLayerSourcesModal.jsx +git -c commit.gpgsign=false commit -m "feat: register new sources as basemaps (prototype)" +``` + +--- + +### Task 6: Basemap card reflects the admin's choices + +Makes the toggles visibly do something, and adds the "More basemaps…" select. + +**Files:** + +- Modify: `src/components/layers/basemaps/BasemapList.jsx` +- Modify: `src/components/layers/basemaps/styles/BasemapList.module.css` + +- [ ] **Step 1: Rewrite `BasemapList`** + +Replace the whole of `src/components/layers/basemaps/BasemapList.jsx` with: + +```jsx +import i18n from '@dhis2/d2-i18n' +import { SingleSelect, SingleSelectOption } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useState } from 'react' +import { mockBasemapSources } from '../../../constants/mockLayerSources.js' +import useAddedLayerSources from '../../../hooks/useAddedLayerSources.js' +import useLayerCatalogPrefs from '../../../hooks/useLayerCatalogPrefs.js' +import { + getLayerSourcePlacement, + getManagedLayerSourceId, + PLACEMENT_BASEMAP, +} from '../../../util/layerSources.js' +import { useCachedData } from '../../cachedDataProvider/CachedDataProvider.jsx' +import Basemap from './Basemap.jsx' +import styles from './styles/BasemapList.module.css' + +const BasemapList = ({ selectedID, selectBasemap }) => { + const { basemaps } = useCachedData() + const { addedSources } = useAddedLayerSources() + const { isDisabled } = useLayerCatalogPrefs() + const [showAll, setShowAll] = useState(false) + + // PROTOTYPE ONLY - mock basemaps and basemaps registered in the manage + // dialog are merged in here, and anything an admin switched off is dropped + const addedBasemaps = addedSources.filter( + (source) => getLayerSourcePlacement(source) === PLACEMENT_BASEMAP + ) + const enabledBasemaps = [ + ...basemaps, + ...mockBasemapSources(), + ...addedBasemaps, + ].filter((basemap) => !isDisabled(getManagedLayerSourceId(basemap))) + + return ( +
+
+ {enabledBasemaps.map((basemap, index) => ( + + ))} +
+ + {showAll && ( +
+ b.id === selectedID) + ? selectedID + : '' + } + placeholder={i18n.t('Choose a basemap')} + onChange={({ selected }) => { + const basemap = enabledBasemaps.find( + (b) => b.id === selected + ) + if (basemap) { + selectBasemap({ + id: basemap.id, + config: basemap.config, + }) + } + }} + dataTest="basemaplist-select" + > + {enabledBasemaps.map((basemap) => ( + + ))} + +
+ )} +
+ ) +} + +BasemapList.propTypes = { + selectBasemap: PropTypes.func.isRequired, + selectedID: PropTypes.string.isRequired, +} + +export default BasemapList +``` + +The `selectBasemap({ id, config })` shape matches exactly what `Basemap.jsx` already passes +to the same handler, so the redux action is unchanged. + +- [ ] **Step 2: Update the styles** + +The scroll container now holds a button too, so the tiles need their own wrapper. Replace the +whole of `src/components/layers/basemaps/styles/BasemapList.module.css` with: + +```css +.basemapList { + max-height: 270px; + overflow-y: auto; + margin-left: 7px; +} + +.tiles { + display: flow-root; +} + +.moreButton { + display: block; + margin: var(--spacers-dp8) 0 var(--spacers-dp4); + padding: 0; + border: none; + background: none; + color: var(--colors-blue700); + font-size: 13px; + cursor: pointer; +} + +.moreSelect { + margin-block-end: var(--spacers-dp8); + padding-inline-end: var(--spacers-dp8); +} +``` + +- [ ] **Step 3: Verify lint passes** + +Run: `yarn lint` +Expected: no errors in the two touched files. + +- [ ] **Step 4: Manual check - the full loop** + +Run: `yarn start`, Maps. In the layers panel, expand the **Basemap** card. +Expected: + +- The tile grid looks as before, plus placeholder tiles for the three mock basemaps. +- **More basemaps…** opens a select listing every enabled basemap by name; choosing one + switches the map's basemap and highlights the matching tile. +- With the Basemap card still expanded, open **Add layer** → **Configure available layer + sources** and disable a basemap. Close the modal: that basemap's tile is gone from the card + and from the select, with no page reload. +- Re-enable it and it comes back. +- Add a new source with Layer position **Basemap**; it appears on the card as a placeholder + tile and in the select, and can be selected. +- Add a new source with Layer position **Overlay**; it appears in the Add layer catalog and + _not_ on the Basemap card. +- The Add layer catalog popover shows no basemaps and has no Placement control. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/layers/basemaps/BasemapList.jsx src/components/layers/basemaps/styles/BasemapList.module.css +git -c commit.gpgsign=false commit -m "feat: honour managed basemaps on the basemap card (prototype)" +``` + +--- + +## Final verification + +- [ ] **Run the full lint pass** + +Run: `yarn lint` +Expected: clean. + +- [ ] **Run the existing test suite to confirm nothing regressed** + +Run: `yarn test` +Expected: the pre-existing suites (`OverlayCard`, `LayerToolbar`, `favorites`, `basemaps`) +still pass. `src/util/__tests__/` includes tests that touch `getBasemapList` and favorites - +neither is modified by this plan, so any failure there is a real regression, most likely from +the `getManagedLayerSourceId` change in Task 1. + +- [ ] **Confirm the prototype is still clearly marked as one** + +Run: `grep -rn "PROTOTYPE ONLY" src/hooks/prototypeStore.js src/hooks/useAddedLayerSources.js src/hooks/useLayerCatalogPrefs.js src/constants/mockLayerSources.js src/components/layerSources/ManageLayerSourcesModal.jsx src/components/layers/basemaps/BasemapList.jsx` +Expected: at least one hit in each file. diff --git a/docs/superpowers/specs/2026-08-27-add-layer-source-modal-design.md b/docs/superpowers/specs/2026-08-27-add-layer-source-modal-design.md new file mode 100644 index 0000000000..3b3c5c46ea --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-add-layer-source-modal-design.md @@ -0,0 +1,164 @@ +# Add layer source modal - design + +Date: 2026-08-27 +Branch: `layer-catalog-prototype` +Status: approved, throwaway prototype + +## Context + +The layer catalog prototype added a "Configure available layer sources" modal +(`ManageLayerSourcesModal`) where an admin enables/disables the layer sources +available to map authors. Its list header has an `Add source` button that is +currently disabled behind a "Coming soon" tooltip. + +This design fills that button in: an in-modal form for registering a new +external layer source, modelled on the External map layer screen in the +Maintenance app. + +## Goals + +- Mock up the Add source flow well enough to demo and get feedback on. +- Stay inside what the backend already supports. Every field maps 1:1 to an + existing `externalMapLayers` property, so nothing here implies backend work. +- Speed over durability. This is a prototype and is expected to be deleted. + +## Non-goals + +- Editing or deleting existing sources. +- Writing to the API. No `POST /api/externalMapLayers`. +- Persistence across reloads. +- Registering Earth Engine or built-in sources (those are not user-creatable). +- URL reachability / capabilities probing ("test this WMS"), which no endpoint + supports today. + +## Navigation + +`ManageLayerSourcesModal` holds `view: 'list' | 'add'`. The same `` +stays mounted and swaps its content, title and actions: + +``` ++- Add layer source ------------------------------- x +| < Back to all sources +| ----------------------------------------------- +| [ form fields ] +| +| [ Cancel ] [ Add source ] ++--------------------------------------------------- +``` + +- `ModalTitle` becomes "Add layer source". +- A borderless back button sits at the top of the content. +- `ModalActions` becomes `Cancel` (back to list) + `Add source` (primary, + disabled until the form is valid). The "N of M sources enabled" counter is + hidden in the add view. +- The list header's `Add source` button loses its "Coming soon" tooltip wrapper + and sets `view: 'add'`. +- Escape in the add view returns to the list instead of closing the modal. + Note: this cannot be done with the app's own `useKeyDown('Escape', ...)`. + `@dhis2/ui`'s `Modal` registers its own **document-level** Escape handler + (`@dhis2-ui/modal/.../modal.js`) that calls `onClose` and then + `stopPropagation()`, so the event never reaches the window listener + `useKeyDown` installs - the app's handler is dead code for Escape and always + was. The working approach is to make the Modal's own `onClose` view-aware: + `onClose={isAddView ? closeAddView : onClose}`, which also makes a backdrop + click back out of the form rather than discarding it. +- Returning to the list resets the search, status and type filters so a newly + added row is visible. + +## Form fields + +Full parity with the Maintenance app's External map layer form. Field set is +exactly what `EXTERNAL_MAP_LAYERS_QUERY` in `src/util/requests.js` already +requests. + +| Field | Control | Required | Notes | +| -------------- | ----------- | -------- | ----------------------------------------- | +| Name | InputField | yes | | +| Code | InputField | no | free text, no uniqueness check | +| Map service | SelectField | yes | WMS, XYZ, TMS, Vector style, GeoJSON URL | +| URL | InputField | yes | placeholder and help text vary by service | +| Layers | InputField | when WMS | hidden unless WMS | +| Image format | SelectField | no | PNG / JPG; hidden unless WMS/XYZ/TMS | +| Layer position | SelectField | no | Overlay (default) / Basemap | +| Attribution | InputField | no | | +| Legend set | SelectField | no | live `legendSets` query, read-only | +| Legend set URL | InputField | no | | + +The map service options are the keys of `mapServiceToTypeMap` in +`src/util/external.js` (`supportedMapServices`) - the services the app can +actually render. + +Deliberate deviation from Maintenance: `Layers` and `Image format` are hidden +for Vector style and GeoJSON URL, because `createExternalLayerConfig` ignores +them for those services. + +### Validation + +Client-side only, computed on each render: + +- Name non-empty. +- Map service selected. +- URL non-empty and parses via `new URL()`. +- Layers non-empty when map service is WMS. + +Errors show on the field via `@dhis2/ui` `InputField`'s `error` / +`validationText` (the local `core/TextField` wrapper does not expose these, so +the form uses `InputField` directly). `Add source` is disabled while invalid. + +## Save behaviour + +Local mock only. On submit: + +1. Build an API-shaped model from the form state (`{ id, name, code, mapService, +url, layers, imageFormat, mapLayerPosition, attribution, legendSet, +legendSetUrl }`) with a generated id. +2. Pass it through `createExternalOverlayLayer()` from `src/util/external.js` - + the same transform the live API path uses - so the new entry renders + identically to a real one and gets its Service/Host meta chips from + `getLayerSourceMeta` for free. +3. Append to `addedSources` state in `ManageLayerSourcesModal`, which is + concatenated into `allSources`. +4. Return to the list view. + +The new source is enabled by default with no extra code: built-in and external +sources use the prototype deny-list (`useLayerCatalogPrefs.isDisabled`), and a +source that has never been disabled is enabled. + +Two supporting behaviours: + +- The new row renders an "Added in this session" pill, via a new optional + `isNew` prop on `LayerSource`. +- If Layer position is Basemap, no row is added. Instead the list view shows a + one-line info note that basemap layers are configured elsewhere and are not + listed here. This matches the real filter in `getDefaultLayerSources` + (`src/util/app.js`), which drops `MAP_LAYER_POSITION_BASEMAP` entries. + +State is lost on reload. That is accepted. + +## Files + +New: + +- `src/components/layerSources/AddLayerSourceForm.jsx` +- `src/components/layerSources/styles/AddLayerSourceForm.module.css` + +Changed: + +- `src/components/layerSources/ManageLayerSourcesModal.jsx` - view state, + title/back/actions switching, `addedSources`, basemap note, enabled + `Add source` button. +- `src/components/layerSources/styles/ManageLayerSourcesModal.module.css` - back + button and info notice styles. +- `src/components/layerSources/LayerSource.jsx` - optional `isNew` pill. +- `src/components/layerSources/styles/LayerSource.module.css` - pill styles. + +All new code carries a `PROTOTYPE ONLY` comment in the style of the existing +prototype files (`mockLayerSources.js`, `useLayerCatalogPrefs.js`). + +## Testing + +No automated tests. This is a throwaway prototype and the repo has no tests for +the surrounding prototype components. Verification is `yarn lint` clean plus a +manual pass through the flow: open the modal, add an XYZ source, add a WMS +source, check required-field blocking, check Escape and back navigation, check +the basemap note. diff --git a/docs/superpowers/specs/2026-08-28-basemaps-in-layer-catalog-design.md b/docs/superpowers/specs/2026-08-28-basemaps-in-layer-catalog-design.md new file mode 100644 index 0000000000..5dee13525b --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-basemaps-in-layer-catalog-design.md @@ -0,0 +1,272 @@ +# Basemaps in the layer catalog - design + +Date: 2026-08-28 +Branch: `layer-catalog-prototype` +Status: approved, throwaway prototype + +## Context + +Basemaps and overlays are managed in two unrelated places today: + +- Overlays come from `getDefaultLayerSources()` (`src/util/app.js`), which + explicitly **drops** every `externalMapLayers` entry with + `mapLayerPosition === MAP_LAYER_POSITION_BASEMAP`. They are browsed in the + Add layer catalog (`AddLayerPopover`) and enabled/disabled by an admin in + `ManageLayerSourcesModal`. +- Basemaps come from `getBasemapList()` (`src/util/basemaps.js`) - the 11 + entries in `defaultBasemaps()` plus external layers positioned as basemaps - + and are picked from a thumbnail grid on the map's Basemap card. Nothing + about them is configurable. + +Basemaps share every characteristic of an overlay layer source: a name, a map +service, a URL, a provenance, and an admin decision about whether map authors +should see them at all. This design brings them into the same management view +rather than building a parallel one. + +## Goals + +- List basemaps beside overlays in `ManageLayerSourcesModal`, filterable by a + new **Placement** filter. +- Make the admin's enable/disable decision visibly affect the map's Basemap + card. +- Make the Add source form's existing "Layer position: Basemap" option + actually produce a basemap instead of a dead-end notice. +- Speed over durability. This is a prototype and is expected to be deleted. + +## Non-goals + +- Showing basemaps in the Add layer catalog popover (`AddLayerPopover`). It + stays overlay-only, with no Placement tabs. Basemaps are chosen on the + Basemap card. +- Writing to the API, or persisting anything beyond the existing + localStorage prototype stores. +- Editing or deleting existing sources. +- Reordering basemaps, or choosing the instance default basemap. +- Pinning basemaps. + +## Model: placement is orthogonal to kind + +`kind` (`KIND_BUILT_IN` / `KIND_EARTH_ENGINE` / `KIND_EXTERNAL`) describes +provenance. Placement is independent of it: OSM Light is built-in + basemap, a +registered WMS basemap is external + basemap. + +New in `src/util/layerSources.js`: + +```js +export const PLACEMENT_OVERLAY = 'overlay' +export const PLACEMENT_BASEMAP = 'basemap' + +// Entries are tagged with `placement` where they are composed; anything +// untagged is an overlay, which is what every existing caller assumes. +export const getLayerSourcePlacement = (entry) => + entry?.placement ?? PLACEMENT_OVERLAY + +export const getLayerSourcePlacementLabel = (placement) => ... +``` + +Basemap entries are tagged at the point they enter the modal's source list +(`{ ...basemap, placement: PLACEMENT_BASEMAP }`), not inside +`defaultBasemaps()` or `getBasemapList()` - those stay untouched so the map +keeps working exactly as before. + +### Two fixes this exposes + +1. `getManagedLayerSourceId(entry)` is `entry.layerId ?? entry.config?.id ?? +entry.layer`. A `defaultBasemaps()` entry has none of those - it carries a + top-level `id` - so it currently resolves to `undefined`, which would collide + across every built-in basemap in the deny-list. Add `entry.id` as the final + fallback. External basemaps built by `createExternalBasemapLayer()` set both + `id` and `config.id` to the same value, so nothing else shifts. +2. `getLayerSourceMeta()` only produces Service/Host chips for + `KIND_EXTERNAL`. Broaden the condition so any entry with a `config.type` + gets them, which gives built-in basemaps their chips too, and add + `BING_LAYER` / `AZURE_LAYER` to `EXTERNAL_TYPE_LABELS` ("Bing" / "Azure"). + Bing and Azure entries have no `url`, so the Host chip is simply omitted. + +## Admin modal changes + +### Sources + +The modal reads the already-assembled `basemaps` list from `useCachedData()` - +the same list the Basemap card renders. That list is `defaultBasemaps()` +filtered by API key validation, concatenated with the external basemaps, so it +is exactly the set an author can actually use. No new query, and nothing in +`src/util/app.js` or `src/util/basemaps.js` changes. + +Each entry is tagged `placement: PLACEMENT_BASEMAP` and appended to the +existing groups. `getLayerSourceKind()` already sorts them correctly with no +change: external basemaps carry `layer: EXTERNAL_LAYER` and land in +`KIND_EXTERNAL`, while `defaultBasemaps()` entries carry no `layer` and fall +through to `KIND_BUILT_IN`. + +- `KIND_BUILT_IN` gains the built-in basemaps, listed after the built-in data + layer types. +- `KIND_EXTERNAL` gains the external basemaps, plus any basemap added this + session, plus the new mock basemaps. + +Because the built-in group now holds both, its label changes from "Built-in +data sources" to "Built-in" (`getLayerSourceKindLabel`). The Type filter keeps +its three provenance options; placement is a separate filter, not a fourth +type. + +### Placement pill + +`LayerSource` gains an optional `placement` prop. When it is +`PLACEMENT_BASEMAP`, a "Basemap" pill renders next to the name, reusing the +`newPill` treatment already in `LayerSource.module.css` (a new `.placementPill` +class sharing the same shape, in a neutral grey rather than teal so it reads as +a category rather than a status). Overlay rows get no pill - overlay is the +default and pilling everything is noise. + +### Placement filter + +A third `SingleSelect` in the existing toolbar, after Status and Type: + +| Placement | +| --------- | +| All | +| Overlay | +| Basemap | + +It filters exactly like the others - `getLayerSourcePlacement(source)` compared +against the selected value - and is reset along with the rest when a source is +added. Earth Engine sources are always overlays, so selecting Basemap empties +that group and it drops out via the existing `.filter(group => group.sources.length)`. + +### Last basemap guard + +If an admin disables every basemap the map has nothing to render. When exactly +one basemap is currently enabled, that row's checkbox is disabled and the row +carries `title="At least one basemap must stay enabled"`, and clicking it does +nothing. Overlays have no such guard. + +### Footer count + +"N of M sources enabled" now counts basemaps too. No code change - it already +derives from `groups`. + +## Add source form + +The form itself is unchanged: it already has a Layer position select with +Overlay/Basemap, and `getExternalLayerModel()` already returns +`mapLayerPosition`. + +`onAddSource()` in `ManageLayerSourcesModal` branches instead: + +- Overlay: `createExternalOverlayLayer(model)`, as today. +- Basemap: `createExternalBasemapLayer(model)`, tagged + `placement: PLACEMENT_BASEMAP`. + +Both are stored with `isNew: true` and appear as a row in the list. The current +behaviour - a basemap adds no row and shows an "it is not listed here" notice - +is removed. The success notice for a basemap reads: + +> "{{name}}" was added as a basemap and is available on the Basemap card. + +## Author side: Basemap card + +`BasemapList` currently renders `useCachedData().basemaps` verbatim. It now: + +1. Appends basemaps added this session (from the shared store below). +2. Filters out anything the admin disabled, via + `useLayerCatalogPrefs().isDisabled(getManagedLayerSourceId(basemap))`. +3. Renders the thumbnail grid unchanged, then a borderless "More basemaps…" + button below it. Clicking it toggles open a `SingleSelect` listing every + enabled basemap by name; choosing one calls the same `selectBasemap` the + tiles call. The grid is **not** capped - the select is purely an additional + way in, useful once an instance has external basemaps whose tiles are all + the same "External basemap" placeholder. + +If the currently selected basemap is disabled while it is in use, nothing +special happens - it stays on the map for this session. `getBasemapOrFallback()` +already handles a missing basemap on the next load. + +## Plumbing: two prototype stores go module-level + +Both existing prototype stores are per-component-instance `useState`, which was +fine while only the popover and the modal used them - they unmount and remount. +It breaks now: + +- `addedSources` lives in `AddLayerButton`'s local state. `BasemapCard` is a + sibling, so an added basemap cannot reach it. +- `useLayerCatalogPrefs` re-reads localStorage per instance. `BasemapList` + stays mounted while the modal is open, so toggling a basemap there would + not update the grid. + +Both become module-level stores read through `useSyncExternalStore`, sharing one +tiny helper: + +``` +src/hooks/prototypeStore.js // createStore({ key, initial }) -> { get, set, subscribe } +src/hooks/useAddedLayerSources.js // { addedSources, addSource } - localStorage-backed +src/hooks/useLayerCatalogPrefs.js // same API as now, backed by the shared store +``` + +`useLayerCatalogPrefs` keeps its current public API (`isPinned`, `isDisabled`, +`togglePinned`, `toggleDisabled`, `pinnedIds`, `disabledIds`) so its callers do +not change. + +`AddLayerButton` drops its `addedSources` state and its `addedSources` / +`onSourceAdded` props to the popover and modal; those components read the hook +directly. `AddLayerPopover` filters the added sources to overlays only, so an +added basemap never appears in the Add layer catalog. + +## Mock data + +`mockLayerSources.js` gains a `mockBasemapSources()` export - three external +basemap entries shaped by `createExternalBasemapLayer` and tagged +`placement: PLACEMENT_BASEMAP` - so the Placement filter has something to show +beyond the built-ins. Suggested set: a national orthophoto WMS, a dark +cartographic XYZ style, and a vector style basemap. + +## Files + +New: + +- `src/hooks/prototypeStore.js` +- `src/hooks/useAddedLayerSources.js` + +Changed: + +- `src/util/layerSources.js` - placement constants and helpers, `entry.id` + fallback in `getManagedLayerSourceId`, meta chips for any entry with a + `config.type`, Bing/Azure type labels, "Built-in" group label. +- `src/hooks/useLayerCatalogPrefs.js` - backed by the shared module store. +- `src/components/layerSources/ManageLayerSourcesModal.jsx` - basemap sources, + Placement filter, placement pill wiring, last-basemap guard, basemap add + path, reads the added-sources hook. +- `src/components/layerSources/LayerSource.jsx` - optional `placement` prop. +- `src/components/layerSources/styles/LayerSource.module.css` - `.placementPill`. +- `src/components/layerSources/styles/ManageLayerSourcesModal.module.css` - + room for a third select in the toolbar. +- `src/components/layers/basemaps/BasemapList.jsx` - added basemaps, disabled + filtering, "More basemaps…" select. +- `src/components/layers/basemaps/styles/BasemapList.module.css` - link and + select styles. +- `src/components/layers/overlays/AddLayerButton.jsx` - drops `addedSources` + state and props. +- `src/components/layers/overlays/AddLayerPopover.jsx` - reads added overlays + from the hook. +- `src/constants/mockLayerSources.js` - `mockBasemapSources()`. + +All new code carries a `PROTOTYPE ONLY` comment, matching the existing +prototype files. + +## Testing + +No automated tests, consistent with the rest of this prototype. Verification is +`yarn lint` clean plus a manual pass: + +1. Open Configure available layer sources - built-in basemaps appear under + Built-in with a Basemap pill, external basemaps under External. +2. Placement: Basemap shows only basemaps; Placement: Overlay hides them; + combining with Status and Type narrows as expected. +3. Disable a basemap - it disappears from the Basemap card grid and the + "More basemaps…" select immediately, with the modal still open. +4. Disable all but one - the last one's checkbox is disabled. +5. Add a source with Layer position: Basemap - a row appears with Basemap and + "Added in this session" pills, and the basemap appears on the Basemap card + and is selectable. +6. Add a source with Layer position: Overlay - unchanged from today, and it does + not appear on the Basemap card. +7. The Add layer catalog popover shows no basemaps and has no Placement control. diff --git a/i18n/en.pot b/i18n/en.pot index 8e5f58ce96..fa73e5f72f 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-10T08:27:09.245Z\n" -"PO-Revision-Date: 2026-07-10T08:27:09.245Z\n" +"POT-Creation-Date: 2026-08-27T08:58:49.598Z\n" +"PO-Revision-Date: 2026-08-27T08:58:49.599Z\n" msgid "2020" msgstr "2020" @@ -609,8 +609,8 @@ msgstr "Group set" msgid "Style by group set" msgstr "Style by group set" -msgid "Manage available layer sources" -msgstr "Manage available layer sources" +msgid "Manage layer sources" +msgstr "Manage layer sources" msgid "Configure available layer sources" msgstr "Configure available layer sources" @@ -622,6 +622,23 @@ msgstr "" "Choose which layer sources are available to add to maps. This selection " "applies to all users." +msgid "Filter layer sources by name or description" +msgstr "Filter layer sources by name or description" + +msgid "Coming soon" +msgstr "Coming soon" + +msgid "Add new source" +msgstr "Add new source" + +msgid "No layer sources match this filter." +msgstr "No layer sources match this filter." + +msgid "{{count}} of {{total}} sources enabled" +msgid_plural "{{count}} of {{total}} sources enabled" +msgstr[0] "{{count}} of {{total}} sources enabled" +msgstr[1] "{{count}} of {{total}} sources enabled" + msgid "Collapse" msgstr "Collapse" @@ -656,15 +673,39 @@ msgstr "Use human-readable keys" msgid "Data download failed." msgstr "Data download failed." +msgid "Nothing pinned. Use the pin on a layer below to pin it here." +msgstr "Nothing pinned. Use the pin on a layer below to pin it here." + +msgid "Filter all layers" +msgstr "Filter all layers" + +msgid "Unpin" +msgstr "Unpin" + +msgid "Pin" +msgstr "Pin" + msgid "External layer" msgstr "External layer" +msgid "No layers match this filter." +msgstr "No layers match this filter." + msgid "Loading layer" msgstr "Loading layer" msgid "{{- name}} deleted." msgstr "{{- name}} deleted." +msgid "Name asc." +msgstr "Name asc." + +msgid "Name desc." +msgstr "Name desc." + +msgid "Sort by" +msgstr "Sort by" + msgid "Edit" msgstr "Edit" @@ -1756,6 +1797,106 @@ msgstr "Logarithmic scale" msgid "Symbol" msgstr "Symbol" +msgid "Health facility catchment areas" +msgstr "Health facility catchment areas" + +msgid "" +"Modelled catchment polygons for every public health facility, based on a 60 " +"minute walking travel time." +msgstr "" +"Modelled catchment polygons for every public health facility, based on a 60 " +"minute walking travel time." + +msgid "Administrative boundaries level 3" +msgstr "Administrative boundaries level 3" + +msgid "" +"Official chiefdom and ward boundaries published by the national statistics " +"office." +msgstr "" +"Official chiefdom and ward boundaries published by the national statistics " +"office." + +msgid "Malaria risk raster 2024" +msgstr "Malaria risk raster 2024" + +msgid "" +"Predicted Plasmodium falciparum prevalence at 1km resolution, Malaria Atlas " +"Project." +msgstr "" +"Predicted Plasmodium falciparum prevalence at 1km resolution, Malaria Atlas " +"Project." + +msgid "Road network (OpenStreetMap)" +msgstr "Road network (OpenStreetMap)" + +msgid "Primary, secondary and tertiary roads extracted from OpenStreetMap." +msgstr "Primary, secondary and tertiary roads extracted from OpenStreetMap." + +msgid "Rivers and water bodies" +msgstr "Rivers and water bodies" + +msgid "Permanent and seasonal surface water, derived from Sentinel-2 imagery." +msgstr "Permanent and seasonal surface water, derived from Sentinel-2 imagery." + +msgid "Flood hazard zones" +msgstr "Flood hazard zones" + +msgid "" +"Areas with a 1-in-100 year flood return period, modelled by the disaster " +"management agency." +msgstr "" +"Areas with a 1-in-100 year flood return period, modelled by the disaster " +"management agency." + +msgid "School locations" +msgstr "School locations" + +msgid "" +"Primary and secondary school points from the education management " +"information system." +msgstr "" +"Primary and secondary school points from the education management " +"information system." + +msgid "Population density (national census)" +msgstr "Population density (national census)" + +msgid "Census enumeration areas shaded by people per square kilometre." +msgstr "Census enumeration areas shaded by people per square kilometre." + +msgid "Satellite imagery 2024" +msgstr "Satellite imagery 2024" + +msgid "High resolution true colour imagery, dry season." +msgstr "High resolution true colour imagery, dry season." + +msgid "Mobile network coverage" +msgstr "Mobile network coverage" + +msgid "" +"Reported 3G and 4G coverage footprints from the telecommunications " +"regulator." +msgstr "" +"Reported 3G and 4G coverage footprints from the telecommunications " +"regulator." + +msgid "Health districts (proposed 2026)" +msgstr "Health districts (proposed 2026)" + +msgid "" +"Draft redistricting proposal under consultation - not for official " +"reporting." +msgstr "" +"Draft redistricting proposal under consultation - not for official " +"reporting." + +msgid "Referral routes" +msgstr "Referral routes" + +msgid "Ambulance referral corridors between health centres and district hospitals." +msgstr "Ambulance referral corridors between health centres and district hospitals." + msgid "Relative" msgstr "Relative" @@ -1910,15 +2051,36 @@ msgstr "Cannot get authorization token for Google Earth Engine." msgid "Thematic" msgstr "Thematic" +msgid "Org units shaded or sized by an aggregate data value." +msgstr "Org units shaded or sized by an aggregate data value." + msgid "Events" msgstr "Events" +msgid "Event data collected by a program, plotted at its location." +msgstr "Event data collected by a program, plotted at its location." + msgid "Tracked entities" msgstr "Tracked entities" +msgid "Tracked entities enrolled in a program, plotted at their location." +msgstr "Tracked entities enrolled in a program, plotted at their location." + +msgid "Health facilities plotted at their location." +msgstr "Health facilities plotted at their location." + msgid "Org units" msgstr "Org units" +msgid "Organisation unit boundaries or locations." +msgstr "Organisation unit boundaries or locations." + +msgid "Built-in layer types" +msgstr "Built-in layer types" + +msgid "External layers" +msgstr "External layers" + msgid "Facility" msgstr "Facility" diff --git a/src/components/layerSources/AddLayerSourceForm.jsx b/src/components/layerSources/AddLayerSourceForm.jsx new file mode 100644 index 0000000000..ddd4329d03 --- /dev/null +++ b/src/components/layerSources/AddLayerSourceForm.jsx @@ -0,0 +1,297 @@ +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(). + +// Same query as components/classification/LegendSetSelect.jsx, which is bound to +// redux layerEdit state and so can't be reused here +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 ['http:', 'https:'].includes(new URL(value).protocol) + } 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, error, 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 ( +
+
{i18n.t('Service')}
+ + onChange('mapService', selected)} + dataTest="addlayersource-mapservice" + > + {MAP_SERVICE_OPTIONS.map(({ value, label }) => ( + + ))} + + {/* Maintenance-app parity only - createExternalLayerConfig() does + not carry code into the layer config, so nothing displays it */} + + + {isWms && ( + + )} + {isRaster && ( + + onChange('imageFormat', selected) + } + dataTest="addlayersource-imageformat" + > + {IMAGE_FORMAT_OPTIONS.map(({ value, label }) => ( + + ))} + + )} + + onChange('mapLayerPosition', selected) + } + dataTest="addlayersource-position" + > + {POSITION_OPTIONS.map(({ value, label }) => ( + + ))} + +
+ {i18n.t('Attribution and legend')} +
+ + + onChange( + 'legendSet', + legendSets.find((ls) => ls.id === selected) ?? null + ) + } + dataTest="addlayersource-legendset" + > + {legendSets.map(({ id, name }) => ( + + ))} + + +
+ ) +} + +AddLayerSourceForm.propTypes = { + errors: PropTypes.object.isRequired, + form: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, +} + +export default AddLayerSourceForm diff --git a/src/components/layerSources/LayerSource.jsx b/src/components/layerSources/LayerSource.jsx index f8f6ce8fac..5e34ad172a 100644 --- a/src/components/layerSources/LayerSource.jsx +++ b/src/components/layerSources/LayerSource.jsx @@ -1,33 +1,78 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React from 'react' +import { + getLayerSourceLabel, + getLayerSourceDescription, + getLayerSourceMeta, + getLayerSourcePlacementLabel, + PLACEMENT_BASEMAP, +} from '../../util/layerSources.js' import { Checkbox } from '../core/index.js' import styles from './styles/LayerSource.module.css' -const LayerSource = ({ layerSource, isAdded, onShow, onHide }) => { - const { layerId, name, img, description, descriptionComplement, source } = - layerSource +const LayerSource = ({ + layerSource, + isAdded, + onToggle, + isNew, + placement, + isLocked, + lockedReason, +}) => { + const { img } = layerSource + const label = getLayerSourceLabel(layerSource) + const description = getLayerSourceDescription(layerSource) + const meta = getLayerSourceMeta(layerSource) return (
trigger hide, n > trigger show - onClick={() => (isAdded ? onHide(layerId) : onShow(layerId))} + onClick={isLocked ? undefined : onToggle} + title={isLocked ? lockedReason : undefined} > {}} /> - -
-

{name}

-

{description}

-

{descriptionComplement}

-
- {i18n.t('Source')}: {source} + {img ? ( + + ) : ( +
+ )} +
+
+ {label} + {placement === PLACEMENT_BASEMAP && ( + + {getLayerSourcePlacementLabel(placement)} + + )} + {isNew && ( + + {i18n.t('Added in this session')} + + )}
+ {description && ( +
{description}
+ )} + {meta.length > 0 && ( +
+ {meta.map(({ label: metaLabel, value }) => ( + + + {metaLabel} + + {value} + + ))} +
+ )}
) @@ -36,8 +81,11 @@ const LayerSource = ({ layerSource, isAdded, onShow, onHide }) => { LayerSource.propTypes = { isAdded: PropTypes.bool.isRequired, layerSource: PropTypes.object.isRequired, - onHide: PropTypes.func.isRequired, - onShow: PropTypes.func.isRequired, + onToggle: PropTypes.func.isRequired, + isLocked: PropTypes.bool, + isNew: PropTypes.bool, + lockedReason: PropTypes.string, + placement: PropTypes.string, } export default LayerSource diff --git a/src/components/layerSources/ManageLayerSourcesButton.jsx b/src/components/layerSources/ManageLayerSourcesButton.jsx index 72635796c8..2206421d5d 100644 --- a/src/components/layerSources/ManageLayerSourcesButton.jsx +++ b/src/components/layerSources/ManageLayerSourcesButton.jsx @@ -1,30 +1,23 @@ import i18n from '@dhis2/d2-i18n' -import { Button } from '@dhis2/ui' +import { Button, IconSettings16 } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' -import { MAPS_ADMIN_AUTHORITY_IDS } from '../../constants/settings.js' -import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import styles from './styles/ManageLayerSourcesButton.module.css' +// PROTOTYPE ONLY - the admin authority gate (MAPS_ADMIN_AUTHORITY_IDS via +// useCachedData) is bypassed so the dialog is reachable on any test login. +// Restore it before this becomes real. const ManageLayerSourcesButton = ({ onClick }) => { - const { currentUser } = useCachedData() - const isMapsAdmin = MAPS_ADMIN_AUTHORITY_IDS.some((id) => - currentUser.authorities.has(id) - ) - - if (!isMapsAdmin) { - return null - } - return (
) diff --git a/src/components/layerSources/ManageLayerSourcesModal.jsx b/src/components/layerSources/ManageLayerSourcesModal.jsx index 3e6f8d870b..549af724e3 100644 --- a/src/components/layerSources/ManageLayerSourcesModal.jsx +++ b/src/components/layerSources/ManageLayerSourcesModal.jsx @@ -6,64 +6,474 @@ import { ModalActions, Button, ButtonStrip, + Input, + SingleSelect, + SingleSelectOption, + IconAdd16, + IconArrowLeft16, + IconInfo16, + IconSearch16, } from '@dhis2/ui' import PropTypes from 'prop-types' -import React from 'react' +import React, { useState } from 'react' import getEarthEngineLayers from '../../constants/earthEngineLayers/index.js' -import useKeyDown from '../../hooks/useKeyDown.js' +import { MAP_LAYER_POSITION_BASEMAP } from '../../constants/layers.js' +import { + mockLayerSources, + mockBasemapSources, +} from '../../constants/mockLayerSources.js' +import useAddedLayerSources from '../../hooks/useAddedLayerSources.js' +import useLayerCatalogPrefs from '../../hooks/useLayerCatalogPrefs.js' import useManagedLayerSourcesStore from '../../hooks/useManagedLayerSourcesStore.js' +import { + createExternalBasemapLayer, + createExternalOverlayLayer, +} from '../../util/external.js' +import { + getLayerSourceKind, + getLayerSourceKindLabel, + getLayerSourcePlacement, + getLayerSourcePlacementLabel, + getManagedLayerSourceId, + matchesLayerSourceFilter, + KIND_BUILT_IN, + KIND_EARTH_ENGINE, + KIND_EXTERNAL, + PLACEMENT_BASEMAP, + PLACEMENT_OVERLAY, +} from '../../util/layerSources.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import AddLayerSourceForm, { + EMPTY_FORM, + getExternalLayerModel, + getFormErrors, +} from './AddLayerSourceForm.jsx' import LayerSource from './LayerSource.jsx' import styles from './styles/ManageLayerSourcesModal.module.css' +const byName = (a, b) => + (a.name || a.type || '').localeCompare(b.name || b.type || '') + const nonLegacyEarthEngineLayers = getEarthEngineLayers() .filter((l) => !l.legacy) - .sort((a, b) => a.name.localeCompare(b.name)) -const layerSources = [...nonLegacyEarthEngineLayers] + .sort(byName) + +const ALL = 'all' +const STATUS_ENABLED = 'enabled' +const STATUS_DISABLED = 'disabled' + +const STATUS_OPTIONS = [ + { value: ALL, label: i18n.t('All') }, + { value: STATUS_ENABLED, label: i18n.t('Enabled') }, + { value: STATUS_DISABLED, label: i18n.t('Disabled') }, +] + +const KIND_OPTIONS = [ + { value: ALL, label: i18n.t('All') }, + { value: KIND_BUILT_IN, label: getLayerSourceKindLabel(KIND_BUILT_IN) }, + { + value: KIND_EARTH_ENGINE, + label: getLayerSourceKindLabel(KIND_EARTH_ENGINE), + }, + { value: KIND_EXTERNAL, label: getLayerSourceKindLabel(KIND_EXTERNAL) }, +] + +const PLACEMENT_OPTIONS = [ + { value: ALL, label: i18n.t('All') }, + { + value: PLACEMENT_OVERLAY, + label: getLayerSourcePlacementLabel(PLACEMENT_OVERLAY), + }, + { + value: PLACEMENT_BASEMAP, + label: getLayerSourcePlacementLabel(PLACEMENT_BASEMAP), + }, +] + +const VIEW_LIST = 'list' +const VIEW_ADD = 'add' const ManageLayerSourcesModal = ({ onClose }) => { const { managedLayerSources, showLayerSource, hideLayerSource } = useManagedLayerSourcesStore() + const { addedSources, addSource } = useAddedLayerSources() + const { isDisabled, toggleDisabled } = useLayerCatalogPrefs() + const { defaultLayerSources, basemaps } = useCachedData() + const [filter, setFilter] = useState('') + const [statusFilter, setStatusFilter] = useState(ALL) + const [kindFilter, setKindFilter] = useState(ALL) + const [placementFilter, setPlacementFilter] = useState(ALL) + const [view, setView] = useState(VIEW_LIST) + const [form, setForm] = useState(EMPTY_FORM) + const [notice, setNotice] = useState(null) + + const isAddView = view === VIEW_ADD + + const closeAddView = () => { + setView(VIEW_LIST) + setForm(EMPTY_FORM) + } + + // PROTOTYPE ONLY - mock sources are appended so the dialog is worth scrolling + const basemapSources = [...basemaps, ...mockBasemapSources()].map( + (basemap) => ({ ...basemap, placement: PLACEMENT_BASEMAP }) + ) + + const allSources = [ + ...defaultLayerSources, + ...mockLayerSources(), + ...basemapSources, + ...addedSources, + ] + + const groups = [ + { + kind: KIND_BUILT_IN, + sources: allSources.filter( + (l) => getLayerSourceKind(l) === KIND_BUILT_IN + ), + }, + { kind: KIND_EARTH_ENGINE, sources: nonLegacyEarthEngineLayers }, + { + kind: KIND_EXTERNAL, + sources: allSources + .filter((l) => getLayerSourceKind(l) === KIND_EXTERNAL) + .sort(byName), + }, + ] + + // Earth Engine visibility is an allow-list held in the dataStore, while + // built-in and external sources use the prototype deny-list + const isEnabled = (kind, id) => + kind === KIND_EARTH_ENGINE + ? managedLayerSources.includes(id) + : !isDisabled(id) + + const onToggle = (kind, id, enabled) => { + if (kind !== KIND_EARTH_ENGINE) { + toggleDisabled(id) + } else if (enabled) { + hideLayerSource(id) + } else { + showLayerSource(id) + } + } + + const matchesStatus = (groupKind, source) => { + if (statusFilter === ALL) { + return true + } + const enabled = isEnabled(groupKind, getManagedLayerSourceId(source)) + return statusFilter === STATUS_ENABLED ? enabled : !enabled + } + + const matchesPlacement = (source) => + placementFilter === ALL || + getLayerSourcePlacement(source) === placementFilter + + const filteredGroups = groups + .filter((group) => kindFilter === ALL || group.kind === kindFilter) + .map((group) => ({ + ...group, + sources: group.sources.filter( + (l) => + matchesLayerSourceFilter(l, filter) && + matchesStatus(group.kind, l) && + matchesPlacement(l) + ), + })) + .filter((group) => group.sources.length) + + const enabledCount = groups.reduce( + (count, { kind, sources }) => + count + + sources.filter((l) => isEnabled(kind, getManagedLayerSourceId(l))) + .length, + 0 + ) + const totalCount = groups.reduce((n, g) => n + g.sources.length, 0) + const visibleCount = filteredGroups.reduce( + (n, g) => n + g.sources.length, + 0 + ) + + const formErrors = getFormErrors(form) + const canAddSource = Object.keys(formErrors).length === 0 + + const onFormChange = (field, value) => + setForm((prev) => ({ ...prev, [field]: value })) - useKeyDown('Escape', onClose) + const openAddView = () => { + setNotice(null) + setView(VIEW_ADD) + } + + const onAddSource = () => { + // Unique per add: the disabled list is persisted to localStorage, so a + // reused id would inherit a stale "disabled" flag from an earlier source + const model = getExternalLayerModel( + form, + `prototype-${Date.now().toString(36)}` + ) + const isBasemap = model.mapLayerPosition === MAP_LAYER_POSITION_BASEMAP + + addSource( + isBasemap + ? { + ...createExternalBasemapLayer(model), + placement: PLACEMENT_BASEMAP, + isNew: true, + } + : { ...createExternalOverlayLayer(model), isNew: true } + ) + + setNotice( + isBasemap + ? i18n.t( + '"{{name}}" was added as a basemap and is available on the Basemap card.', + { name: model.name } + ) + : i18n.t('"{{name}}" was added and is enabled for all users.', { + name: model.name, + }) + ) + + // Clear the filters so the new row is definitely visible + setFilter('') + setStatusFilter(ALL) + setKindFilter(ALL) + setPlacementFilter(ALL) + closeAddView() + } + + // The map needs somewhere to render - never let the admin switch the last + // basemap off. Counts sources added through the form too, not just the + // ones that came from useCachedData(). + const enabledBasemapCount = allSources.filter( + (source) => + getLayerSourcePlacement(source) === PLACEMENT_BASEMAP && + isEnabled( + getLayerSourceKind(source), + getManagedLayerSourceId(source) + ) + ).length + + const lockedReason = i18n.t('At least one basemap must stay enabled') + + const listContent = ( + <> +
+ {i18n.t( + 'Choose which layer sources are available to add to maps. This selection applies to all users.' + )} +
+ {notice && ( +
+ + {notice} +
+ )} +
+ + {i18n.t('{{count}} layer sources', { + count: visibleCount, + })} + + +
+
+
+ } + value={filter} + clearable + placeholder={i18n.t('Filter layer sources')} + onChange={({ value }) => setFilter(value)} + dataTest="managelayersources-filter" + /> +
+
+ setStatusFilter(selected)} + dataTest="managelayersources-status" + > + {STATUS_OPTIONS.map(({ value, label }) => ( + + ))} + +
+
+ setKindFilter(selected)} + dataTest="managelayersources-kind" + > + {KIND_OPTIONS.map(({ value, label }) => ( + + ))} + +
+
+ + setPlacementFilter(selected) + } + dataTest="managelayersources-placement" + > + {PLACEMENT_OPTIONS.map(({ value, label }) => ( + + ))} + +
+
+ {filteredGroups.length === 0 && ( +
+ {i18n.t('No layer sources match these filters.')} +
+ )} + {filteredGroups.map(({ kind, sources }) => ( +
+
+ {getLayerSourceKindLabel(kind)} +
+ {sources.map((layerSource) => { + const id = getManagedLayerSourceId(layerSource) + const enabled = isEnabled(kind, id) + const placement = getLayerSourcePlacement(layerSource) + const isLocked = + placement === PLACEMENT_BASEMAP && + enabled && + enabledBasemapCount === 1 + return ( + onToggle(kind, id, enabled)} + /> + ) + })} +
+ ))} + + ) + + const addContent = ( + <> +
+ +
+
+ {i18n.t( + 'Register an external map service. Once added it is available to all users, and can be disabled again from the list.' + )} +
+ + + ) return ( + // @dhis2/ui's Modal closes itself on Escape via a document listener, so + // backing out of the form has to go through its own onClose - {i18n.t('Configure available layer sources')} + {isAddView + ? i18n.t('Add layer source') + : i18n.t('Configure available layer sources')} - -
- {i18n.t( - 'Choose which layer sources are available to add to maps. This selection applies to all users.' - )} -
- {layerSources.map((layerSource) => ( - - ))} + + {isAddView ? addContent : listContent} - - - + {isAddView ? ( + + + + + ) : ( +
+ + {i18n.t('{{count}} of {{total}} sources enabled', { + count: enabledCount, + total: totalCount, + })} + + + + +
+ )}
) diff --git a/src/components/layerSources/styles/AddLayerSourceForm.module.css b/src/components/layerSources/styles/AddLayerSourceForm.module.css new file mode 100644 index 0000000000..e67f87bf5d --- /dev/null +++ b/src/components/layerSources/styles/AddLayerSourceForm.module.css @@ -0,0 +1,22 @@ +.form { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: start; + gap: var(--spacers-dp8) var(--spacers-dp16); + max-width: 760px; + padding-block-end: var(--spacers-dp16); +} + +.full { + grid-column: 1 / -1; +} + +.sectionTitle { + grid-column: 1 / -1; + margin-block-start: var(--spacers-dp8); + padding-block-end: var(--spacers-dp4); + border-bottom: 1px solid var(--colors-grey300); + font-size: 14px; + font-weight: 500; + color: var(--colors-grey700); +} diff --git a/src/components/layerSources/styles/LayerSource.module.css b/src/components/layerSources/styles/LayerSource.module.css index 8f6c11f721..d3293c5963 100644 --- a/src/components/layerSources/styles/LayerSource.module.css +++ b/src/components/layerSources/styles/LayerSource.module.css @@ -1,52 +1,105 @@ .layerSource { display: flex; - align-items: center; - color: var(--colors-grey800); - font-size: 0.9rem; - border: 1px solid var(--colors-grey300); - border-radius: 4px; - padding: var(--spacers-dp16) var(--spacers-dp8); - margin-bottom: var(--spacers-dp8); - margin-right: var(--spacers-dp8); + align-items: flex-start; + gap: var(--spacers-dp8); + color: var(--colors-grey900); + font-size: 14px; + padding: var(--spacers-dp16) var(--spacers-dp4); + border-bottom: 1px solid var(--colors-grey200); cursor: pointer; } +.layerSource:last-child { + border-bottom: none; +} + .layerSource:hover { - border-color: var(--colors-grey400); + background-color: var(--colors-grey050); } .layerSource input { cursor: pointer; } -.layerSource img { +.checkbox { + flex: 0 0 auto; + margin-block-start: 6px; +} + +.layerSource .image, +.layerSource .noImage { display: block; + flex: 0 0 auto; box-sizing: border-box; - border: 1px solid var(--colors-grey400); - width: 120px; - height: 120px; - margin-left: var(--spacers-dp16); - margin-right: var(--spacers-dp16); + border: 1px solid var(--colors-grey300); + width: 64px; + height: 64px; + border-radius: 3px; + object-fit: cover; + margin-inline-end: var(--spacers-dp4); } -.layerSourceInfo { - align-self: start; +.layerSource .noImage { + background-color: var(--colors-grey200); } -.layerSourceInfo h2 { - margin: 0; - padding-top: 2px; - font-size: 0.9rem; +.info { + min-width: 0; + flex: 1 1 auto; +} + +.name { + font-size: 15px; font-weight: 500; + line-height: 18px; } -.layerSourceInfo p { +.description { color: var(--colors-grey700); + font-size: 13px; + line-height: 19px; + margin-block-start: 2px; +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: var(--spacers-dp4) var(--spacers-dp24); margin: var(--spacers-dp8) 0; - line-height: 1.2rem; + font-size: 13px; + line-height: 18px; + color: var(--colors-grey700); +} + +.metaItem { + white-space: nowrap; } -.layerSourceInfo .source { - color: var(--colors-grey600); - padding-top: var(--spacers-dp8); +.metaLabel { + color: var(--colors-grey500); + margin-inline-end: var(--spacers-dp4); +} + +.newPill { + display: inline-block; + margin-inline-start: var(--spacers-dp8); + padding: 1px 6px; + border-radius: 8px; + background-color: var(--colors-teal050); + color: var(--colors-teal700); + font-size: 11px; + font-weight: 400; + vertical-align: middle; +} + +.placementPill { + display: inline-block; + margin-inline-start: var(--spacers-dp8); + padding: 1px 6px; + border-radius: 8px; + background-color: var(--colors-grey200); + color: var(--colors-grey800); + font-size: 11px; + font-weight: 400; + vertical-align: middle; } diff --git a/src/components/layerSources/styles/ManageLayerSourcesButton.module.css b/src/components/layerSources/styles/ManageLayerSourcesButton.module.css index ffea82b567..a24efcbf7a 100644 --- a/src/components/layerSources/styles/ManageLayerSourcesButton.module.css +++ b/src/components/layerSources/styles/ManageLayerSourcesButton.module.css @@ -1,4 +1,3 @@ .button { - margin-left: var(--spacers-dp16); - margin-bottom: var(--spacers-dp12); + flex: 0 0 auto; } diff --git a/src/components/layerSources/styles/ManageLayerSourcesModal.module.css b/src/components/layerSources/styles/ManageLayerSourcesModal.module.css index b3f2cd5e2e..3f4f44b3db 100644 --- a/src/components/layerSources/styles/ManageLayerSourcesModal.module.css +++ b/src/components/layerSources/styles/ManageLayerSourcesModal.module.css @@ -1,3 +1,7 @@ +.content { + min-height: 70vh; +} + .description { font-size: 0.9rem; line-height: 1.5rem; @@ -8,3 +12,86 @@ border-collapse: collapse; width: 100%; } + +.toolbar { + display: flex; + align-items: center; + gap: var(--spacers-dp8); + margin-block-end: var(--spacers-dp8); +} + +.search { + flex: 3 1 auto; + min-width: 160px; +} + +.select { + flex: 1 1 140px; +} + +.listHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacers-dp16); + border-bottom: 1px solid var(--colors-grey300); + padding-block-end: var(--spacers-dp8); + margin-block-end: var(--spacers-dp16); +} + +.listHeaderCount { + font-size: 16px; + line-height: 16px; + font-weight: 500; + color: var(--colors-grey900); +} + +.group { + margin-bottom: var(--spacers-dp16); +} + +.groupTitle { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--colors-white); + padding: var(--spacers-dp8) 0; + font-size: 14px; + font-weight: 500; + color: var(--colors-grey700); +} + +.empty { + padding: var(--spacers-dp16) 0; + color: var(--colors-grey600); + font-style: italic; +} + +.actions { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: var(--spacers-dp16); +} + +.count { + color: var(--colors-grey600); + font-size: 14px; +} + +.back { + margin-block-end: var(--spacers-dp12); +} + +.notice { + display: flex; + align-items: center; + gap: var(--spacers-dp8); + margin-block-end: var(--spacers-dp12); + padding: var(--spacers-dp8) var(--spacers-dp12); + border-radius: 4px; + background-color: var(--colors-teal050); + color: var(--colors-grey800); + font-size: 13px; +} diff --git a/src/components/layers/basemaps/BasemapList.jsx b/src/components/layers/basemaps/BasemapList.jsx index f5c0d2ec41..1a9befeb1b 100644 --- a/src/components/layers/basemaps/BasemapList.jsx +++ b/src/components/layers/basemaps/BasemapList.jsx @@ -1,14 +1,23 @@ import PropTypes from 'prop-types' import React from 'react' -import { useCachedData } from '../../cachedDataProvider/CachedDataProvider.jsx' +import useCatalogBasemaps from '../../../hooks/useCatalogBasemaps.js' +import useLayerCatalogPrefs from '../../../hooks/useLayerCatalogPrefs.js' +import { getManagedLayerSourceId } from '../../../util/layerSources.js' import Basemap from './Basemap.jsx' import styles from './styles/BasemapList.module.css' const BasemapList = ({ selectedID, selectBasemap }) => { - const { basemaps } = useCachedData() + const basemaps = useCatalogBasemaps() + const { isDisabled } = useLayerCatalogPrefs() + + // PROTOTYPE ONLY - anything an admin switched off is dropped from the card + const enabledBasemaps = basemaps.filter( + (basemap) => !isDisabled(getManagedLayerSourceId(basemap)) + ) + return (
- {basemaps.map((basemap, index) => ( + {enabledBasemaps.map((basemap, index) => ( { // Earth Engine layers that are added to this DHIS2 instance @@ -25,7 +39,8 @@ const includeEarthEngineLayers = (defaultLayerSources, managedLayerSources) => { // Insert Earth Engine layers before external layers layerSources.splice(5, 0, ...managedEarthEngineLayers) - return layerSources + // PROTOTYPE ONLY - pad out the catalog so the filter has something to chew on + return layerSources.concat(mockLayerSources()) } const AddLayerPopover = ({ anchorEl, onClose, onManaging }) => { @@ -35,13 +50,40 @@ const AddLayerPopover = ({ anchorEl, onClose, onManaging }) => { const dispatch = useDispatch() const { defaultLayerSources } = useCachedData() const { managedLayerSources } = useManagedLayerSourcesStore() + const { pinnedIds, isPinned, isDisabled, togglePinned, reorderPinned } = + useLayerCatalogPrefs() + const { addedSources } = useAddedLayerSources() + const [filter, setFilter] = useState('') + // The details sub panel - one open at a time, positioned next to whichever + // info button opened it + const [info, setInfo] = useState(null) + const catalogRef = useRef(null) + + // Basemaps are chosen on the Basemap card, never added as a layer here + const addedOverlays = addedSources.filter( + (source) => getLayerSourcePlacement(source) === PLACEMENT_OVERLAY + ) const layerSources = includeEarthEngineLayers( defaultLayerSources, managedLayerSources - ) + ).concat(addedOverlays) const groupedLayerSources = groupLayerSources(layerSources) - useKeyDown('Escape', onClose) + useKeyDown('Escape', () => (info ? setInfo(null) : onClose())) + + const onShowInfo = (layer, event) => { + const id = getLayerSourceId(layer) + if (info?.id === id) { + setInfo(null) + return + } + // Line the panel up with the button that opened it - the panel itself + // pulls back up if that would push it off the bottom of the window + const button = event.currentTarget.getBoundingClientRect() + const catalog = catalogRef.current?.getBoundingClientRect() + const offset = catalog ? button.top - catalog.top : 0 + setInfo({ id, layer, top: Math.max(0, offset) }) + } const onLayerSelect = (layer) => { let selectedLayer = layer @@ -60,6 +102,29 @@ const AddLayerPopover = ({ anchorEl, onClose, onManaging }) => { onClose() } + const onTogglePin = (layer) => togglePinned(getLayerSourceId(layer)) + + const onFilterChange = (value) => { + setFilter(value) + setInfo(null) + } + + // Sources switched off in the manage dialog never show up here + const enabledLayerSources = groupedLayerSources.filter( + (layer) => !isDisabled(getLayerSourceId(layer)) + ) + // Pinned tiles follow the order they were dragged into, not the catalog's + const pinnedLayerSources = enabledLayerSources + .filter((layer) => isPinned(getLayerSourceId(layer))) + .sort( + (a, b) => + pinnedIds.indexOf(getLayerSourceId(a)) - + pinnedIds.indexOf(getLayerSourceId(b)) + ) + const otherLayerSources = enabledLayerSources + .filter((layer) => !isPinned(getLayerSourceId(layer))) + .filter((layer) => matchesLayerSourceFilter(layer, filter)) + return ( { maxWidth={700} onClickOutside={onClose} dataTest="addlayerpopover" + className={styles.popover} > - - {!isSplitView && } + {isSplitView ? ( + + ) : ( +
+ {pinnedLayerSources.length > 0 && ( +
+ +
+ )} +
+
+
+ } + clearable + value={filter} + placeholder={i18n.t( + 'Filter {{count}} available layers', + { + count: enabledLayerSources.length, + } + )} + onChange={({ value }) => + onFilterChange(value) + } + dataTest="addlayerfilter" + /> +
+ + +
+ setInfo(null)} + /> +
+ {info && ( + setInfo(null)} + onSelect={onLayerSelect} + /> + )} +
+ )}
) } diff --git a/src/components/layers/overlays/Layer.jsx b/src/components/layers/overlays/Layer.jsx index 2a7aa37587..011625919f 100644 --- a/src/components/layers/overlays/Layer.jsx +++ b/src/components/layers/overlays/Layer.jsx @@ -1,12 +1,16 @@ import i18n from '@dhis2/d2-i18n' import { Tooltip } from '@dhis2/ui' +import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' +import { getLayerSourceDescription } from '../../../util/layerSources.js' +import PinIcon from './PinIcon.jsx' import styles from './styles/Layer.module.css' -const Layer = ({ layer, onClick }) => { +const Layer = ({ layer, onClick, isPinned, onTogglePin }) => { const { img, type, name } = layer const label = name || i18n.t(type) + const description = getLayerSourceDescription(layer) const dataTest = `addlayeritem-${label .toLowerCase() .replaceAll(/\s/g, '_')}` @@ -17,12 +21,25 @@ const Layer = ({ layer, onClick }) => { onClick={() => onClick(layer)} data-test={dataTest} > + {onTogglePin && ( + + )}
{img ? ( @@ -42,6 +59,8 @@ const Layer = ({ layer, onClick }) => { Layer.propTypes = { layer: PropTypes.object.isRequired, onClick: PropTypes.func.isRequired, + isPinned: PropTypes.bool, + onTogglePin: PropTypes.func, } export default Layer diff --git a/src/components/layers/overlays/LayerList.jsx b/src/components/layers/overlays/LayerList.jsx index 1f4d4faf06..77a79e30a3 100644 --- a/src/components/layers/overlays/LayerList.jsx +++ b/src/components/layers/overlays/LayerList.jsx @@ -1,24 +1,148 @@ +import { + DndContext, + closestCenter, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + SortableContext, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import PropTypes from 'prop-types' -import React from 'react' +import React, { useRef } from 'react' import { THEMATIC_LAYER } from '../../../constants/layers.js' +import { getLayerSourceId } from '../../../util/layerSources.js' import Layer from './Layer.jsx' import styles from './styles/LayerList.module.css' -const LayerList = ({ layers, isSplitView, onLayerSelect }) => { +// PROTOTYPE ONLY - drag to reorder the pinned tiles. The whole tile is the +// drag handle, so a drag has to travel a little before it counts as one, and +// the click that lands after a drop is swallowed rather than adding the layer. +const SortableLayer = ({ id, ...layerProps }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const wasDragged = useRef(false) + + if (isDragging) { + wasDragged.current = true + } + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : undefined, + opacity: isDragging ? 0.4 : 1, + } + + return ( +
{ + if (wasDragged.current) { + wasDragged.current = false + event.preventDefault() + event.stopPropagation() + } + }} + {...attributes} + {...listeners} + > + +
+ ) +} + +SortableLayer.propTypes = { + id: PropTypes.string.isRequired, +} + +const LayerList = ({ + layers, + isSplitView, + onLayerSelect, + isPinned, + onTogglePin, + onReorder, + variant, +}) => { const displayedLayers = isSplitView ? layers.filter((layer) => layer.layer === THEMATIC_LAYER) : layers + + const sensors = useSensors( + useSensor(MouseSensor, { + // Require a small movement so a click on a tile isn't a drag + activationConstraint: { distance: 5 }, + }), + useSensor(TouchSensor, { + activationConstraint: { delay: 250, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const isSortable = variant === 'pinned' && !!onReorder + const ids = displayedLayers.map(getLayerSourceId) + + const onDragEnd = ({ active, over }) => { + if (over && active.id !== over.id) { + onReorder(active.id, over.id) + } + } + + const grid = ( +
+ {displayedLayers.map((layer, index) => { + const id = getLayerSourceId(layer) + const props = { + onClick: onLayerSelect, + layer, + isPinned: isPinned?.(id), + onTogglePin, + } + + return isSortable ? ( + + ) : ( + + ) + })} +
+ ) + return (
-
- {displayedLayers.map((layer, index) => ( - - ))} -
+ {isSortable ? ( + + + {grid} + + + ) : ( + grid + )}
) } @@ -26,7 +150,11 @@ const LayerList = ({ layers, isSplitView, onLayerSelect }) => { LayerList.propTypes = { layers: PropTypes.array.isRequired, onLayerSelect: PropTypes.func.isRequired, + isPinned: PropTypes.func, isSplitView: PropTypes.bool, + variant: PropTypes.oneOf(['pinned']), + onReorder: PropTypes.func, + onTogglePin: PropTypes.func, } export default LayerList diff --git a/src/components/layers/overlays/LayerSourceDetails.jsx b/src/components/layers/overlays/LayerSourceDetails.jsx new file mode 100644 index 0000000000..d4be8af423 --- /dev/null +++ b/src/components/layers/overlays/LayerSourceDetails.jsx @@ -0,0 +1,142 @@ +import i18n from '@dhis2/d2-i18n' +import { Button, IconCross16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useLayoutEffect, useRef, useState } from 'react' +import { + getLayerSourceLabel, + getLayerSourceDescription, + getLayerSourceMeta, + getLayerSourceKind, + getLayerSourceKindLabel, + getLayerSourcePlacement, + getLayerSourcePlacementLabel, + PLACEMENT_BASEMAP, +} from '../../../util/layerSources.js' +import styles from './styles/LayerSourceDetails.module.css' + +// Grouped Earth Engine entries hold their datasets one or two levels down - +// list them so the group is more than an opaque name +const getContainedLayerNames = (entry) => + (entry?.items ?? []) + .flatMap((sub) => + sub?.items + ? sub.items.map(getLayerSourceLabel) + : [getLayerSourceLabel(sub)] + ) + .filter(Boolean) + +// Distance the panel keeps from the bottom of the window +const VIEWPORT_MARGIN = 8 + +const LayerSourceDetails = ({ layer, top, onClose, onSelect }) => { + const ref = useRef(null) + // The caller lines the panel up with the info button it came from; only + // once it has rendered do we know how tall it is, so nudge it back up if + // that alignment pushes it past the bottom of the window + const [offsetTop, setOffsetTop] = useState(top) + + useLayoutEffect(() => { + const { bottom } = ref.current?.getBoundingClientRect() ?? {} + const overflow = bottom - (window.innerHeight - VIEWPORT_MARGIN) + if (overflow > 0) { + setOffsetTop(Math.max(0, top - overflow)) + } + }, [top]) + + const label = getLayerSourceLabel(layer) + const description = getLayerSourceDescription(layer) + const meta = getLayerSourceMeta(layer) + const kind = getLayerSourceKind(layer) + const placement = getLayerSourcePlacement(layer) + const contained = getContainedLayerNames(layer) + + return ( +
event.stopPropagation()} + > + + {layer.img ? ( + + ) : ( +
+ {i18n.t('No preview available')} +
+ )} +
{label}
+
+ + {getLayerSourceKindLabel(kind)} + + {placement === PLACEMENT_BASEMAP && ( + + {getLayerSourcePlacementLabel(placement)} + + )} +
+ {description ? ( +
{description}
+ ) : ( +
+ {i18n.t('No description provided.')} +
+ )} + {meta.length > 0 && ( +
+ {meta.map(({ label: metaLabel, value }) => ( + +
{metaLabel}
+
{value}
+
+ ))} +
+ )} + {contained.length > 0 && ( +
+
+ {i18n.t('Includes {{count}} datasets', { + count: contained.length, + })} +
+
    + {contained.map((name) => ( +
  • {name}
  • + ))} +
+
+ )} + {onSelect && ( +
+ +
+ )} +
+ ) +} + +LayerSourceDetails.propTypes = { + layer: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired, + top: PropTypes.number, + onSelect: PropTypes.func, +} + +export default LayerSourceDetails diff --git a/src/components/layers/overlays/LayerSourceList.jsx b/src/components/layers/overlays/LayerSourceList.jsx new file mode 100644 index 0000000000..8d51587bf4 --- /dev/null +++ b/src/components/layers/overlays/LayerSourceList.jsx @@ -0,0 +1,59 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import { getLayerSourceId } from '../../../util/layerSources.js' +import LayerSourceRow from './LayerSourceRow.jsx' +import styles from './styles/LayerSourceList.module.css' + +const LayerSourceList = ({ + layers, + onLayerSelect, + isPinned, + onTogglePin, + onShowInfo, + infoLayerId, + onScroll, +}) => { + if (!layers.length) { + return ( +
+ {i18n.t('No layers match this filter.')} +
+ ) + } + + return ( +
+ {layers.map((layer) => { + const id = getLayerSourceId(layer) + return ( + + ) + })} +
+ ) +} + +LayerSourceList.propTypes = { + isPinned: PropTypes.func.isRequired, + layers: PropTypes.array.isRequired, + onLayerSelect: PropTypes.func.isRequired, + onTogglePin: PropTypes.func.isRequired, + infoLayerId: PropTypes.string, + onScroll: PropTypes.func, + onShowInfo: PropTypes.func, +} + +export default LayerSourceList diff --git a/src/components/layers/overlays/LayerSourceRow.jsx b/src/components/layers/overlays/LayerSourceRow.jsx new file mode 100644 index 0000000000..feeb8e77b0 --- /dev/null +++ b/src/components/layers/overlays/LayerSourceRow.jsx @@ -0,0 +1,89 @@ +import i18n from '@dhis2/d2-i18n' +import { IconInfo16 } from '@dhis2/ui' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React from 'react' +import { + getLayerSourceLabel, + getLayerSourceDescription, + getLayerSourceDataTest, +} from '../../../util/layerSources.js' +import PinIcon from './PinIcon.jsx' +import styles from './styles/LayerSourceRow.module.css' + +const LayerSourceRow = ({ + layer, + onClick, + isPinned, + onTogglePin, + onShowInfo, + isInfoOpen, +}) => { + const label = getLayerSourceLabel(layer) + const description = getLayerSourceDescription(layer) + + return ( +
onClick(layer)} + data-test={getLayerSourceDataTest(label)} + > + {layer.img ? ( + + ) : ( +
+ )} +
+
{label}
+ {description && ( +
{description}
+ )} +
+
+ {onShowInfo && ( + + )} + {onTogglePin && ( + + )} +
+
+ ) +} + +LayerSourceRow.propTypes = { + layer: PropTypes.object.isRequired, + onClick: PropTypes.func.isRequired, + isInfoOpen: PropTypes.bool, + isPinned: PropTypes.bool, + onShowInfo: PropTypes.func, + onTogglePin: PropTypes.func, +} + +export default LayerSourceRow diff --git a/src/components/layers/overlays/PinIcon.jsx b/src/components/layers/overlays/PinIcon.jsx new file mode 100644 index 0000000000..0333819506 --- /dev/null +++ b/src/components/layers/overlays/PinIcon.jsx @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types' +import React from 'react' + +// Pin / pinFill glyphs - @dhis2/ui has no pin icon, so these are inlined. +// fill="currentColor" so the surrounding CSS colour applies. +const PinIcon = ({ filled }) => ( + +) + +PinIcon.propTypes = { + filled: PropTypes.bool, +} + +export default PinIcon diff --git a/src/components/layers/overlays/SortByButton.jsx b/src/components/layers/overlays/SortByButton.jsx new file mode 100644 index 0000000000..f67f2527e1 --- /dev/null +++ b/src/components/layers/overlays/SortByButton.jsx @@ -0,0 +1,44 @@ +import i18n from '@dhis2/d2-i18n' +import { DropdownButton, FlyoutMenu, MenuItem } from '@dhis2/ui' +import React, { useState } from 'react' + +// PROTOTYPE ONLY - purely cosmetic, doesn't actually sort anything +const SORT_OPTIONS = [ + { value: 'name-asc', label: i18n.t('Name asc.') }, + { value: 'name-desc', label: i18n.t('Name desc.') }, + { value: 'type', label: i18n.t('Type') }, +] + +const SortByButton = () => { + const [open, setOpen] = useState(false) + const [sortBy, setSortBy] = useState(SORT_OPTIONS[0]) + + return ( + setOpen(!open)} + dataTest="addlayersort" + component={ + + {SORT_OPTIONS.map((option) => ( + { + setSortBy(option) + setOpen(false) + }} + /> + ))} + + } + > + {i18n.t('Sort by')} + + ) +} + +export default SortByButton diff --git a/src/components/layers/overlays/styles/AddLayerPopover.module.css b/src/components/layers/overlays/styles/AddLayerPopover.module.css new file mode 100644 index 0000000000..1cfd2666c2 --- /dev/null +++ b/src/components/layers/overlays/styles/AddLayerPopover.module.css @@ -0,0 +1,50 @@ +/* Nudges the popover off the anchor's edge - popper positions via an inline + transform, so margin (which it never touches) is the safe way to offset it */ +.popover { + margin-top: 1px; + margin-left: 4px; +} + +.catalog { + position: relative; /* anchor for the layer details sub panel */ + display: flex; + flex-direction: column; + width: 684px; + /* The popover is anchored to the top toolbar, so cap the whole panel to the + viewport - otherwise a long pinned zone pushes it off screen. */ + max-height: calc(100vh - 120px); +} + +/* Pinned tiles get space priority - they are the common case, whereas + searching the full catalog is occasional. Size to content up to three rows + (~150px pitch each), then scroll. flex-shrink is 0 (not the auto/1 you'd + expect) because overflow-y:auto resets a flex item's automatic minimum + size to 0, which would otherwise let it get shrunk below its own content + height and permanently show a scrollbar even when everything fits. */ +.pinnedZone { + flex: 0 0 auto; + max-height: min(480px, 55vh); + overflow-y: auto; + border-bottom: 1px solid var(--colors-grey300); +} + +/* shrink-factor 3 so the catalog list gives up height before the pinned zone */ +.listZone { + display: flex; + flex: 1 3 auto; + flex-direction: column; + min-height: 0; +} + +.zoneHeader { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--spacers-dp8); + padding: var(--spacers-dp8) var(--spacers-dp12) var(--spacers-dp8); + background-color: var(--colors-grey050); +} + +.filter { + flex: 1 1 auto; +} diff --git a/src/components/layers/overlays/styles/Layer.module.css b/src/components/layers/overlays/styles/Layer.module.css index 972b8aa690..3fd02b6d4e 100644 --- a/src/components/layers/overlays/styles/Layer.module.css +++ b/src/components/layers/overlays/styles/Layer.module.css @@ -1,59 +1,90 @@ .container { - float: left; - width: 128px; - margin-right: var(--spacers-dp4); - margin-bottom: var(--spacers-dp4); - padding: var(--spacers-dp4); - cursor: pointer; + position: relative; box-sizing: border-box; - height: auto; + padding: var(--spacers-dp8); background-color: transparent; - border-radius: 3px; + border: 1px solid transparent; + border-radius: 5px; + cursor: pointer; } .container:hover { + /* border: 1px solid var(--colors-grey400); */ background-color: var(--colors-grey100); } -.image { +.image, +.noImage { + display: block; box-sizing: border-box; + width: 110px; + height: 110px; + margin: 0 auto; border: 1px solid var(--colors-grey400); - width: 120px; - height: 120px; - border-radius: 2px; + border-radius: 3px; } -.container:hover .image { +.container:hover .image, +.container:hover .noImage { border-color: var(--colors-grey500); } .noImage { - box-sizing: border-box; - border: 1px solid var(--colors-grey400); - width: 120px; - height: 120px; - border-radius: 2px; - line-height: 120px; + line-height: 96px; background: var(--colors-grey200); color: var(--colors-grey600); font-size: 12px; text-align: center; - margin-bottom: var(--spacers-dp4); -} -.container:hover .noImage { - border-color: var(--colors-grey500); } .name { + margin-top: var(--spacers-dp8); font-size: 14px; + line-height: 15px; + font-weight: 500; color: var(--colors-grey800); - padding-bottom: var(--spacers-dp4); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; text-align: center; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; } .container:hover .name { color: var(--colors-grey900); } -.tooltip { - transform: translate(0px, -26px); + +/* Pin toggle - flush against the card's top-right corner, revealed on hover */ +.pin { + position: absolute; + top: 0; + right: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--colors-grey400); + border-radius: 0 4px 0 3px; + background-color: var(--colors-white); + color: var(--colors-grey600); + cursor: pointer; + opacity: 0; +} + +.container:hover .pin { + opacity: 1; +} + +.pin svg { + display: block; +} + +.pin:hover { + border-color: var(--colors-grey600); + color: var(--colors-grey900); +} + +.pin.isPinned { + color: var(--colors-blue600); } diff --git a/src/components/layers/overlays/styles/LayerList.module.css b/src/components/layers/overlays/styles/LayerList.module.css index 7ebeec2dd6..cb5423b351 100644 --- a/src/components/layers/overlays/styles/LayerList.module.css +++ b/src/components/layers/overlays/styles/LayerList.module.css @@ -3,9 +3,14 @@ } .list { - max-width: 684px; + display: grid; + /* width to fit 5 items - minmax(0, 1fr) so an unbroken label can't + blow out its column past its even share (1fr alone defaults to + minmax(auto, 1fr), which grows to fit unwrapped content) */ + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--spacers-dp8); max-height: calc(100vh - 150px); - padding: var(--spacers-dp8) var(--spacers-dp4) var(--spacers-dp8) + padding: var(--spacers-dp12) var(--spacers-dp8) var(--spacers-dp12) var(--spacers-dp12); overflow-y: auto; } @@ -16,3 +21,14 @@ line-height: 25px; font-style: italic; } + +/* Used for the pinned tile grid - the surrounding zone owns the scrolling */ +.pinnedList { + display: grid; + /* width to fit 5 items - minmax(0, 1fr) so an unbroken label can't + blow out its column past its even share (1fr alone defaults to + minmax(auto, 1fr), which grows to fit unwrapped content) */ + grid-template-columns: repeat(5, minmax(0, 1fr)); + + padding: var(--spacers-dp12) var(--spacers-dp8); +} diff --git a/src/components/layers/overlays/styles/LayerSourceDetails.module.css b/src/components/layers/overlays/styles/LayerSourceDetails.module.css new file mode 100644 index 0000000000..6efba17017 --- /dev/null +++ b/src/components/layers/overlays/styles/LayerSourceDetails.module.css @@ -0,0 +1,135 @@ +/* Sits outside the catalog popover's right edge, like a sub menu. Rendered + inside the popover's own DOM (rather than as a nested UI Popover) so a click + in here never registers as a click outside the catalog. */ +.panel { + position: absolute; + left: calc(100% + 4px); + z-index: 2; + box-sizing: border-box; + width: 320px; + max-height: min(560px, 70vh); + overflow-y: auto; + padding: var(--spacers-dp16); + border-radius: 4px; + background-color: var(--colors-white); + box-shadow: 0 2px 12px rgba(33, 43, 54, 0.25); + cursor: default; +} + +.close { + position: absolute; + top: var(--spacers-dp8); + right: var(--spacers-dp8); + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 3px; + background-color: var(--colors-white); + color: var(--colors-grey600); + cursor: pointer; +} + +.close:hover { + background-color: var(--colors-grey100); + color: var(--colors-grey900); +} + +.thumb, +.noThumb { + display: block; + box-sizing: border-box; + width: 100%; + height: 160px; + border: 1px solid var(--colors-grey400); + border-radius: 3px; + object-fit: cover; +} + +.noThumb { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--colors-grey200); + color: var(--colors-grey600); + font-size: 12px; +} + +.name { + margin-top: var(--spacers-dp12); + padding-inline-end: var(--spacers-dp16); + font-size: 15px; + font-weight: 500; + line-height: 20px; + color: var(--colors-grey900); +} + +.pills { + display: flex; + flex-wrap: wrap; + gap: var(--spacers-dp4); + margin-top: var(--spacers-dp8); +} + +.pill { + padding: 1px 6px; + border-radius: 8px; + background-color: var(--colors-grey200); + color: var(--colors-grey800); + font-size: 11px; +} + +.description, +.noDescription { + margin-top: var(--spacers-dp8); + font-size: 13px; + line-height: 19px; + color: var(--colors-grey700); +} + +.noDescription { + color: var(--colors-grey500); + font-style: italic; +} + +.meta { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacers-dp4) var(--spacers-dp12); + margin: var(--spacers-dp12) 0 0; + font-size: 13px; + line-height: 18px; +} + +.metaLabel { + color: var(--colors-grey500); +} + +.metaValue { + margin: 0; + color: var(--colors-grey800); + overflow-wrap: anywhere; +} + +.contained { + margin-top: var(--spacers-dp12); + font-size: 13px; + line-height: 18px; +} + +.containedLabel { + color: var(--colors-grey500); +} + +.containedList { + margin: var(--spacers-dp4) 0 0; + padding-inline-start: var(--spacers-dp16); + color: var(--colors-grey800); +} + +.actions { + margin-top: var(--spacers-dp16); +} diff --git a/src/components/layers/overlays/styles/LayerSourceList.module.css b/src/components/layers/overlays/styles/LayerSourceList.module.css new file mode 100644 index 0000000000..a3b3ad0e43 --- /dev/null +++ b/src/components/layers/overlays/styles/LayerSourceList.module.css @@ -0,0 +1,17 @@ +.list { + display: flex; + flex-direction: column; + gap: 0; + /* height is governed by the bounded .catalog flex column */ + flex: 1 1 auto; + min-height: 72px; + overflow-y: auto; + padding: 0 0 var(--spacers-dp12); +} + +.empty { + padding: var(--spacers-dp24) var(--spacers-dp16); + color: var(--colors-grey600); + font-size: 13px; + text-align: center; +} diff --git a/src/components/layers/overlays/styles/LayerSourceRow.module.css b/src/components/layers/overlays/styles/LayerSourceRow.module.css new file mode 100644 index 0000000000..c61d10070c --- /dev/null +++ b/src/components/layers/overlays/styles/LayerSourceRow.module.css @@ -0,0 +1,108 @@ +.row { + display: flex; + align-items: center; + gap: var(--spacers-dp12); + padding: var(--spacers-dp12) var(--spacers-dp4) var(--spacers-dp12) + var(--spacers-dp16); + cursor: pointer; +} + +.row:hover { + background-color: var(--colors-grey100); +} + +.thumb, +.noThumb { + flex: 0 0 auto; + box-sizing: border-box; + width: 40px; + height: 40px; + border: 1px solid var(--colors-grey400); + border-radius: 3px; +} + +.row:hover .thumb, +.row:hover .noThumb { + border-color: var(--colors-grey600); +} + +.noThumb { + background-color: var(--colors-grey200); +} + +.text { + flex: 1 1 auto; + min-width: 0; +} + +.label { + font-size: 14px; + line-height: 20px; + color: var(--colors-grey900); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.description { + margin-top: 4px; + font-size: 13px; + line-height: 16px; + color: var(--colors-grey600); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* The two buttons sit flush against each other, outside the row's own gap */ +.actions { + flex: 0 0 auto; + display: flex; + align-items: center; +} + +.pin, +.info { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + background-color: transparent; + color: var(--colors-grey600); + cursor: pointer; + opacity: 0; +} + +.row:hover .pin, +.row:hover .info, +.pin.isPinned, +.info.isInfoOpen { + opacity: 1; +} + +.pin svg, +.info svg { + display: block; +} + +.pin:hover, +.info:hover { + border-color: var(--colors-grey400); + background-color: var(--colors-white); + color: var(--colors-grey900); +} + +.pin.isPinned { + color: var(--colors-blue600); +} + +.info.isInfoOpen { + border-color: var(--colors-grey400); + background-color: var(--colors-white); + color: var(--colors-grey900); +} diff --git a/src/constants/mockLayerSources.js b/src/constants/mockLayerSources.js new file mode 100644 index 0000000000..2b5bf24c7c --- /dev/null +++ b/src/constants/mockLayerSources.js @@ -0,0 +1,177 @@ +// PROTOTYPE ONLY - fake external layer sources so the catalog list is worth +// filtering without depending on what the dev instance has configured. +// Delete this file and its two importers (AddLayerPopover, ManageLayerSourcesModal) +// before this becomes real. +import i18n from '@dhis2/d2-i18n' +import { PLACEMENT_BASEMAP } from '../util/layerSources.js' +import { + EXTERNAL_LAYER, + TILE_LAYER, + WMS_LAYER, + VECTOR_STYLE, +} from './layers.js' + +const mock = ({ id, name, description, type = TILE_LAYER, url }) => ({ + layer: EXTERNAL_LAYER, + img: 'images/featurelayer.png', + name, + description, + config: { + id, + type, + name, + url, + tms: false, + format: 'image/png', + }, +}) + +export const mockLayerSources = () => [ + mock({ + id: 'mockCatchment01', + name: i18n.t('Health facility catchment areas'), + description: i18n.t( + 'Modelled catchment polygons for every public health facility, based on a 60 minute walking travel time.' + ), + type: WMS_LAYER, + url: 'https://example.org/geoserver/wms', + }), + mock({ + id: 'mockAdmin03', + name: i18n.t('Administrative boundaries level 3'), + description: i18n.t( + 'Official chiefdom and ward boundaries published by the national statistics office.' + ), + type: WMS_LAYER, + url: 'https://example.org/geoserver/wms', + }), + mock({ + id: 'mockMalaria24', + name: i18n.t('Malaria risk raster 2024'), + description: i18n.t( + 'Predicted Plasmodium falciparum prevalence at 1km resolution, Malaria Atlas Project.' + ), + url: 'https://example.org/tiles/malaria/{z}/{x}/{y}.png', + }), + mock({ + id: 'mockRoads01', + name: i18n.t('Road network (OpenStreetMap)'), + description: i18n.t( + 'Primary, secondary and tertiary roads extracted from OpenStreetMap.' + ), + url: 'https://example.org/tiles/roads/{z}/{x}/{y}.png', + }), + mock({ + id: 'mockRivers01', + name: i18n.t('Rivers and water bodies'), + description: i18n.t( + 'Permanent and seasonal surface water, derived from Sentinel-2 imagery.' + ), + url: 'https://example.org/tiles/water/{z}/{x}/{y}.png', + }), + mock({ + id: 'mockFlood01', + name: i18n.t('Flood hazard zones'), + description: i18n.t( + 'Areas with a 1-in-100 year flood return period, modelled by the disaster management agency.' + ), + type: WMS_LAYER, + url: 'https://example.org/geoserver/wms', + }), + mock({ + id: 'mockSchools01', + name: i18n.t('School locations'), + description: i18n.t( + 'Primary and secondary school points from the education management information system.' + ), + url: 'https://example.org/tiles/schools/{z}/{x}/{y}.png', + }), + mock({ + id: 'mockPopDensity', + name: i18n.t('Population density (national census)'), + description: i18n.t( + 'Census enumeration areas shaded by people per square kilometre.' + ), + type: WMS_LAYER, + url: 'https://example.org/geoserver/wms', + }), + mock({ + id: 'mockSatellite24', + name: i18n.t('Satellite imagery 2024'), + description: i18n.t('High resolution true colour imagery, dry season.'), + url: 'https://example.org/tiles/imagery/{z}/{x}/{y}.png', + }), + mock({ + id: 'mockMobile01', + name: i18n.t('Mobile network coverage'), + description: i18n.t( + 'Reported 3G and 4G coverage footprints from the telecommunications regulator.' + ), + url: 'https://example.org/tiles/coverage/{z}/{x}/{y}.png', + }), + mock({ + id: 'mockHealthDistricts', + name: i18n.t('Health districts (proposed 2026)'), + description: i18n.t( + 'Draft redistricting proposal under consultation - not for official reporting.' + ), + type: WMS_LAYER, + url: 'https://example.org/geoserver/wms', + }), + mock({ + id: 'mockClinicRefs', + name: i18n.t('Referral routes'), + description: i18n.t( + 'Ambulance referral corridors between health centres and district hospitals.' + ), + url: 'https://example.org/tiles/referrals/{z}/{x}/{y}.png', + }), +] + +// Shaped like createExternalBasemapLayer() output, plus the placement tag the +// catalog filters on. No `img`, so they render the "External basemap" +// placeholder tile on the Basemap card, like real external basemaps do. +const mockBasemap = ({ id, name, description, type = TILE_LAYER, url }) => ({ + layer: EXTERNAL_LAYER, + id, + name, + description, + placement: PLACEMENT_BASEMAP, + config: { + id, + type, + name, + url, + tms: false, + format: 'image/png', + }, +}) + +export const mockBasemapSources = () => [ + mockBasemap({ + id: 'mockOrthophoto22', + name: i18n.t('National orthophoto 2022'), + description: i18n.t( + 'Aerial imagery flown at 25cm resolution by the national mapping agency.' + ), + type: WMS_LAYER, + url: 'https://example.org/geoserver/wms', + }), + mockBasemap({ + id: 'mockDarkMatter', + name: i18n.t('Dark cartographic base'), + description: i18n.t( + 'Low contrast dark basemap, intended as a backdrop for bright thematic layers.' + ), + url: 'https://example.org/tiles/dark/{z}/{x}/{y}.png', + }), + mockBasemap({ + id: 'mockVectorStreets', + name: i18n.t('Vector streets'), + description: i18n.t( + 'Vector tile street map with labels in the national languages.' + ), + type: VECTOR_STYLE, + url: 'https://example.org/styles/streets.json', + }), +] diff --git a/src/hooks/prototypeStore.js b/src/hooks/prototypeStore.js new file mode 100644 index 0000000000..c263682859 --- /dev/null +++ b/src/hooks/prototypeStore.js @@ -0,0 +1,36 @@ +// PROTOTYPE ONLY - a minimal localStorage-backed store with subscribers, so +// sibling components (the Add layer button and the Basemap card) see each +// other's changes without a remount. The real thing belongs in the dataStore, +// next to the Earth Engine allow-list managed by useManagedLayerSourcesStore. +export const createPrototypeStore = ({ key, initial }) => { + const read = () => { + try { + const stored = JSON.parse(window.localStorage.getItem(key)) + return stored === null ? initial : { ...initial, ...stored } + } catch (error) { + return initial + } + } + + let state = read() + const listeners = new Set() + + const get = () => state + + const set = (updater) => { + state = typeof updater === 'function' ? updater(state) : updater + try { + window.localStorage.setItem(key, JSON.stringify(state)) + } catch (error) { + // ignore - prototype only + } + listeners.forEach((listener) => listener()) + } + + const subscribe = (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + + return { get, set, subscribe } +} diff --git a/src/hooks/useAddedLayerSources.js b/src/hooks/useAddedLayerSources.js new file mode 100644 index 0000000000..804ea5b0fc --- /dev/null +++ b/src/hooks/useAddedLayerSources.js @@ -0,0 +1,26 @@ +import { useCallback, useSyncExternalStore } from 'react' +import { createPrototypeStore } from './prototypeStore.js' + +// PROTOTYPE ONLY - sources registered through the manage dialog. The real +// thing would POST to externalMapLayers and come back through useCachedData. +const store = createPrototypeStore({ + key: 'maps-prototype-added-layer-sources', + initial: { sources: [] }, +}) + +const useAddedLayerSources = () => { + const state = useSyncExternalStore(store.subscribe, store.get) + + const addSource = useCallback( + (source) => + store.set((prev) => ({ + ...prev, + sources: [...prev.sources, source], + })), + [] + ) + + return { addedSources: state.sources, addSource } +} + +export default useAddedLayerSources diff --git a/src/hooks/useBasemapConfig.js b/src/hooks/useBasemapConfig.js index 86d75355da..fa6f544c72 100644 --- a/src/hooks/useBasemapConfig.js +++ b/src/hooks/useBasemapConfig.js @@ -2,12 +2,16 @@ import { useState, useEffect } from 'react' import { useCachedData } from '../components/cachedDataProvider/CachedDataProvider.jsx' import { getFallbackBasemap } from '../constants/basemaps.js' import { defaultBasemapState } from '../reducers/map.js' +import useCatalogBasemaps from './useCatalogBasemaps.js' const emptyBasemap = { config: {} } function useBasemapConfig(selected) { const [basemap, setBasemap] = useState(emptyBasemap) - const { systemSettings, basemaps } = useCachedData() + const { systemSettings } = useCachedData() + // PROTOTYPE ONLY - includes mock and session-added basemaps, so picking one + // resolves instead of silently falling back to the default + const basemaps = useCatalogBasemaps() const defaultBasemap = systemSettings.keyDefaultBaseMap useEffect(() => { diff --git a/src/hooks/useCatalogBasemaps.js b/src/hooks/useCatalogBasemaps.js new file mode 100644 index 0000000000..cb14830dbc --- /dev/null +++ b/src/hooks/useCatalogBasemaps.js @@ -0,0 +1,32 @@ +import { useMemo } from 'react' +import { useCachedData } from '../components/cachedDataProvider/CachedDataProvider.jsx' +import { mockBasemapSources } from '../constants/mockLayerSources.js' +import { + getLayerSourcePlacement, + PLACEMENT_BASEMAP, +} from '../util/layerSources.js' +import useAddedLayerSources from './useAddedLayerSources.js' + +// PROTOTYPE ONLY - every basemap an author can pick: the ones the API gave us, +// plus the mocks, plus any registered through the manage dialog this session. +// Both the Basemap card and useBasemapConfig read this, so a picked basemap +// resolves to the right name and config rather than falling back to the +// default. Memoised because useBasemapConfig has it in an effect dependency. +const useCatalogBasemaps = () => { + const { basemaps } = useCachedData() + const { addedSources } = useAddedLayerSources() + + return useMemo( + () => [ + ...basemaps, + ...mockBasemapSources(), + ...addedSources.filter( + (source) => + getLayerSourcePlacement(source) === PLACEMENT_BASEMAP + ), + ], + [basemaps, addedSources] + ) +} + +export default useCatalogBasemaps diff --git a/src/hooks/useLayerCatalogPrefs.js b/src/hooks/useLayerCatalogPrefs.js new file mode 100644 index 0000000000..0d9e955c97 --- /dev/null +++ b/src/hooks/useLayerCatalogPrefs.js @@ -0,0 +1,65 @@ +import { useCallback, useSyncExternalStore } from 'react' +import { DEFAULT_PINNED_IDS } from '../util/layerSources.js' +import { createPrototypeStore } from './prototypeStore.js' + +// PROTOTYPE ONLY - pinned layer sources, and the enabled/disabled state for +// built-in and external sources, kept in localStorage so it survives a reload +// while testing. The real thing belongs in the dataStore, next to the Earth +// Engine allow-list managed by useManagedLayerSourcesStore. +const store = createPrototypeStore({ + key: 'maps-prototype-layer-catalog', + initial: { pinned: DEFAULT_PINNED_IDS, disabled: [] }, +}) + +const toggle = (field, id) => + store.set((prev) => ({ + ...prev, + [field]: prev[field].includes(id) + ? prev[field].filter((item) => item !== id) + : [...prev[field], id], + })) + +// Move a pinned id to the position of another one, keeping the rest in order +const reorder = (activeId, overId) => + store.set((prev) => { + const oldIndex = prev.pinned.indexOf(activeId) + const newIndex = prev.pinned.indexOf(overId) + + if (oldIndex === -1 || newIndex === -1) { + return prev + } + + const pinned = [...prev.pinned] + pinned.splice(newIndex, 0, ...pinned.splice(oldIndex, 1)) + + return { ...prev, pinned } + }) + +const useLayerCatalogPrefs = () => { + const state = useSyncExternalStore(store.subscribe, store.get) + + const togglePinned = useCallback((id) => toggle('pinned', id), []) + const toggleDisabled = useCallback((id) => toggle('disabled', id), []) + const reorderPinned = useCallback( + (activeId, overId) => reorder(activeId, overId), + [] + ) + + return { + pinnedIds: state.pinned, + disabledIds: state.disabled, + isPinned: useCallback( + (id) => state.pinned.includes(id), + [state.pinned] + ), + isDisabled: useCallback( + (id) => state.disabled.includes(id), + [state.disabled] + ), + togglePinned, + toggleDisabled, + reorderPinned, + } +} + +export default useLayerCatalogPrefs diff --git a/src/util/getDefaultLayerTypes.js b/src/util/getDefaultLayerTypes.js index 2c7e85db82..4a1df7353a 100644 --- a/src/util/getDefaultLayerTypes.js +++ b/src/util/getDefaultLayerTypes.js @@ -11,12 +11,18 @@ export const getDefaultLayerTypes = () => [ { layer: THEMATIC_LAYER, type: i18n.t('Thematic'), + description: i18n.t( + 'Org units shaded or sized by an aggregate data value.' + ), img: 'images/thematic.png', opacity: 0.9, }, { layer: EVENT_LAYER, type: i18n.t('Events'), + description: i18n.t( + 'Event data collected by a program, plotted at its location.' + ), img: 'images/events.png', opacity: 0.8, eventClustering: true, @@ -24,18 +30,23 @@ export const getDefaultLayerTypes = () => [ { layer: TRACKED_ENTITY_LAYER, type: i18n.t('Tracked entities'), + description: i18n.t( + 'Tracked entities enrolled in a program, plotted at their location.' + ), img: 'images/trackedentities.png', opacity: 0.5, }, { layer: FACILITY_LAYER, type: i18n.t('Facilities'), + description: i18n.t('Health facilities plotted at their location.'), img: 'images/facilities.png', opacity: 1, }, { layer: ORG_UNIT_LAYER, type: i18n.t('Org units'), + description: i18n.t('Organisation unit boundaries or locations.'), img: 'images/orgunits.png', opacity: 1, }, diff --git a/src/util/layerSources.js b/src/util/layerSources.js index 2a24f93486..6acec58cdf 100644 --- a/src/util/layerSources.js +++ b/src/util/layerSources.js @@ -1,4 +1,21 @@ +import i18n from '@dhis2/d2-i18n' import { getEarthEngineLayer } from '../constants/earthEngineLayers/index.js' +import { + THEMATIC_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, + FACILITY_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + EXTERNAL_LAYER, + GEOJSON_URL_LAYER, + TILE_LAYER, + WMS_LAYER, + GEOJSON_LAYER, + VECTOR_STYLE, + BING_LAYER, + AZURE_LAYER, +} from '../constants/layers.js' export const resolveGroupKey = (layer) => { return ( @@ -91,3 +108,205 @@ export const groupLayerSources = (layers) => { return groupedArray } + +/* ------------------------------------------------------------------------- * + * Layer catalog helpers (pinned zone + filterable list) + * ------------------------------------------------------------------------- */ + +// Identity for one entry in the displayed catalog. Grouped Earth Engine entries +// produced by groupLayerSources() carry their own `id`; everything else falls +// back to resolveGroupKey (layerId / config.id / layer). +export const getLayerSourceId = (entry) => entry?.id ?? resolveGroupKey(entry) + +// Built-in layer types carry `type`, everything else carries `name` +export const getLayerSourceLabel = (entry) => entry?.name || entry?.type || '' + +// Grouped entries carry no description of their own - the text lives on the +// individual layers inside, so fall back to the first descendant that has one +const findNestedDescription = (entry) => { + for (const item of entry?.items ?? []) { + if (item?.description) { + return item.description + } + const nested = findNestedDescription(item) + if (nested) { + return nested + } + } + return '' +} + +export const getLayerSourceDescription = (entry) => { + const own = [entry?.description, entry?.descriptionComplement] + .filter(Boolean) + .join(' ') + return own || findNestedDescription(entry) +} + +export const KIND_BUILT_IN = 'builtIn' +export const KIND_EARTH_ENGINE = 'earthEngine' +export const KIND_EXTERNAL = 'external' + +export const getLayerSourceKind = (entry) => { + // Grouped entries only ever come from Earth Engine grouping + if (entry?.items || entry?.layer === EARTH_ENGINE_LAYER) { + return KIND_EARTH_ENGINE + } + if (entry?.layer === EXTERNAL_LAYER || entry?.layer === GEOJSON_URL_LAYER) { + return KIND_EXTERNAL + } + return KIND_BUILT_IN +} + +export const getLayerSourceKindLabel = (kind) => + ({ + [KIND_BUILT_IN]: i18n.t('Built-in'), + [KIND_EARTH_ENGINE]: i18n.t('Earth Engine'), + [KIND_EXTERNAL]: i18n.t('External data'), + }[kind] || kind) + +// PROTOTYPE ONLY - placement is orthogonal to kind: OSM Light is built-in + +// basemap, a registered WMS basemap is external + basemap. Entries are tagged +// with `placement` where the catalog composes them; anything untagged is an +// overlay, which is what every pre-existing caller assumes. +export const PLACEMENT_OVERLAY = 'overlay' +export const PLACEMENT_BASEMAP = 'basemap' + +export const getLayerSourcePlacement = (entry) => + entry?.placement ?? PLACEMENT_OVERLAY + +export const getLayerSourcePlacementLabel = (placement) => + ({ + [PLACEMENT_OVERLAY]: i18n.t('Overlay'), + [PLACEMENT_BASEMAP]: i18n.t('Basemap'), + }[placement] || placement) + +// The 5 built-in layer types are pinned by default +export const DEFAULT_PINNED_IDS = [ + THEMATIC_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, + FACILITY_LAYER, + ORG_UNIT_LAYER, +] + +const getSearchableText = (entry) => + [ + getLayerSourceLabel(entry), + getLayerSourceDescription(entry), + // Match on the contents of a group too, so filtering for "rainfall" + // still surfaces the Precipitation group that contains it + ...(entry?.items ?? []).flatMap((sub) => [ + getLayerSourceLabel(sub), + getLayerSourceDescription(sub), + ...(sub?.items ?? []).flatMap((layer) => [ + getLayerSourceLabel(layer), + getLayerSourceDescription(layer), + ]), + ]), + ].join(' ') + +export const matchesLayerSourceFilter = (entry, filter) => { + const needle = filter?.trim().toLowerCase() + if (!needle) { + return true + } + return getSearchableText(entry).toLowerCase().includes(needle) +} + +export const getLayerSourceDataTest = (label) => + `addlayeritem-${String(label).toLowerCase().replaceAll(/\s/g, '_')}` + +// Id used by the manage dialog, where Earth Engine layers are listed +// individually rather than collapsed into their group. Deliberately skips the +// grouping branch of resolveGroupKey so sibling layers stay distinct. +// Built-in basemaps carry none of the first three and fall back to `id`. +export const getManagedLayerSourceId = (entry) => + entry?.layerId ?? entry?.config?.id ?? entry?.layer ?? entry?.id + +// Short type label for external layers, derived from the config the +// externalMapLayers endpoint already gives us +const EXTERNAL_TYPE_LABELS = { + [TILE_LAYER]: i18n.t('XYZ tiles'), + [WMS_LAYER]: i18n.t('WMS'), + [GEOJSON_LAYER]: i18n.t('GeoJSON'), + [VECTOR_STYLE]: i18n.t('Vector style'), + [BING_LAYER]: i18n.t('Bing'), + [AZURE_LAYER]: i18n.t('Azure'), +} + +const getUrlHost = (url) => { + try { + return new URL(url).host + } catch { + return '' + } +} + +// Grouped entries carry no fields of their own, same as descriptions - fall +// back to the first descendant that has something to say +const findNestedMeta = (entry) => { + for (const item of entry?.items ?? []) { + const meta = getLayerSourceMeta(item) + if (meta.length) { + return meta + } + } + return [] +} + +// Per-kind metadata for a catalog row. Everything here is already present on +// the layer definitions - no extra requests, no backend changes. +export const getLayerSourceMeta = (entry) => { + const kind = getLayerSourceKind(entry) + + if (kind === KIND_EARTH_ENGINE) { + const { source, resolution = {}, unit, periodType } = entry ?? {} + const own = [ + source && { label: i18n.t('Source'), value: source }, + resolution.spatial && { + label: i18n.t('Resolution'), + value: resolution.spatial, + }, + resolution.temporal && { + label: i18n.t('Updated'), + value: resolution.temporal, + }, + resolution.temporalCoverage && { + label: i18n.t('Coverage'), + value: resolution.temporalCoverage, + }, + unit && { label: i18n.t('Unit'), value: unit }, + !resolution.temporal && + periodType && { + label: i18n.t('Period'), + value: periodType, + }, + ].filter(Boolean) + + return own.length ? own : findNestedMeta(entry) + } + + // Any entry with a renderable config gets Service/Host chips - that covers + // external layers and built-in basemaps alike + if (entry?.config?.type) { + // Deliberately skips config.attribution - it's raw HTML meant for the + // map's attribution control, not display text, so it isn't safe to + // render here + const { type, url, tms } = entry?.config ?? {} + const host = getUrlHost(url) + return [ + type && { + label: i18n.t('Service'), + // TMS and XYZ both become TILE_LAYER - only config.tms tells them apart + value: + type === TILE_LAYER && tms + ? i18n.t('TMS tiles') + : EXTERNAL_TYPE_LABELS[type] || type, + }, + host && { label: i18n.t('Host'), value: host }, + ].filter(Boolean) + } + + return [] +}