diff --git a/demo/js/esri-datasets.js b/demo/js/esri-datasets.js index c3c90202..1d6e2d83 100644 --- a/demo/js/esri-datasets.js +++ b/demo/js/esri-datasets.js @@ -7,102 +7,400 @@ 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: 'flood-zones-cc', - label: 'Flood Zones Climate Change', - groupLabel: 'Datasets', - esriGroupId: 'flood-zones', - 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, - style: { - fill: { outdoor: '#F4A582', dark: '#BF3D4A' }, - stroke: 'none' - }, - }, - { - id: 'data-unavailable', - label: 'Climate change data unavailable', - 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', - style: { - stroke: { outdoor: '#000000', dark: '#FFFFFF' }, - }, - esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/0', - showInKey: false, - showInMenu: false, - }, - { - id: 'data-unavailable-light', - visibleWhen: { mapStyleId: ['outdoor', 'black-and-white'] }, - esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/1', - esriUseServerStyle: true, - showInKey: false, - showInMenu: false, - }, - { - id: 'data-unavailable-dark', - visibleWhen: { mapStyleId: ['dark'] }, - esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/2', - esriUseServerStyle: true, - showInKey: false, - showInMenu: false, + 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: '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: 'flood-zones', - label: 'Flood Zones', - groupLabel: 'Datasets', - esriGroupId: 'flood-zones', - 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', - 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 = [ + { + 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: { menu: { dataset: ['floodzones', 'surfacewater'] } }, + value: 'presentday', + items: [ + { id: 'presentday', label: 'Present day' }, + { id: 'climatechange', label: '2070 to 2125', visibleWhen: { menu: { dataset: ['floodzones'] } } }, + { id: 'climatechange', label: '2061 to 2125', visibleWhen: { menu: { dataset: ['surfacewater'] } } }, + ] + }, { + id: 'aep', + label: 'Annual likelihood of flooding', + urlKey: 'dataset', + urlIndex: 2, + type: 'radio', + visibleWhen: { menu: { 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: { menu: { 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({ @@ -111,7 +409,8 @@ const datasetsPlugin = createDatasetsPlugin({ opacity: 0.75, visible: true }, - datasets + datasets, + menu }) const interactiveMap = new InteractiveMap('map', { @@ -132,8 +431,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 +446,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/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/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' } + ) + }) + }) +}) 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.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/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.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.jsx new file mode 100644 index 00000000..2b74090e --- /dev/null +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersMenuRadio.jsx @@ -0,0 +1,28 @@ +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/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.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx new file mode 100644 index 00000000..493d5143 --- /dev/null +++ b/plugins/beta/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react' +import { isVisibleWhen } from '../../registry/isVisibleWhen.js' +import { LayersMenuRadio } from './LayersMenuRadio.jsx' + +export const LayersRadioGroupWrapper = ({ pluginState, 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 ( +
+
+ + {menuGroup.label} + +
+ {items.map((menuGroupItem) => + + )} +
+
+
+ ) +} 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' } + }) + }) + }) +}) diff --git a/plugins/beta/datasets/src/initialise/DatasetsInit.jsx b/plugins/beta/datasets/src/initialise/DatasetsInit.jsx index 36964be1..81f88be4 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.menuState), [pluginState.menuState]) + useEffect(() => datasetRegistry.attach(pluginState.mappedDatasets, pluginState.orderedDatasets), [pluginState.mappedDatasets, pluginState.orderedDatasets]) 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' }) }) diff --git a/plugins/beta/datasets/src/initialise/initialiseDatasets.js b/plugins/beta/datasets/src/initialise/initialiseDatasets.js index ecd97f1f..cda72920 100644 --- a/plugins/beta/datasets/src/initialise/initialiseDatasets.js +++ b/plugins/beta/datasets/src/initialise/initialiseDatasets.js @@ -3,6 +3,9 @@ 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 { buildMenuState } from '../reducers/menuStateReducer.js' +import { datasetsToMenu } from '../reducers/datasetsToMenu.js' export const initialiseDatasets = ({ adapter, @@ -28,7 +31,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(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 => { if (!registryDataset.hasDynamicGeoJSON) { return @@ -43,6 +49,7 @@ export const initialiseDatasets = ({ }) adapter.attachDynamicSources(dynamicSources) // TODO - apply dynamic source defaults here, and include in mappedDatasets + 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/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/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 cee81ca7..33f894a7 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' @@ -15,7 +16,9 @@ const initialState = { items: [], hasGroups: false }, - actionsArray: [] + actionsArray: [], + menu: [], + menuState: {} } const validateDatasetExists = (state, datasetId, prefix, suffix = 'not found') => { @@ -41,14 +44,31 @@ 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) + return { + ...state, + menu, + menuState + } +} + 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 } } @@ -93,6 +113,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, @@ -197,7 +220,9 @@ const actions = { HIDE_FEATURES: hideFeatures, SHOW_FEATURES: showFeatures, REMOVE_ADAPTER_ACTIONS: removeAdapterActions, - SET_GLOBAL_STATE: setGlobalState + SET_GLOBAL_STATE: setGlobalState, + SET_MENU: setMenu, + UPDATE_MENU_STATE: updateMenuState } export { 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', () => { 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..b2fd1cb9 --- /dev/null +++ b/plugins/beta/datasets/src/registry/isVisibleWhen.js @@ -0,0 +1,55 @@ +import { datasetRegistry } from './datasetRegistry.js' + +let _menuState = {} +export const setMenuState = (menuState) => (_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 + } + // 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) + }) +})