From 8eaf84d8f5d9924d52a9b694955667a137a41a95 Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Fri, 10 Jul 2026 09:16:51 +0100 Subject: [PATCH 01/11] IM-372 added a menu reducer for user configured menus --- demo/js/esri-datasets.js | 138 +++++++++++++++--- .../src/initialise/initialiseDatasets.js | 3 + .../beta/datasets/src/reducers/pluginState.js | 21 ++- 3 files changed, 137 insertions(+), 25 deletions(-) diff --git a/demo/js/esri-datasets.js b/demo/js/esri-datasets.js index c3c90202..43407fc5 100644 --- a/demo/js/esri-datasets.js +++ b/demo/js/esri-datasets.js @@ -8,10 +8,10 @@ import { vtsMapStyles27700 } from './mapStyles.js' import { transformGeocodeRequest, transformVtsRequest3857, setupEsriConfig } from './auth.js' const datasetFloodZonesCC = { - id: 'flood-zones-cc', + id: 'floodzonescc', label: 'Flood Zones Climate Change', groupLabel: 'Datasets', - esriGroupId: 'flood-zones', + esriGroupId: 'floodzones-group', tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Zones_2_and_3_Rivers_and_Sea_CCP1_NON_PRODUCTION/VectorTileServer`, showInKey: true, showInMenu: true, @@ -24,6 +24,11 @@ const datasetFloodZonesCC = { esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Flood Zones plus climate change/1', showInKey: true, showInMenu: false, + visibleWhen: { + menu: { + dataset: ['floodzones'], timeframe: ['climatechange'] + } + }, style: { fill: { outdoor: '#F4A582', dark: '#BF3D4A' }, stroke: 'none' @@ -32,26 +37,37 @@ const datasetFloodZonesCC = { { id: 'data-unavailable', label: 'Climate change data unavailable', - style: { // This is used just for the key - so that it renders the pattern correctly. + showInKey: true, + showInMenu: false, + visibleWhen: { + menu: { + dataset: ['floodzones'], timeframe: ['climatechange'] + } + }, + style: { // This is used just for the key - so that it renders the pattern correctly. fillPattern: 'dot', fillPatternForegroundColor: { outdoor: '#000000', dark: '#ffffff' }, stroke: { outdoor: '#000000', dark: '#FFFFFF' }, - }, - showInKey: true, - showInMenu: false, + } }, { id: 'data-unavailable-outline', + showInKey: false, + showInMenu: false, + visibleWhen: { + menu: { dataset: ['floodzones'], timeframe: ['climatechange'] } + }, style: { stroke: { outdoor: '#000000', dark: '#FFFFFF' }, }, - esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/0', - showInKey: false, - showInMenu: false, + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/0' }, { id: 'data-unavailable-light', - visibleWhen: { mapStyleId: ['outdoor', 'black-and-white'] }, + visibleWhen: { + mapStyleId: ['outdoor', 'black-and-white'], + menu: { dataset: ['floodzones'], timeframe: ['climatechange'] } + }, esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/1', esriUseServerStyle: true, showInKey: false, @@ -59,7 +75,10 @@ const datasetFloodZonesCC = { }, { id: 'data-unavailable-dark', - visibleWhen: { mapStyleId: ['dark'] }, + visibleWhen: { + menu: { dataset: ['floodzones'], timeframe: ['climatechange'] }, + mapStyleId: ['dark'] + }, esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/2', esriUseServerStyle: true, showInKey: false, @@ -69,14 +88,17 @@ const datasetFloodZonesCC = { } const datasetFloodZones = { - id: 'flood-zones', + id: 'floodzones', label: 'Flood Zones', groupLabel: 'Datasets', - esriGroupId: 'flood-zones', + esriGroupId: 'floodzones-group', tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Zones_2_and_3_Rivers_and_Sea_NON_PRODUCTION/VectorTileServer`, showInKey: true, // showInMenu: true, sourceLayer: 'Flood Zones 2 and 3 Rivers and Sea', + visibleWhen: { + menu: { dataset: ['floodzones'] } + }, sublayers: [ { id: 'flood-zone-2', @@ -105,13 +127,87 @@ const datasets = [ datasetFloodZonesCC, datasetFloodZones ] +const menu = [ + { + id: 'dataset', + label: 'Datasets', + urlKey: 'dataset', + visibleWhen: true, + type: 'radio', // 'checkbox' or 'radio' + value: 'floodzones', // this is the default value for the menu, it should be one of the items' id + items: [ + { id: 'floodzones', label: 'Flood zones' }, + { id: 'surfacewater', label: 'Surface water' }, + { id: 'none', label: 'None', }, + ], + }, + { + id: 'timeframe', + label: 'Timeframe', + urlKey: 'dataset', + urlIndex: 1, // eg: surfacewater-presentday-high-depth or floodzones-climatechange + type: 'radio', + visibleWhen: { dataset: ['floodzones', 'surfacewater'] }, + value: 'presentday', + items: [ + { id: 'presentday', label: 'Present day' }, + { id: 'climatechange', label: '2070 to 2125', visibleWhen: { dataset: ['floodzones'] } }, + { id: 'climatechange', label: '2061 to 2125', visibleWhen: { dataset: ['surfacewater'] } }, + ] + }, { + id: 'aep', + label: 'Annual likelihood of flooding', + urlKey: 'dataset', + urlIndex: 2, + type: 'radio', + visibleWhen: { dataset: ['surfacewater'] }, + value: 'medium', + items: [ + { id: 'high', label: '1 in 30' }, + { id: 'medium', label: '1 in 100' }, + { id: 'low', label: '1 in 1000' }, + ] + }, { + id: 'depth', + label: 'Depth', + urlKey: 'dataset', + urlIndex: 3, + type: 'radio', + visibleWhen: { dataset: ['surfacewater'] }, + subMenu: true, + value: 'depthAll', + items: [ + { id: 'depthAll', label: 'All depths', }, + { id: 'depth150', label: 'Full extent of flooding', }, + { id: 'depth300', label: 'Extent over 150mm', }, + { id: 'depth600', label: 'Extent over 300mm', }, + { id: 'depth900', label: 'Extent over 600mm', }, + { id: 'depth1200', label: 'Extent over 900mm', }, + { id: 'depth2300', label: 'Extent over 1200mm', }, + { id: 'depthOver2300', label: 'Extent over 2300mm', }, + ] + }, { + id: 'features', + label: 'Map features', + urlKey: 'features', + type: 'checkbox', + visibleWhen: true, + items: [ + { id: 'water-storage', label: 'Water storage', checked: false }, + { id: 'flood-defence', label: 'Flood defence', checked: false }, + { id: 'main-rivers', label: 'Main rivers', checked: false }, + ] + } +] + const datasetsPlugin = createDatasetsPlugin({ globals: { opacityMode: 'global', // 'dataset', 'global' or 'multiply' opacity: 0.75, visible: true }, - datasets + datasets, + menu }) const interactiveMap = new InteractiveMap('map', { @@ -132,8 +228,8 @@ const interactiveMap = new InteractiveMap('map', { desktop: { slot: 'right-top', showLabel: true } }], panels: [ - { - id: 'mapStyles', + { + id: 'mapStyles', desktop: { slot: 'map-styles-button', width: '400px', modal: true } } ] @@ -147,9 +243,13 @@ const testGlobalVisibility = () => { setTimeout(() => datasetsPlugin.setDatasetVisibility(true), 6000) } +const testAddRemoveDataset = () => { + setTimeout(() => datasetsPlugin.removeDataset('floodzonescc'), 1000) + setTimeout(() => datasetsPlugin.removeDataset('floodzones'), 3000) + setTimeout(() => datasetsPlugin.addDataset(datasetFloodZones), 5000) +} + interactiveMap.on('datasets:ready', function () { // testGlobalVisibility() - setTimeout(() => datasetsPlugin.removeDataset('flood-zones-cc'), 1000) - setTimeout(() => datasetsPlugin.removeDataset('flood-zones'), 3000) - setTimeout(() => datasetsPlugin.addDataset(datasetFloodZones), 5000) + // testAddRemoveDataset() }) \ No newline at end of file diff --git a/plugins/beta/datasets/src/initialise/initialiseDatasets.js b/plugins/beta/datasets/src/initialise/initialiseDatasets.js index ecd97f1f..9c11db3d 100644 --- a/plugins/beta/datasets/src/initialise/initialiseDatasets.js +++ b/plugins/beta/datasets/src/initialise/initialiseDatasets.js @@ -3,6 +3,7 @@ import { createDynamicSource } from '../fetch/createDynamicSource.js' import { applyDatasetDefaults, datasetDefaults } from './defaults.js' import { mappedDatasetsReducer } from '../reducers/mappedDatasetsReducer.js' import { datasetRegistry } from '../registry/datasetRegistry.js' +import { datasetsToMenu } from '../reducers/datasetsToMenu.js' export const initialiseDatasets = ({ adapter, @@ -44,6 +45,8 @@ export const initialiseDatasets = ({ adapter.attachDynamicSources(dynamicSources) // TODO - apply dynamic source defaults here, and include in mappedDatasets dispatch({ type: 'SET_DATASETS', payload: { datasets: processedDatasets, mappedDatasets, orderedDatasets } }) + const menu = pluginConfig.menu || datasetsToMenu({ datasets: processedDatasets }) + dispatch({ type: 'SET_MENU', payload: { menu } }) eventBus.emit('datasets:ready') }) diff --git a/plugins/beta/datasets/src/reducers/pluginState.js b/plugins/beta/datasets/src/reducers/pluginState.js index cee81ca7..011b88c1 100755 --- a/plugins/beta/datasets/src/reducers/pluginState.js +++ b/plugins/beta/datasets/src/reducers/pluginState.js @@ -15,7 +15,8 @@ const initialState = { items: [], hasGroups: false }, - actionsArray: [] + actionsArray: [], + menu: [] } const validateDatasetExists = (state, datasetId, prefix, suffix = 'not found') => { @@ -41,14 +42,21 @@ const setGlobalState = (state, payload) => { }) } +const setMenu = (state, payload) => { + const { menu } = payload + return { + ...state, + menu + } +} + const setDatasets = (state, payload) => { - const { datasets, mappedDatasets, orderedDatasets } = payload - const menu = payload.menu || datasetsToMenu({ datasets }) + const { mappedDatasets, orderedDatasets } = payload + return { ...state, mappedDatasets, - orderedDatasets, - menu + orderedDatasets } } @@ -197,7 +205,8 @@ const actions = { HIDE_FEATURES: hideFeatures, SHOW_FEATURES: showFeatures, REMOVE_ADAPTER_ACTIONS: removeAdapterActions, - SET_GLOBAL_STATE: setGlobalState + SET_GLOBAL_STATE: setGlobalState, + SET_MENU: setMenu } export { From 8044a658dcb222868f6ff3f7fee39ae82a148ae4 Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Mon, 13 Jul 2026 09:47:40 +0100 Subject: [PATCH 02/11] IM-372 removed fill: 'transparent' from defaults --- plugins/beta/datasets/src/initialise/defaults.js | 1 - plugins/beta/datasets/src/initialise/defaults.test.js | 1 - 2 files changed, 2 deletions(-) diff --git a/plugins/beta/datasets/src/initialise/defaults.js b/plugins/beta/datasets/src/initialise/defaults.js index 7153f2dd..62b6e66e 100644 --- a/plugins/beta/datasets/src/initialise/defaults.js +++ b/plugins/beta/datasets/src/initialise/defaults.js @@ -7,7 +7,6 @@ const datasetDefaults = { style: { stroke: '#d4351c', strokeWidth: 2, - fill: 'transparent', symbolDescription: 'red outline' } } diff --git a/plugins/beta/datasets/src/initialise/defaults.test.js b/plugins/beta/datasets/src/initialise/defaults.test.js index 626add11..6e25a271 100644 --- a/plugins/beta/datasets/src/initialise/defaults.test.js +++ b/plugins/beta/datasets/src/initialise/defaults.test.js @@ -15,7 +15,6 @@ describe('datasetDefaults', () => { expect(datasetDefaults.style).toMatchObject({ stroke: '#d4351c', strokeWidth: 2, - fill: 'transparent', symbolDescription: 'red outline' }) }) From 15170075326d77784e8b562fc031bb2594a5d066 Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Mon, 13 Jul 2026 09:49:11 +0100 Subject: [PATCH 03/11] IM-372 updated pluginState test to refclect changed menu --- plugins/beta/datasets/src/reducers/pluginState.test.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/beta/datasets/src/reducers/pluginState.test.js b/plugins/beta/datasets/src/reducers/pluginState.test.js index 9a608ef8..c0d92753 100644 --- a/plugins/beta/datasets/src/reducers/pluginState.test.js +++ b/plugins/beta/datasets/src/reducers/pluginState.test.js @@ -54,13 +54,12 @@ describe('SET_DATASETS', () => { const payload = { datasets: [{ id: 'parks', label: 'Parks', showInMenu: true }], mappedDatasets: { parks: { id: 'parks', label: 'Parks' } }, - orderedDatasets: ['parks'], - menu: [{ id: 'parks', label: 'Parks', type: 'item' }] + orderedDatasets: ['parks'] } const result = actions.SET_DATASETS(state, payload) expect(result.mappedDatasets).toEqual(payload.mappedDatasets) expect(result.orderedDatasets).toEqual(payload.orderedDatasets) - expect(result.menu).toEqual(payload.menu) + expect(result.menu).toEqual([]) }) it('derives menu from datasets when menu is not in payload', () => { From f158c848bc1b8f23794d60226e2775f5ac6bb06b Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Mon, 13 Jul 2026 09:50:27 +0100 Subject: [PATCH 04/11] IM-372 ensure adapter visibility change not triggered, when state doesn't change --- plugins/beta/datasets/src/reducers/pluginState.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/beta/datasets/src/reducers/pluginState.js b/plugins/beta/datasets/src/reducers/pluginState.js index 011b88c1..b4ab1641 100755 --- a/plugins/beta/datasets/src/reducers/pluginState.js +++ b/plugins/beta/datasets/src/reducers/pluginState.js @@ -101,6 +101,9 @@ const setDatasetVisibility = (state, payload) => { if (!validateDatasetExists(state, datasetId, 'setDatasetVisibility')) { return state } + if (state.mappedDatasets[datasetId].visible === visible) { + return state + } return addAction('applyDatasetVisibility', [datasetId, visible], { ...state, From e64a735347cb3f861d7ea4c94202dc097470ad7b Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Mon, 13 Jul 2026 12:32:53 +0100 Subject: [PATCH 05/11] IM-372 added 1st SW datasets and an isVisibleWhen helper method --- demo/js/esri-datasets.js | 429 +++++++++++++----- .../datasets/src/initialise/DatasetsInit.jsx | 3 + .../src/initialise/initialiseDatasets.js | 7 +- plugins/beta/datasets/src/registry/dataset.js | 7 +- .../datasets/src/registry/isVisibleWhen.js | 62 +++ 5 files changed, 391 insertions(+), 117 deletions(-) create mode 100644 plugins/beta/datasets/src/registry/isVisibleWhen.js diff --git a/demo/js/esri-datasets.js b/demo/js/esri-datasets.js index 43407fc5..7934364d 100644 --- a/demo/js/esri-datasets.js +++ b/demo/js/esri-datasets.js @@ -7,124 +7,327 @@ import createDatasetsPlugin from '/plugins/beta/datasets/src/index.js' import { vtsMapStyles27700 } from './mapStyles.js' import { transformGeocodeRequest, transformVtsRequest3857, setupEsriConfig } from './auth.js' +const nonFloodZoneLight = '#2b8cbe' +const nonFloodZoneDark = '#7fcdbb' + +const COLOURS = { + // floodExtents: { default: nonFloodZoneLight, dark: nonFloodZoneDark }, + + depthOver2300: { default: '#7f2704', dark: '#238b45' }, + depth2300: { default: '#a63603', dark: '#41ab5d' }, + depth1200: { default: '#d94801', dark: '#74c476' }, + depth900: { default: '#f16913', dark: '#a1d99b' }, + depth600: { default: '#fd8d3c', dark: '#c7e9c0' }, + depth300: { default: '#fdae6b', dark: '#e5f5e0' }, + depth150: { default: '#fdd0a2', dark: '#f7fcf5' }, + + floodZone3: { default: '#003078', dark: '#e5f5e0' }, + floodZone2: { default: '#1d70b8', dark: '#41ab5d' }, + floodZoneClimateChange: { default: '#F4A582', dark: '#BF3D4A' }, + // floodZoneClimateChangeNoData: { default: darkTeal, dark: white }, + + floodDefences: { default: '#f47738', dark: '#f47738' }, + // waterStorageAreas: { default: darkTeal, dark: white }, + // mainRivers: { default: darkTeal, dark: white } +} + +// light tones > 2300 to < 150 +const nonFloodZoneDepthBandsLight = [COLOURS.depthOver2300.default, COLOURS.depth2300.default, COLOURS.depth1200.default, COLOURS.depth900.default, COLOURS.depth600.default, COLOURS.depth300.default, COLOURS.depth150.default] +// GREENS dark tones > 2300 to < 150 +const nonFloodZoneDepthBandsDark = [COLOURS.depthOver2300.dark, COLOURS.depth2300.dark, COLOURS.depth1200.dark, COLOURS.depth900.dark, COLOURS.depth600.dark, COLOURS.depth300.dark, COLOURS.depth150.dark] + + + const datasetFloodZonesCC = { - id: 'floodzonescc', - label: 'Flood Zones Climate Change', - groupLabel: 'Datasets', - esriGroupId: 'floodzones-group', - tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Zones_2_and_3_Rivers_and_Sea_CCP1_NON_PRODUCTION/VectorTileServer`, - showInKey: true, - showInMenu: true, - visible: true, - sourceLayer: 'Flood Zones 2 and 3 Rivers and Sea CCP1', - sublayers: [ - { - id: 'climate-change', - label: 'Climate change (2070 to 2125)', - esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Flood Zones plus climate change/1', - showInKey: true, - showInMenu: false, - visibleWhen: { - menu: { - dataset: ['floodzones'], timeframe: ['climatechange'] - } - }, - style: { - fill: { outdoor: '#F4A582', dark: '#BF3D4A' }, - stroke: 'none' - }, - }, - { - id: 'data-unavailable', - label: 'Climate change data unavailable', - showInKey: true, - showInMenu: false, - visibleWhen: { - menu: { - dataset: ['floodzones'], timeframe: ['climatechange'] - } - }, - style: { // This is used just for the key - so that it renders the pattern correctly. - fillPattern: 'dot', - fillPatternForegroundColor: { outdoor: '#000000', dark: '#ffffff' }, - stroke: { outdoor: '#000000', dark: '#FFFFFF' }, + id: 'floodzonescc', + label: 'Flood Zones Climate Change', + groupLabel: 'Datasets', + esriGroupId: 'floodzones-group', + tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Zones_2_and_3_Rivers_and_Sea_CCP1_NON_PRODUCTION/VectorTileServer`, + showInKey: true, + showInMenu: true, + visible: true, + sourceLayer: 'Flood Zones 2 and 3 Rivers and Sea CCP1', + sublayers: [ + { + id: 'climate-change', + label: 'Climate change (2070 to 2125)', + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Flood Zones plus climate change/1', + showInKey: true, + showInMenu: false, + visibleWhen: { + menu: { + dataset: ['floodzones'], timeframe: ['climatechange'] + } + }, + style: { + fill: { outdoor: '#F4A582', dark: '#BF3D4A' }, + stroke: 'none' + }, + }, + { + id: 'data-unavailable', + label: 'Climate change data unavailable', + showInKey: true, + showInMenu: false, + visibleWhen: { + menu: { + dataset: ['floodzones'], timeframe: ['climatechange'] } }, - { - id: 'data-unavailable-outline', - showInKey: false, - showInMenu: false, - visibleWhen: { - menu: { dataset: ['floodzones'], timeframe: ['climatechange'] } - }, - style: { - stroke: { outdoor: '#000000', dark: '#FFFFFF' }, - }, - esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/0' - }, - { - id: 'data-unavailable-light', - visibleWhen: { - mapStyleId: ['outdoor', 'black-and-white'], - menu: { dataset: ['floodzones'], timeframe: ['climatechange'] } - }, - esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/1', - esriUseServerStyle: true, - showInKey: false, - showInMenu: false, - }, - { - id: 'data-unavailable-dark', - visibleWhen: { - menu: { dataset: ['floodzones'], timeframe: ['climatechange'] }, - mapStyleId: ['dark'] - }, - esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/2', - esriUseServerStyle: true, - showInKey: false, - showInMenu: false, + style: { // This is used just for the key - so that it renders the pattern correctly. + fillPattern: 'dot', + fillPatternForegroundColor: { outdoor: '#000000', dark: '#ffffff' }, + stroke: { outdoor: '#000000', dark: '#FFFFFF' }, } - ] - } + }, + { + id: 'data-unavailable-outline', + showInKey: false, + showInMenu: false, + visibleWhen: { + menu: { dataset: ['floodzones'], timeframe: ['climatechange'] } + }, + style: { + stroke: { outdoor: '#000000', dark: '#FFFFFF' }, + }, + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/0' + }, + { + id: 'data-unavailable-light', + visibleWhen: { + mapStyleId: ['outdoor', 'black-and-white'], + menu: { dataset: ['floodzones'], timeframe: ['climatechange'] } + }, + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/1', + esriUseServerStyle: true, + showInKey: false, + showInMenu: false, + }, + { + id: 'data-unavailable-dark', + visibleWhen: { + menu: { dataset: ['floodzones'], timeframe: ['climatechange'] }, + mapStyleId: ['dark'] + }, + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/2', + esriUseServerStyle: true, + showInKey: false, + showInMenu: false, + } + ] +} - const datasetFloodZones = { - id: 'floodzones', - label: 'Flood Zones', - groupLabel: 'Datasets', - esriGroupId: 'floodzones-group', - tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Zones_2_and_3_Rivers_and_Sea_NON_PRODUCTION/VectorTileServer`, - showInKey: true, - // showInMenu: true, - sourceLayer: 'Flood Zones 2 and 3 Rivers and Sea', - visibleWhen: { - menu: { dataset: ['floodzones'] } - }, - sublayers: [ - { - id: 'flood-zone-2', - label: 'Flood Zone 2', - esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea/Flood Zone 2/1', - showInMenu: true, - style: { - fill: { outdoor: '#1d70b8', dark: '#7fcdbb' }, - stroke: 'none' - }, - }, - { - id: 'flood-zone-3', - label: 'Flood Zone 3', - esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea/Flood Zone 3/1', - showInMenu: true, - style: { - fill: { outdoor: '#003078', dark: '#e5f5e0' }, - stroke: 'none' - }, +const datasetFloodZones = { + id: 'floodzones', + label: 'Flood Zones', + groupLabel: 'Datasets', + esriGroupId: 'floodzones-group', + tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Zones_2_and_3_Rivers_and_Sea_NON_PRODUCTION/VectorTileServer`, + showInKey: true, + // showInMenu: true, + sourceLayer: 'Flood Zones 2 and 3 Rivers and Sea', + visibleWhen: { + menu: { dataset: ['floodzones'] } + }, + sublayers: [ + { + id: 'flood-zone-2', + label: 'Flood Zone 2', + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea/Flood Zone 2/1', + style: { + fill: { outdoor: '#1d70b8', dark: '#7fcdbb' }, + stroke: 'none' + }, + }, + { + id: 'flood-zone-3', + label: 'Flood Zone 3', + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea/Flood Zone 3/1', + style: { + fill: { outdoor: '#003078', dark: '#e5f5e0' }, + stroke: 'none' + }, + } + ] +} + +const surfaceWaterDataset = { + id: 'surfacewater', + label: 'Surface Water', + groupLabel: 'Datasets', + tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Surface_Water_Spatial_Planning_1_in_1000_Depths_NON_PRODUCTION/VectorTileServer`, + showInKey: true, + sourceLayer: 'Surface Water Spatial Planning 1 in 1000 Depths', + visibleWhen: { + menu: { + dataset: ['surfacewater'], + timeframe: ['presentday'], + aep: ['low'], + } + }, + sublayers: [ + { + id: 'depthOver2300', + label: 'Depth over 2300mm', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/>2300mm/1', + visibleWhen: { + menu: { + dataset: ['surfacewater'], + timeframe: ['presentday'], + aep: ['low'], + depth: ['depth150', 'depth300', 'depth600', 'depth900', 'depth1200', 'depth2300', 'depthOver2300'] + } + }, + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[0], dark: nonFloodZoneDepthBandsDark[0] }, } - ] - } + }, + { + id: 'depth2300', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/1200-2300mm/1', + visibleWhen: { + menu: { + dataset: ['surfacewater'], + timeframe: ['presentday'], + aep: ['low'], + depth: ['depth150', 'depth300', 'depth600', 'depth900', 'depth1200', 'depth2300'] + } + }, + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[1], dark: nonFloodZoneDepthBandsDark[1] }, + } + }, + { + id: 'depth1200', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/900-1200mm/1', + visibleWhen: { + menu: { + dataset: ['surfacewater'], + timeframe: ['presentday'], + aep: ['low'], + depth: ['depth150', 'depth300', 'depth600', 'depth900', 'depth1200'] + } + }, + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[2], dark: nonFloodZoneDepthBandsDark[2] }, + } + }, + { + id: 'depth900', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/600-900mm/1', + visibleWhen: { + menu: { + dataset: ['surfacewater'], + timeframe: ['presentday'], + aep: ['low'], + depth: ['depth150', 'depth300', 'depth600', 'depth900'] + } + }, + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[3], dark: nonFloodZoneDepthBandsDark[3] }, + } + }, + { + id: 'depth600', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/300-600mm/1', + visibleWhen: { + menu: { + dataset: ['surfacewater'], + timeframe: ['presentday'], + aep: ['low'], + depth: ['depth150', 'depth300', 'depth600'] + } + }, + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[4], dark: nonFloodZoneDepthBandsDark[4] }, + } + }, + { + id: 'depth300', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/150-300mm/1', + visibleWhen: { + menu: { + dataset: ['surfacewater'], + timeframe: ['presentday'], + aep: ['low'], + depth: ['depth150', 'depth300'] + } + }, + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[5], dark: nonFloodZoneDepthBandsDark[5] }, + } + }, + { + id: 'depth150', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/<150mm/1', + visibleWhen: { + menu: { + dataset: ['surfacewater'], + timeframe: ['presentday'], + aep: ['low'], + depth: ['depth150'] + } + }, + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[6], dark: nonFloodZoneDepthBandsDark[6] }, + } + }, + ] +} + +const surfaceWaterDepthAllDataset = { + id: 'surfacewaterDepthAll', + label: 'Surface Water Depth All', + groupLabel: 'Datasets', + tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Surface_Water_Spatial_Planning_1_in_1000_Depths_NON_PRODUCTION/VectorTileServer`, + showInKey: true, + sourceLayer: 'Surface Water Spatial Planning 1 in 1000 Depths', + style: { + fill: { outdoor: nonFloodZoneLight, dark: nonFloodZoneDark }, + }, + visibleWhen: { + menu: { + dataset: ['surfacewater'], + timeframe: ['presentday'], + aep: ['low'], + depth: ['depthAll'] + }, + }, + sublayers: [ + { + id: 'depthOver2300', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/>2300mm/1', + }, + { + id: 'depth2300', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/1200-2300mm/1', + }, + { + id: 'depth1200', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/900-1200mm/1', + }, + { + id: 'depth900', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/600-900mm/1', + }, + { + id: 'depth600', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/300-600mm/1', + }, + { + id: 'depth300', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/150-300mm/1', + }, + { + id: 'depth150', + esriStyleLayerId: 'Surface Water Spatial Planning 1 in 1000 Depths/<150mm/1', + } + ] +} const datasets = [ - datasetFloodZonesCC, datasetFloodZones + datasetFloodZonesCC, datasetFloodZones, surfaceWaterDataset, surfaceWaterDepthAllDataset ] const menu = [ @@ -137,7 +340,9 @@ const menu = [ value: 'floodzones', // this is the default value for the menu, it should be one of the items' id items: [ { id: 'floodzones', label: 'Flood zones' }, + { id: 'floodzonescc', label: 'Flood zones climate change' }, { id: 'surfacewater', label: 'Surface water' }, + { id: 'surfacewaterDepthAll', label: 'Surface water all depths' }, { id: 'none', label: 'None', }, ], }, @@ -148,10 +353,10 @@ const menu = [ urlIndex: 1, // eg: surfacewater-presentday-high-depth or floodzones-climatechange type: 'radio', visibleWhen: { dataset: ['floodzones', 'surfacewater'] }, - value: 'presentday', + value: 'climatechange', items: [ { id: 'presentday', label: 'Present day' }, - { id: 'climatechange', label: '2070 to 2125', visibleWhen: { dataset: ['floodzones'] } }, + { id: 'climatechange', label: '2070 to 2125', visibleWhen: { dataset: ['floodzones'], aep: ['medium', 'low'] } }, { id: 'climatechange', label: '2061 to 2125', visibleWhen: { dataset: ['surfacewater'] } }, ] }, { diff --git a/plugins/beta/datasets/src/initialise/DatasetsInit.jsx b/plugins/beta/datasets/src/initialise/DatasetsInit.jsx index 36964be1..09773937 100755 --- a/plugins/beta/datasets/src/initialise/DatasetsInit.jsx +++ b/plugins/beta/datasets/src/initialise/DatasetsInit.jsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react' import { EVENTS } from '../../../../../src/config/events.js' import { initialiseDatasets } from './initialiseDatasets.js' import { datasetRegistry } from '../registry/datasetRegistry.js' +import { setMenuState } from '../registry/isVisibleWhen.js' import { attachGlobalState } from '../registry/globalDataset.js' import { loadLayerAdapter, layerAdapter } from '../adapters/loadLayerAdapter.js' @@ -49,6 +50,8 @@ export function DatasetsInit ({ pluginConfig, pluginState, appState, mapState, m initDatasets() }, [isBaseMapReady, appState.mode]) + useEffect(() => setMenuState(pluginState.menu), [pluginState.menu]) + useEffect(() => datasetRegistry.attach(pluginState.mappedDatasets, pluginState.orderedDatasets), [pluginState.mappedDatasets, pluginState.orderedDatasets]) diff --git a/plugins/beta/datasets/src/initialise/initialiseDatasets.js b/plugins/beta/datasets/src/initialise/initialiseDatasets.js index 9c11db3d..4d2960db 100644 --- a/plugins/beta/datasets/src/initialise/initialiseDatasets.js +++ b/plugins/beta/datasets/src/initialise/initialiseDatasets.js @@ -3,6 +3,7 @@ import { createDynamicSource } from '../fetch/createDynamicSource.js' import { applyDatasetDefaults, datasetDefaults } from './defaults.js' import { mappedDatasetsReducer } from '../reducers/mappedDatasetsReducer.js' import { datasetRegistry } from '../registry/datasetRegistry.js' +import { setMenuState } from '../registry/isVisibleWhen.js' import { datasetsToMenu } from '../reducers/datasetsToMenu.js' export const initialiseDatasets = ({ @@ -29,7 +30,10 @@ export const initialiseDatasets = ({ datasetRegistry.attachCreateDataset(adapter.createDataset) } datasetRegistry.attach(mappedDatasets, orderedDatasets, mapStyle) - adapter.init(mapStyle).then(() => { + const menu = pluginConfig.menu || datasetsToMenu({ datasets: processedDatasets }) + setMenuState(menu) // Must be called before adapter.init so that _menuState is set before any datasets are checked for visibility + + adapter.init().then(() => { datasetRegistry.forEachDataset(registryDataset => { if (!registryDataset.hasDynamicGeoJSON) { return @@ -45,7 +49,6 @@ export const initialiseDatasets = ({ adapter.attachDynamicSources(dynamicSources) // TODO - apply dynamic source defaults here, and include in mappedDatasets dispatch({ type: 'SET_DATASETS', payload: { datasets: processedDatasets, mappedDatasets, orderedDatasets } }) - const menu = pluginConfig.menu || datasetsToMenu({ datasets: processedDatasets }) dispatch({ type: 'SET_MENU', payload: { menu } }) eventBus.emit('datasets:ready') }) diff --git a/plugins/beta/datasets/src/registry/dataset.js b/plugins/beta/datasets/src/registry/dataset.js index a8bd7423..36d1122d 100644 --- a/plugins/beta/datasets/src/registry/dataset.js +++ b/plugins/beta/datasets/src/registry/dataset.js @@ -1,4 +1,5 @@ import { datasetRegistry } from './datasetRegistry.js' +import { isVisibleWhen } from './isVisibleWhen.js' import { hasCustomVisualStyle } from '../initialise/defaults.js' import { hasPattern } from '../../../../../src/utils/patternUtils.js' import { DynamicGeoJson } from './dynamicGeoJson.js' @@ -96,9 +97,9 @@ export class Dataset { get esriStyleLayerId () { return this._datasetDefinition.esriStyleLayerId } get visibility () { - const visible = this.visible - if (visible && this.visibleWhen?.mapStyleId) { - return this.visibleWhen?.mapStyleId.includes(datasetRegistry.mapStyle.id) ? 'visible' : 'none' + const { visible, visibleWhen } = this + if (visible && visibleWhen) { + return isVisibleWhen(visibleWhen) ? 'visible' : 'none' } return visible ? 'visible' : 'none' } diff --git a/plugins/beta/datasets/src/registry/isVisibleWhen.js b/plugins/beta/datasets/src/registry/isVisibleWhen.js new file mode 100644 index 00000000..36f3ad67 --- /dev/null +++ b/plugins/beta/datasets/src/registry/isVisibleWhen.js @@ -0,0 +1,62 @@ +import { datasetRegistry } from './datasetRegistry.js' + +let _menuState = {} + +export const setMenuState = (menu) => { + _menuState = {} + menu.forEach(item => { + if (item.type === 'radio') { + _menuState[item.id] = item.value + } + }) + console.log('_menuState:', _menuState) +} + +const _isVisibleWhenMenuCheck = (menuVisibleWhen) => { + for (const [key, valueArray] of Object.entries(menuVisibleWhen)) { + const menuValue = _menuState[key] + if (!valueArray.includes(menuValue)) { + return false + } + } + return true +} + +const _isVisibleWhenMapStyleCheck = (visibleWhenValue) => { + const mapStylesArray = Array.isArray(visibleWhenValue) ? visibleWhenValue : [visibleWhenValue] + return mapStylesArray.includes(_getMapStyleId()) +} + +// exported so it can be mocked and overridden when testing +export const _getMapStyleId = () => datasetRegistry.mapStyle.id + +/** + * receives a visibleWhen boolean or object + * and returns a boolean indicating whether the dataset should be visible + * if visibleWhen is undefined, it returns true + * if visibleWhen is a boolean, it returns that boolean + * if visibleWhen is an object, it checks the properties of the object, against the relevant pluginState properties, + * and returns true if all properties are satisfied: + * @param {boolean|object} visibleWhen - the visibleWhen property of a dataset + * @returns {boolean} - true if the dataset should be visible, false otherwise + */ +export const isVisibleWhen = (visibleWhen) => { + if (visibleWhen === undefined || visibleWhen === null) { + return true + } + if (typeof visibleWhen === 'boolean') { + return visibleWhen + } + if (typeof visibleWhen === 'object') { + // check each property of the visibleWhen object against the relevant pluginState property + for (const [visibleWhenKey, visibleWhenValue] of Object.entries(visibleWhen)) { + if (visibleWhenKey === 'mapStyleId' && !_isVisibleWhenMapStyleCheck(visibleWhenValue)) { + return false + } + if (visibleWhenKey === 'menu' && !_isVisibleWhenMenuCheck(visibleWhenValue)) { + return false + } + } + return true + } +} From bfad9777e2e74b1f57587a77c74b9ecaf4d5ff5f Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Mon, 13 Jul 2026 13:35:39 +0100 Subject: [PATCH 06/11] IM-372 added a menuStateReducer --- plugins/beta/datasets/src/initialise/DatasetsInit.jsx | 2 +- .../datasets/src/initialise/initialiseDatasets.js | 5 +++-- .../beta/datasets/src/reducers/menuStateReducer.js | 9 +++++++++ plugins/beta/datasets/src/reducers/pluginState.js | 11 ++++++++--- plugins/beta/datasets/src/registry/isVisibleWhen.js | 11 +---------- 5 files changed, 22 insertions(+), 16 deletions(-) create mode 100644 plugins/beta/datasets/src/reducers/menuStateReducer.js diff --git a/plugins/beta/datasets/src/initialise/DatasetsInit.jsx b/plugins/beta/datasets/src/initialise/DatasetsInit.jsx index 09773937..81f88be4 100755 --- a/plugins/beta/datasets/src/initialise/DatasetsInit.jsx +++ b/plugins/beta/datasets/src/initialise/DatasetsInit.jsx @@ -50,7 +50,7 @@ export function DatasetsInit ({ pluginConfig, pluginState, appState, mapState, m initDatasets() }, [isBaseMapReady, appState.mode]) - useEffect(() => setMenuState(pluginState.menu), [pluginState.menu]) + useEffect(() => setMenuState(pluginState.menuState), [pluginState.menuState]) useEffect(() => datasetRegistry.attach(pluginState.mappedDatasets, pluginState.orderedDatasets), [pluginState.mappedDatasets, pluginState.orderedDatasets]) diff --git a/plugins/beta/datasets/src/initialise/initialiseDatasets.js b/plugins/beta/datasets/src/initialise/initialiseDatasets.js index 4d2960db..cda72920 100644 --- a/plugins/beta/datasets/src/initialise/initialiseDatasets.js +++ b/plugins/beta/datasets/src/initialise/initialiseDatasets.js @@ -4,6 +4,7 @@ import { applyDatasetDefaults, datasetDefaults } from './defaults.js' import { mappedDatasetsReducer } from '../reducers/mappedDatasetsReducer.js' import { datasetRegistry } from '../registry/datasetRegistry.js' import { setMenuState } from '../registry/isVisibleWhen.js' +import { buildMenuState } from '../reducers/menuStateReducer.js' import { datasetsToMenu } from '../reducers/datasetsToMenu.js' export const initialiseDatasets = ({ @@ -31,7 +32,7 @@ export const initialiseDatasets = ({ } datasetRegistry.attach(mappedDatasets, orderedDatasets, mapStyle) const menu = pluginConfig.menu || datasetsToMenu({ datasets: processedDatasets }) - setMenuState(menu) // Must be called before adapter.init so that _menuState is set before any datasets are checked for visibility + setMenuState(buildMenuState(menu)) // Must be called before adapter.init so that menuState is set before any datasets are checked for visibility adapter.init().then(() => { datasetRegistry.forEachDataset(registryDataset => { @@ -48,8 +49,8 @@ export const initialiseDatasets = ({ }) adapter.attachDynamicSources(dynamicSources) // TODO - apply dynamic source defaults here, and include in mappedDatasets - dispatch({ type: 'SET_DATASETS', payload: { datasets: processedDatasets, mappedDatasets, orderedDatasets } }) dispatch({ type: 'SET_MENU', payload: { menu } }) + dispatch({ type: 'SET_DATASETS', payload: { datasets: processedDatasets, mappedDatasets, orderedDatasets } }) eventBus.emit('datasets:ready') }) diff --git a/plugins/beta/datasets/src/reducers/menuStateReducer.js b/plugins/beta/datasets/src/reducers/menuStateReducer.js new file mode 100644 index 00000000..839e771b --- /dev/null +++ b/plugins/beta/datasets/src/reducers/menuStateReducer.js @@ -0,0 +1,9 @@ +export const buildMenuState = (menu) => { + const menuState = {} + menu.forEach(menuGroup => { + if (menuGroup.type === 'radio') { + menuState[menuGroup.id] = menuGroup.value || menuGroup.items?.[0].value + } + }) + return menuState +} diff --git a/plugins/beta/datasets/src/reducers/pluginState.js b/plugins/beta/datasets/src/reducers/pluginState.js index b4ab1641..9b78bb46 100755 --- a/plugins/beta/datasets/src/reducers/pluginState.js +++ b/plugins/beta/datasets/src/reducers/pluginState.js @@ -1,4 +1,5 @@ -import { datasetsToMenu, addDatasetToMenu, removeDatasetsFromMenu } from './datasetsToMenu.js' +import { addDatasetToMenu, removeDatasetsFromMenu } from './datasetsToMenu.js' +import { buildMenuState } from './menuStateReducer.js' import { mappedDatasetsReducer } from './mappedDatasetsReducer.js' import { logger } from '../../../../../src/services/logger.js' @@ -16,7 +17,8 @@ const initialState = { hasGroups: false }, actionsArray: [], - menu: [] + menu: [], + menuState: {} } const validateDatasetExists = (state, datasetId, prefix, suffix = 'not found') => { @@ -44,9 +46,12 @@ const setGlobalState = (state, payload) => { const setMenu = (state, payload) => { const { menu } = payload + const menuState = buildMenuState(menu) + console.log('setMenu: menuState:', menuState) return { ...state, - menu + menu, + menuState } } diff --git a/plugins/beta/datasets/src/registry/isVisibleWhen.js b/plugins/beta/datasets/src/registry/isVisibleWhen.js index 36f3ad67..6af4d3cc 100644 --- a/plugins/beta/datasets/src/registry/isVisibleWhen.js +++ b/plugins/beta/datasets/src/registry/isVisibleWhen.js @@ -1,16 +1,7 @@ import { datasetRegistry } from './datasetRegistry.js' let _menuState = {} - -export const setMenuState = (menu) => { - _menuState = {} - menu.forEach(item => { - if (item.type === 'radio') { - _menuState[item.id] = item.value - } - }) - console.log('_menuState:', _menuState) -} +export const setMenuState = (menuState) => (_menuState = menuState) const _isVisibleWhenMenuCheck = (menuVisibleWhen) => { for (const [key, valueArray] of Object.entries(menuVisibleWhen)) { From 52701d2a7574709702fe3b06260fe27763d0a480 Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Mon, 13 Jul 2026 16:55:51 +0100 Subject: [PATCH 07/11] IM-372 LayersMenu renders radios --- demo/js/esri-datasets.js | 4 +- .../components/LayersMenu/Layers.module.scss | 5 ++- .../src/components/LayersMenu/LayersMenu.jsx | 41 +++++++++++++------ .../LayersMenu/LayersMenuGroupWrapper.jsx | 3 -- .../components/LayersMenu/LayersMenuRadio.jsx | 23 +++++++++++ .../LayersMenu/LayersRadioGroupWrapper.jsx | 33 +++++++++++++++ .../beta/datasets/src/reducers/pluginState.js | 12 +++++- 7 files changed, 99 insertions(+), 22 deletions(-) create mode 100644 plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.jsx create mode 100644 plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx diff --git a/demo/js/esri-datasets.js b/demo/js/esri-datasets.js index 7934364d..2ef8220d 100644 --- a/demo/js/esri-datasets.js +++ b/demo/js/esri-datasets.js @@ -340,9 +340,7 @@ const menu = [ value: 'floodzones', // this is the default value for the menu, it should be one of the items' id items: [ { id: 'floodzones', label: 'Flood zones' }, - { id: 'floodzonescc', label: 'Flood zones climate change' }, { id: 'surfacewater', label: 'Surface water' }, - { id: 'surfacewaterDepthAll', label: 'Surface water all depths' }, { id: 'none', label: 'None', }, ], }, @@ -353,7 +351,7 @@ const menu = [ urlIndex: 1, // eg: surfacewater-presentday-high-depth or floodzones-climatechange type: 'radio', visibleWhen: { dataset: ['floodzones', 'surfacewater'] }, - value: 'climatechange', + value: 'presentday', items: [ { id: 'presentday', label: 'Present day' }, { id: 'climatechange', label: '2070 to 2125', visibleWhen: { dataset: ['floodzones'], aep: ['medium', 'low'] } }, diff --git a/plugins/beta/datasets/src/components/LayersMenu/Layers.module.scss b/plugins/beta/datasets/src/components/LayersMenu/Layers.module.scss index ce035ba6..da6b1ba0 100644 --- a/plugins/beta/datasets/src/components/LayersMenu/Layers.module.scss +++ b/plugins/beta/datasets/src/components/LayersMenu/Layers.module.scss @@ -51,10 +51,11 @@ } // GovUK style overide -.im-c-datasets-layers__item .govuk-checkboxes__item { +.im-c-datasets-layers__item .govuk-checkboxes__item, +.im-c-datasets-layers__item .govuk-radios__item { flex-wrap: nowrap; - .govuk-checkboxes__input { + .govuk-checkboxes__input, .govuk-radios__input { margin-left: -3px; } } diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.jsx index d9221cef..d3998299 100644 --- a/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.jsx +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.jsx @@ -1,10 +1,12 @@ import React from 'react' import { setDatasetVisibility } from '../../api/setDatasetVisibility.js' import { LayersMenuCheckbox } from './LayersMenuCheckbox.jsx' +import { LayersRadioGroupWrapper } from './LayersRadioGroupWrapper.jsx' import { LayersMenuGroupWrapper } from './LayersMenuGroupWrapper.jsx' export const LayersMenu = ({ pluginState }) => { const { menu = [] } = pluginState + const handleDatasetChange = (e) => { const { value, checked } = e.target setDatasetVisibility({ pluginState }, checked, { datasetId: value }) @@ -15,18 +17,33 @@ export const LayersMenu = ({ pluginState }) => { return (
{// Each menuGroup - menu.map(menuGroup => - - {// Each menuGroupItem - menuGroup.items.map(menuGroupItem => - - ) - } - ) + menu.map(menuGroup => { + const { type } = menuGroup + if (type === 'checkbox') { + return ( + { + // Each menuGroupItem + menuGroup.items.map(menuGroupItem => ( + ) + ) + } + + ) + } else { + return ( + + ) + } + } + ) }
) diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx index 8467486a..912948ac 100644 --- a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx @@ -2,9 +2,6 @@ export const LayersMenuGroupWrapper = ({ menuGroup, children }) => { if (!menuGroup.groupLabel) { return <>{children} } - // TODO - is there any need for im-c-datasets-layers-group--items-checked - it isn't used anywhere - // const anyItemsChecked = false - // const wrapperClass = `govuk-form-group im-c-datasets-layers-group${anyItemsChecked ? ' im-c-datasets-layers-group--items-checked' : ''}` const wrapperClass = 'govuk-form-group im-c-datasets-layers-group' return (
diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.jsx new file mode 100644 index 00000000..de049a71 --- /dev/null +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.jsx @@ -0,0 +1,23 @@ +// import { datasetRegistry } from '../../registry/datasetRegistry.js' + +export const LayersMenuRadio = ({ menuState, menuGroupItem, checked, name, onChange }) => { + const itemClass = 'im-c-datasets-layers__item govuk-radios govuk-radios--small"' + return ( +
+
+ + +
+
+ ) +} diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx new file mode 100644 index 00000000..ba3da2b3 --- /dev/null +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react' +import { LayersMenuRadio } from './LayersMenuRadio.jsx' + +export const LayersRadioGroupWrapper = ({ pluginState, menuGroup }) => { + const { id, items } = menuGroup + const { menuState, dispatch } = pluginState + const [value, setValue] = useState(menuState[id]) + const handleChange = (event) => { + setValue(event.target.value) + dispatch({ type: 'UPDATE_MENU_STATE', payload: { [id]: event.target.value } }) + } + const wrapperClass = 'govuk-form-group im-c-datasets-layers-group' + return ( +
+
+ + {menuGroup.label} + +
+ {items.map((menuGroupItem) => + + )} +
+
+
+ ) +} diff --git a/plugins/beta/datasets/src/reducers/pluginState.js b/plugins/beta/datasets/src/reducers/pluginState.js index 9b78bb46..33f894a7 100755 --- a/plugins/beta/datasets/src/reducers/pluginState.js +++ b/plugins/beta/datasets/src/reducers/pluginState.js @@ -44,10 +44,17 @@ const setGlobalState = (state, payload) => { }) } +const updateMenuState = (state, payload) => { + return addAction('applyGlobalVisibility', [], { + ...state, + menuState: { ...state.menuState, ...payload } + }) +} + const setMenu = (state, payload) => { const { menu } = payload + // build the initial menuState for radios from the menu const menuState = buildMenuState(menu) - console.log('setMenu: menuState:', menuState) return { ...state, menu, @@ -214,7 +221,8 @@ const actions = { SHOW_FEATURES: showFeatures, REMOVE_ADAPTER_ACTIONS: removeAdapterActions, SET_GLOBAL_STATE: setGlobalState, - SET_MENU: setMenu + SET_MENU: setMenu, + UPDATE_MENU_STATE: updateMenuState } export { From 45cbd080c657e840e231402ed1b6503a81f73ed5 Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Mon, 13 Jul 2026 17:21:12 +0100 Subject: [PATCH 08/11] IM-372 added visibleWhen filter to layersMenu --- demo/js/esri-datasets.js | 10 +++++----- .../src/components/LayersMenu/LayersMenuRadio.jsx | 7 ++++++- .../components/LayersMenu/LayersRadioGroupWrapper.jsx | 9 ++++++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/demo/js/esri-datasets.js b/demo/js/esri-datasets.js index 2ef8220d..1d6e2d83 100644 --- a/demo/js/esri-datasets.js +++ b/demo/js/esri-datasets.js @@ -350,12 +350,12 @@ const menu = [ urlKey: 'dataset', urlIndex: 1, // eg: surfacewater-presentday-high-depth or floodzones-climatechange type: 'radio', - visibleWhen: { dataset: ['floodzones', 'surfacewater'] }, + visibleWhen: { menu: { dataset: ['floodzones', 'surfacewater'] } }, value: 'presentday', items: [ { id: 'presentday', label: 'Present day' }, - { id: 'climatechange', label: '2070 to 2125', visibleWhen: { dataset: ['floodzones'], aep: ['medium', 'low'] } }, - { id: 'climatechange', label: '2061 to 2125', visibleWhen: { dataset: ['surfacewater'] } }, + { id: 'climatechange', label: '2070 to 2125', visibleWhen: { menu: { dataset: ['floodzones'] } } }, + { id: 'climatechange', label: '2061 to 2125', visibleWhen: { menu: { dataset: ['surfacewater'] } } }, ] }, { id: 'aep', @@ -363,7 +363,7 @@ const menu = [ urlKey: 'dataset', urlIndex: 2, type: 'radio', - visibleWhen: { dataset: ['surfacewater'] }, + visibleWhen: { menu: { dataset: ['surfacewater'] } }, value: 'medium', items: [ { id: 'high', label: '1 in 30' }, @@ -376,7 +376,7 @@ const menu = [ urlKey: 'dataset', urlIndex: 3, type: 'radio', - visibleWhen: { dataset: ['surfacewater'] }, + visibleWhen: { menu: { dataset: ['surfacewater'] } }, subMenu: true, value: 'depthAll', items: [ diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.jsx index de049a71..2b74090e 100644 --- a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.jsx +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.jsx @@ -1,7 +1,12 @@ -// import { datasetRegistry } from '../../registry/datasetRegistry.js' +import { isVisibleWhen } from '../../registry/isVisibleWhen.js' export const LayersMenuRadio = ({ menuState, menuGroupItem, checked, name, onChange }) => { const itemClass = 'im-c-datasets-layers__item govuk-radios govuk-radios--small"' + const { visibleWhen } = menuGroupItem + const visible = visibleWhen ? isVisibleWhen(visibleWhen) : true + if (!visible) { + return null + } return (
diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx index ba3da2b3..493d5143 100644 --- a/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx @@ -1,14 +1,21 @@ import React, { useState } from 'react' +import { isVisibleWhen } from '../../registry/isVisibleWhen.js' import { LayersMenuRadio } from './LayersMenuRadio.jsx' export const LayersRadioGroupWrapper = ({ pluginState, menuGroup }) => { - const { id, items } = menuGroup + const { id, items, visibleWhen } = menuGroup + const visible = visibleWhen ? isVisibleWhen(visibleWhen) : true + if (!visible) { + return null + } + const { menuState, dispatch } = pluginState const [value, setValue] = useState(menuState[id]) const handleChange = (event) => { setValue(event.target.value) dispatch({ type: 'UPDATE_MENU_STATE', payload: { [id]: event.target.value } }) } + const wrapperClass = 'govuk-form-group im-c-datasets-layers-group' return (
From 6b4c793d79a09ca2512ab2f622aa2d81f6dd2972 Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Tue, 14 Jul 2026 08:18:17 +0100 Subject: [PATCH 09/11] IM-372 added tests for isVisibleWhen --- .../src/initialise/initialiseDatasets.test.js | 2 +- .../datasets/src/registry/isVisibleWhen.js | 2 + .../src/registry/isVisibleWhen.test.js | 67 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 plugins/beta/datasets/src/registry/isVisibleWhen.test.js diff --git a/plugins/beta/datasets/src/initialise/initialiseDatasets.test.js b/plugins/beta/datasets/src/initialise/initialiseDatasets.test.js index 78302805..9b1005dc 100644 --- a/plugins/beta/datasets/src/initialise/initialiseDatasets.test.js +++ b/plugins/beta/datasets/src/initialise/initialiseDatasets.test.js @@ -61,7 +61,7 @@ describe('initialiseDatasets', () => { it('calls adapter.init with mapStyle', () => { const args = makeArgs() initialiseDatasets(args) - expect(args.adapter.init).toHaveBeenCalledWith(args.mapStyle) + expect(args.adapter.init).toHaveBeenCalledWith() }) it('attaches mappedDatasets to datasetRegistry', () => { diff --git a/plugins/beta/datasets/src/registry/isVisibleWhen.js b/plugins/beta/datasets/src/registry/isVisibleWhen.js index 6af4d3cc..b2fd1cb9 100644 --- a/plugins/beta/datasets/src/registry/isVisibleWhen.js +++ b/plugins/beta/datasets/src/registry/isVisibleWhen.js @@ -50,4 +50,6 @@ export const isVisibleWhen = (visibleWhen) => { } return true } + // Fallback to true if visibleWhen is incorrectly configured + return true } diff --git a/plugins/beta/datasets/src/registry/isVisibleWhen.test.js b/plugins/beta/datasets/src/registry/isVisibleWhen.test.js new file mode 100644 index 00000000..8d35bdd0 --- /dev/null +++ b/plugins/beta/datasets/src/registry/isVisibleWhen.test.js @@ -0,0 +1,67 @@ +import { isVisibleWhen, setMenuState } from './isVisibleWhen.js' +import { datasetRegistry } from './datasetRegistry.js' + +describe('isVisibleWhen', () => { + beforeEach(() => { + // reset menu state before each test + setMenuState({}) + }) + + it('returns true if visibleWhen is undefined', () => { + expect(isVisibleWhen(undefined)).toBe(true) + }) + + it('returns true if visibleWhen is null', () => { + expect(isVisibleWhen(null)).toBe(true) + }) + + it('returns the boolean value if visibleWhen is a boolean', () => { + expect(isVisibleWhen(true)).toBe(true) + expect(isVisibleWhen(false)).toBe(false) + }) + + it('returns true if all properties of visibleWhen object are satisfied', () => { + setMenuState({ datasets: 'floodZones', timeframe: 'climateChange' }) + const visibleWhen = { menu: { datasets: ['floodZones'], timeframe: ['climateChange'] } } + expect(isVisibleWhen(visibleWhen)).toBe(true) + }) + + it('returns false if any property of visibleWhen object is not satisfied', () => { + setMenuState({ datasets: 'floodZones', timeframe: 'climateChange' }) + const visibleWhen = { menu: { datasets: ['floodZones'], timeframe: ['presentDay'] } } + expect(isVisibleWhen(visibleWhen)).toBe(false) + }) + + it('returns true if mapStyleId is satisfied', () => { + const mapStyleId = 'outdoor' + datasetRegistry.attach([], [], { id: mapStyleId }) + const visibleWhen = { mapStyleId } + expect(isVisibleWhen(visibleWhen)).toBe(true) + }) + + it('returns false if mapStyleId is not satisfied', () => { + const mapStyleId = 'outdoor' + datasetRegistry.attach([], [], { id: mapStyleId }) + const visibleWhen = { mapStyleId: 'dark' } + expect(isVisibleWhen(visibleWhen)).toBe(false) + }) + + it('returns true if mapStyleId is an array and one of the values is satisfied', () => { + const mapStyleId = 'outdoor' + datasetRegistry.attach([], [], { id: mapStyleId }) + const visibleWhen = { mapStyleId: ['dark', mapStyleId] } + expect(isVisibleWhen(visibleWhen)).toBe(true) + }) + + it('returns false if mapStyleId is an array and none of the values are satisfied', () => { + const mapStyleId = 'outdoor' + datasetRegistry.attach([], [], { id: mapStyleId }) + const visibleWhen = { mapStyleId: ['dark', 'black-and-white'] } + expect(isVisibleWhen(visibleWhen)).toBe(false) + }) + + it('returns true if visibleWhen is incorrectly configured', () => { + const visibleWhen = 'incorrectly-configured' + expect(isVisibleWhen(visibleWhen)).toBe(true) + }) +}) From 43bc818ff5550b18d2ac58fb649cdbb8cd0b919c Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Tue, 14 Jul 2026 08:32:51 +0100 Subject: [PATCH 10/11] IM-372 covered LayersMenu.jsx --- .../components/LayersMenu/LayersMenu.test.jsx | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 plugins/beta/datasets/src/components/LayersMenu/LayersMenu.test.jsx diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.test.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.test.jsx new file mode 100644 index 00000000..0799c32b --- /dev/null +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.test.jsx @@ -0,0 +1,175 @@ +import { render } from '@testing-library/react' +import { LayersMenu } from './LayersMenu' +import { setDatasetVisibility } from '../../api/setDatasetVisibility.js' + +jest.mock('../../api/setDatasetVisibility.js', () => ({ + setDatasetVisibility: jest.fn() +})) + +let capturedOnChange = null + +jest.mock('./LayersMenuCheckbox.jsx', () => ({ + LayersMenuCheckbox: ({ menuGroupItem, onChange }) => { + capturedOnChange = onChange + return ( +
+ ) + } +})) + +jest.mock('./LayersRadioGroupWrapper.jsx', () => ({ + LayersRadioGroupWrapper: ({ menuGroup }) => ( +
+ ) +})) + +jest.mock('./LayersMenuGroupWrapper.jsx', () => ({ + LayersMenuGroupWrapper: ({ menuGroup, children }) => ( +
+ {children} +
+ ) +})) + +const makePluginState = (menu = [], extra = {}) => ({ + menu, + dispatch: jest.fn(), + ...extra +}) + +describe('LayersMenu', () => { + beforeEach(() => { + setDatasetVisibility.mockClear() + }) + + describe('container class', () => { + it('renders the base container class when no groups have a groupLabel', () => { + const pluginState = makePluginState([{ id: 'g1', type: 'checkbox', items: [] }]) + const { container } = render() + const div = container.firstChild + expect(div.className).toBe('im-c-datasets-layers') + }) + + it('adds the --has-groups modifier when at least one group has a groupLabel', () => { + const pluginState = makePluginState([{ id: 'g1', type: 'checkbox', groupLabel: 'My Group', items: [] }]) + const { container } = render() + const div = container.firstChild + expect(div.className).toBe('im-c-datasets-layers im-c-datasets-layers--has-groups') + }) + }) + + describe('empty menu', () => { + it('renders the container with no children when menu is empty', () => { + const pluginState = makePluginState([]) + const { container } = render() + expect(container.firstChild.children).toHaveLength(0) + }) + + it('uses an empty array when menu is not provided', () => { + const { container } = render() + expect(container.firstChild.children).toHaveLength(0) + }) + }) + + describe('checkbox groups', () => { + it('renders a LayersMenuGroupWrapper for each checkbox group', () => { + const pluginState = makePluginState([ + { id: 'g1', type: 'checkbox', items: [] }, + { id: 'g2', type: 'checkbox', items: [] } + ]) + const { getAllByTestId } = render() + expect(getAllByTestId('layers-menu-group-wrapper')).toHaveLength(2) + }) + + it('renders a LayersMenuCheckbox for each item in a checkbox group', () => { + const pluginState = makePluginState([ + { id: 'g1', type: 'checkbox', items: [{ id: 'item-a' }, { id: 'item-b' }] } + ]) + const { getAllByTestId } = render() + expect(getAllByTestId('layers-menu-checkbox')).toHaveLength(2) + }) + + it('passes the correct group to LayersMenuGroupWrapper', () => { + const group = { id: 'group-x', type: 'checkbox', items: [] } + const pluginState = makePluginState([group]) + const { getByTestId } = render() + expect(getByTestId('layers-menu-group-wrapper').dataset.groupId).toBe('group-x') + }) + + it('passes the correct item to LayersMenuCheckbox', () => { + const pluginState = makePluginState([ + { id: 'g1', type: 'checkbox', items: [{ id: 'item-1' }] } + ]) + const { getByTestId } = render() + expect(getByTestId('layers-menu-checkbox').dataset.itemId).toBe('item-1') + }) + }) + + describe('radio groups', () => { + it('renders a LayersRadioGroupWrapper for non-checkbox groups', () => { + const pluginState = makePluginState([ + { id: 'r1', type: 'radio', items: [{ id: 'opt1' }, { id: 'opt2' }] } + ]) + const { getAllByTestId } = render() + expect(getAllByTestId('layers-radio-group-wrapper')).toHaveLength(1) + }) + + it('passes the correct group to LayersRadioGroupWrapper', () => { + const pluginState = makePluginState([ + { id: 'radio-group-1', type: 'radio', items: [] } + ]) + const { getByTestId } = render() + expect(getByTestId('layers-radio-group-wrapper').dataset.groupId).toBe('radio-group-1') + }) + + it('does not render a LayersMenuGroupWrapper for radio groups', () => { + const pluginState = makePluginState([{ id: 'r1', type: 'radio', items: [] }]) + const { queryByTestId } = render() + expect(queryByTestId('layers-menu-group-wrapper')).toBeNull() + }) + }) + + describe('mixed groups', () => { + it('renders both checkbox and radio wrappers when both types are present', () => { + const pluginState = makePluginState([ + { id: 'c1', type: 'checkbox', items: [] }, + { id: 'r1', type: 'radio', items: [] } + ]) + const { getByTestId } = render() + expect(getByTestId('layers-menu-group-wrapper')).toBeTruthy() + expect(getByTestId('layers-radio-group-wrapper')).toBeTruthy() + }) + }) + + describe('handleDatasetChange', () => { + it('calls setDatasetVisibility with the correct arguments when a checkbox changes', () => { + const pluginState = makePluginState([ + { id: 'g1', type: 'checkbox', items: [{ id: 'dataset-abc' }] } + ]) + render() + capturedOnChange({ target: { value: 'dataset-abc', checked: true } }) + expect(setDatasetVisibility).toHaveBeenCalledTimes(1) + expect(setDatasetVisibility).toHaveBeenCalledWith( + { pluginState }, + true, + { datasetId: 'dataset-abc' } + ) + }) + + it('passes checked: false to setDatasetVisibility when unchecking', () => { + const pluginState = makePluginState([ + { id: 'g1', type: 'checkbox', items: [{ id: 'dataset-xyz' }] } + ]) + render() + capturedOnChange({ target: { value: 'dataset-xyz', checked: false } }) + expect(setDatasetVisibility).toHaveBeenCalledWith( + { pluginState }, + false, + { datasetId: 'dataset-xyz' } + ) + }) + }) +}) From 3d26c064d33b94454a7cc844f941b60bb737b394 Mon Sep 17 00:00:00 2001 From: Mark Fee Date: Tue, 14 Jul 2026 08:44:27 +0100 Subject: [PATCH 11/11] IM-372 covered all LayersMenu/*.jsx --- .../LayersMenu/LayersMenuCheckbox.test.jsx | 117 +++++++++++++++ .../LayersMenuGroupWrapper.test.jsx | 68 +++++++++ .../LayersMenu/LayersMenuRadio.test.jsx | 105 ++++++++++++++ .../LayersRadioGroupWrapper.test.jsx | 136 ++++++++++++++++++ 4 files changed, 426 insertions(+) create mode 100644 plugins/beta/datasets/src/components/LayersMenu/LayersMenuCheckbox.test.jsx create mode 100644 plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.test.jsx create mode 100644 plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx create mode 100644 plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuCheckbox.test.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuCheckbox.test.jsx new file mode 100644 index 00000000..c0c7841e --- /dev/null +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuCheckbox.test.jsx @@ -0,0 +1,117 @@ +import { render, screen, act } from '@testing-library/react' +import { datasetRegistry } from '../../registry/datasetRegistry.js' +import { LayersMenuCheckbox } from './LayersMenuCheckbox' + +jest.mock('../../registry/datasetRegistry.js', () => ({ + datasetRegistry: { + getDataset: jest.fn() + } +})) + +const onChange = jest.fn() +const menuGroupItem = { id: 'dataset-1' } + +const baseDataset = { + id: 'dataset-1', + label: 'Dataset One', + visible: true, + isLocallyVisible: true, + isSublayer: false, + parentId: undefined +} + +beforeEach(() => { + datasetRegistry.getDataset.mockReset() + onChange.mockReset() +}) + +describe('LayersMenuCheckbox', () => { + describe('when the dataset is not in the registry', () => { + it('returns null', () => { + datasetRegistry.getDataset.mockReturnValue(undefined) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + }) + + describe('when the dataset exists', () => { + it('renders a checkbox input', () => { + datasetRegistry.getDataset.mockReturnValue(baseDataset) + const { container } = render() + expect(container.querySelector('input[type="checkbox"]')).toBeTruthy() + }) + + it('renders the dataset label', () => { + datasetRegistry.getDataset.mockReturnValue(baseDataset) + render() + expect(screen.getByText('Dataset One')).toBeTruthy() + }) + + it('associates the label with the input via htmlFor', () => { + datasetRegistry.getDataset.mockReturnValue(baseDataset) + const { container } = render() + const label = container.querySelector('label') + const input = container.querySelector('input') + expect(label.htmlFor).toBe(input.id) + }) + + it('calls onChange when the checkbox changes', () => { + datasetRegistry.getDataset.mockReturnValue(baseDataset) + const { container } = render() + const input = container.querySelector('input') + const propsKey = Object.keys(input).find(k => k.startsWith('__reactProps')) + act(() => { input[propsKey].onChange({ target: { checked: true, value: input.value } }) }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + }) + + describe('checked state', () => { + it('is checked when isLocallyVisible is true', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, isLocallyVisible: true }) + const { container } = render() + expect(container.querySelector('input').checked).toBe(true) + }) + + it('is unchecked when isLocallyVisible is false', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, isLocallyVisible: false }) + const { container } = render() + expect(container.querySelector('input').checked).toBe(false) + }) + }) + + describe('item class', () => { + it('does not include the --checked modifier when visible is true', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, visible: true }) + const { container } = render() + expect(container.firstChild.className).not.toContain('im-c-datasets-layers__item--checked') + }) + + it('includes the --checked modifier when visible is false', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, visible: false }) + const { container } = render() + expect(container.firstChild.className).toContain('im-c-datasets-layers__item--checked') + }) + }) + + describe('data attributes', () => { + it('uses the dataset id as data-dataset-id when not a sublayer', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, isSublayer: false }) + const { container } = render() + const input = container.querySelector('input') + expect(input.dataset.datasetId).toBe('dataset-1') + expect(input.dataset.sublayerId).toBeUndefined() + }) + + it('uses parentId as data-dataset-id when a sublayer', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, id: 'sub-1', isSublayer: true, parentId: 'parent-1' }) + const { container } = render() + expect(container.querySelector('input').dataset.datasetId).toBe('parent-1') + }) + + it('uses the sublayer id as data-sublayer-id when a sublayer', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, id: 'sub-1', isSublayer: true, parentId: 'parent-1' }) + const { container } = render() + expect(container.querySelector('input').dataset.sublayerId).toBe('sub-1') + }) + }) +}) diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.test.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.test.jsx new file mode 100644 index 00000000..45865a3e --- /dev/null +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.test.jsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import { LayersMenuGroupWrapper } from './LayersMenuGroupWrapper' + +const child = Child content + +describe('LayersMenuGroupWrapper', () => { + describe('without a groupLabel', () => { + it('renders children directly without a wrapping div', () => { + const { getByTestId, container } = render( + {child} + ) + expect(getByTestId('child')).toBeTruthy() + expect(container.querySelector('.govuk-form-group')).toBeNull() + }) + + it('does not render a fieldset when groupLabel is absent', () => { + const { container } = render( + {child} + ) + expect(container.querySelector('fieldset')).toBeNull() + }) + + it('does not render a fieldset when groupLabel is an empty string', () => { + const { container } = render( + {child} + ) + expect(container.querySelector('fieldset')).toBeNull() + }) + }) + + describe('with a groupLabel', () => { + it('renders the outer wrapper with the correct classes', () => { + const { container } = render( + {child} + ) + const wrapper = container.querySelector('.govuk-form-group.im-c-datasets-layers-group') + expect(wrapper).toBeTruthy() + }) + + it('renders a fieldset with the correct class', () => { + const { container } = render( + {child} + ) + expect(container.querySelector('fieldset.im-c-datasets-layers-group__fieldset')).toBeTruthy() + }) + + it('renders the groupLabel as the legend text', () => { + render( + {child} + ) + expect(screen.getByText('My Group')).toBeTruthy() + }) + + it('renders the legend with the correct class', () => { + const { container } = render( + {child} + ) + expect(container.querySelector('legend.im-c-datasets-layers-group__legend')).toBeTruthy() + }) + + it('renders children inside the fieldset', () => { + const { getByTestId } = render( + {child} + ) + expect(getByTestId('child')).toBeTruthy() + }) + }) +}) diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx new file mode 100644 index 00000000..55b2c15a --- /dev/null +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx @@ -0,0 +1,105 @@ +import { render, screen, act } from '@testing-library/react' +import { isVisibleWhen } from '../../registry/isVisibleWhen.js' +import { LayersMenuRadio } from './LayersMenuRadio' + +jest.mock('../../registry/isVisibleWhen.js', () => ({ + isVisibleWhen: jest.fn() +})) + +const onChange = jest.fn() +const baseItem = { id: 'radio-1', label: 'Option One' } + +beforeEach(() => { + isVisibleWhen.mockReturnValue(true) + onChange.mockReset() +}) + +describe('LayersMenuRadio', () => { + describe('visibility', () => { + it('renders when visibleWhen is not set', () => { + const { container } = render( + + ) + expect(container.querySelector('input[type="radio"]')).toBeTruthy() + }) + + it('does not call isVisibleWhen when visibleWhen is not set', () => { + render() + expect(isVisibleWhen).not.toHaveBeenCalled() + }) + + it('calls isVisibleWhen with the visibleWhen value', () => { + const visibleWhen = { menu: ['someValue'] } + render() + expect(isVisibleWhen).toHaveBeenCalledWith(visibleWhen) + }) + + it('returns null when isVisibleWhen resolves to false', () => { + isVisibleWhen.mockReturnValue(false) + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it('renders when isVisibleWhen resolves to true', () => { + isVisibleWhen.mockReturnValue(true) + const { container } = render( + + ) + expect(container.querySelector('input[type="radio"]')).toBeTruthy() + }) + }) + + describe('rendered output', () => { + it('renders the item label text', () => { + render() + expect(screen.getByText('Option One')).toBeTruthy() + }) + + it('sets the name attribute on the input', () => { + const { container } = render( + + ) + expect(container.querySelector('input').name).toBe('my-radio-group') + }) + + it('sets checked to true when checked prop is true', () => { + const { container } = render( + + ) + expect(container.querySelector('input').checked).toBe(true) + }) + + it('sets checked to false when checked prop is false', () => { + const { container } = render( + + ) + expect(container.querySelector('input').checked).toBe(false) + }) + + it('sets the input value to the item id', () => { + const { container } = render( + + ) + expect(container.querySelector('input').value).toBe('radio-1') + }) + + it('associates the label with the input via htmlFor', () => { + const { container } = render( + + ) + expect(container.querySelector('label').htmlFor).toBe('radio-1') + }) + + it('calls onChange when the radio input changes', () => { + const { container } = render( + + ) + const input = container.querySelector('input') + const propsKey = Object.keys(input).find(k => k.startsWith('__reactProps')) + act(() => { input[propsKey].onChange({ target: { value: 'radio-1' } }) }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx new file mode 100644 index 00000000..24095d3e --- /dev/null +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx @@ -0,0 +1,136 @@ +import { render, screen, act } from '@testing-library/react' +import { isVisibleWhen } from '../../registry/isVisibleWhen.js' +import { LayersRadioGroupWrapper } from './LayersRadioGroupWrapper' + +jest.mock('../../registry/isVisibleWhen.js', () => ({ + isVisibleWhen: jest.fn() +})) + +let capturedOnChange = null + +jest.mock('./LayersMenuRadio.jsx', () => ({ + LayersMenuRadio: ({ menuGroupItem, checked, name, onChange }) => { + capturedOnChange = onChange + return ( +
+ ) + } +})) + +const makePluginState = (menuState = {}) => ({ + menuState, + dispatch: jest.fn() +}) + +const baseGroup = { + id: 'group-1', + label: 'My Group', + items: [{ id: 'opt-a' }, { id: 'opt-b' }] +} + +beforeEach(() => { + isVisibleWhen.mockClear() + isVisibleWhen.mockReturnValue(true) + capturedOnChange = null +}) + +describe('LayersRadioGroupWrapper', () => { + describe('visibility', () => { + it('returns null when isVisibleWhen resolves to false', () => { + isVisibleWhen.mockReturnValue(false) + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it('renders when visibleWhen is not set', () => { + const { container } = render( + + ) + expect(container.querySelector('.govuk-form-group')).toBeTruthy() + }) + + it('does not call isVisibleWhen when visibleWhen is not set', () => { + render() + expect(isVisibleWhen).not.toHaveBeenCalled() + }) + + it('calls isVisibleWhen with the visibleWhen value', () => { + const visibleWhen = { menu: ['someValue'] } + render() + expect(isVisibleWhen).toHaveBeenCalledWith(visibleWhen) + }) + }) + + describe('rendered output', () => { + it('renders the group label as the legend', () => { + render() + expect(screen.getByText('My Group')).toBeTruthy() + }) + + it('renders a LayersMenuRadio for each item', () => { + const { getAllByTestId } = render( + + ) + expect(getAllByTestId('layers-menu-radio')).toHaveLength(2) + }) + + it('passes the group id as the name to each radio item', () => { + const { getAllByTestId } = render( + + ) + getAllByTestId('layers-menu-radio').forEach(radio => { + expect(radio.dataset.name).toBe('group-1') + }) + }) + }) + + describe('checked state', () => { + it('marks the item matching menuState as checked', () => { + const { getAllByTestId } = render( + + ) + const radios = getAllByTestId('layers-menu-radio') + expect(radios[0].dataset.checked).toBe('true') + expect(radios[1].dataset.checked).toBe('false') + }) + + it('marks no item as checked when menuState has no value for the group', () => { + const { getAllByTestId } = render( + + ) + getAllByTestId('layers-menu-radio').forEach(radio => { + expect(radio.dataset.checked).toBe('false') + }) + }) + }) + + describe('handleChange', () => { + it('dispatches UPDATE_MENU_STATE with the selected item id', () => { + const pluginState = makePluginState({ 'group-1': 'opt-a' }) + render() + act(() => { capturedOnChange({ target: { value: 'opt-b' } }) }) + expect(pluginState.dispatch).toHaveBeenCalledWith({ + type: 'UPDATE_MENU_STATE', + payload: { 'group-1': 'opt-b' } + }) + }) + + it('dispatches using the group id as the payload key', () => { + const group = { ...baseGroup, id: 'another-group' } + const pluginState = makePluginState({ 'another-group': 'opt-a' }) + render() + act(() => { capturedOnChange({ target: { value: 'opt-b' } }) }) + expect(pluginState.dispatch).toHaveBeenCalledWith({ + type: 'UPDATE_MENU_STATE', + payload: { 'another-group': 'opt-b' } + }) + }) + }) +})