diff --git a/docs/developer-guide/maps-configuration.md b/docs/developer-guide/maps-configuration.md index 1c8653047c..ac1e9e8c84 100644 --- a/docs/developer-guide/maps-configuration.md +++ b/docs/developer-guide/maps-configuration.md @@ -1644,6 +1644,12 @@ Example: "clippingPolygonFeatureId": "feature.id.01", "clippingPolygonUnion": false } + ], + "groups": [ + { + "id": "group_01", + "visibility": true + } ] } ], @@ -1710,6 +1716,7 @@ View configuration object | globeTranslucency.nearDistance | number | when `fadeByDistance` is true it indicates the minimum distance to apply translucency | | globeTranslucency.farDistance | number | when `fadeByDistance` is true it indicates the maximum distance to apply translucency | | layers | array | array of layer configuration overrides, default properties override `visibility` and `opacity` | +| groups | array | array of group configuration overrides, default property overrides `visibility` | Resource object configuration diff --git a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js index 6e2e06c884..c50c89483c 100644 --- a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js +++ b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js @@ -95,12 +95,19 @@ function clip3DTiles(tileSet, options, map) { }); } -function ensureReady(tileSet, callback) { +let pendingCallbacks = {}; + +function ensureReady(layer, callback, eventKey) { + const tileSet = layer.getTileSet(); + if (!tileSet && eventKey) { + pendingCallbacks[eventKey] = callback; + return; + } if (tileSet.ready) { - callback(); + callback(tileSet); } else { tileSet.readyPromise.then(() => { - callback(); + callback(tileSet); }); } } @@ -152,10 +159,13 @@ const createLayer = (options, map) => { map.scene.primitives.remove(tileSet); tileSet = undefined; }; + const layer = { + getTileSet: () => tileSet, + getResource: () => resource + }; return { detached: true, - getTileSet: () => tileSet, - getResource: () => resource, + ...layer, add: () => { resource = new Cesium.Resource({ url: options.url, @@ -175,7 +185,7 @@ const createLayer = (options, map) => { map.scene.primitives.add(tileSet); // assign the original mapstore id of the layer tileSet.msId = options.id; - ensureReady(tileSet, () => { + ensureReady(layer, () => { updateModelMatrix(tileSet, options); clip3DTiles(tileSet, options, map); updateShading(tileSet, options, map); @@ -184,6 +194,10 @@ const createLayer = (options, map) => { if (style) { tileSet.style = new Cesium.Cesium3DTileStyle(style); } + Object.keys(pendingCallbacks).forEach((eventKey) => { + pendingCallbacks[eventKey](tileSet); + }); + pendingCallbacks = {}; }); }); }); @@ -209,21 +223,20 @@ Layers.registerType('3dtiles', { if (newOptions.forceProxy !== oldOptions.forceProxy) { return createLayer(newOptions, map); } - const tileSet = layer?.getTileSet(); if ( (!isEqual(newOptions.clippingPolygon, oldOptions.clippingPolygon) || newOptions.clippingPolygonUnion !== oldOptions.clippingPolygonUnion || newOptions.clipOriginalGeometry !== oldOptions.clipOriginalGeometry) - && tileSet) { - ensureReady(tileSet, () => { + ) { + ensureReady(layer, (tileSet) => { clip3DTiles(tileSet, newOptions, map); - }); + }, 'clip'); } if (( !isEqual(newOptions.style, oldOptions.style) || newOptions?.pointCloudShading?.attenuation !== oldOptions?.pointCloudShading?.attenuation - ) && tileSet) { - ensureReady(tileSet, () => { + )) { + ensureReady(layer, (tileSet) => { getStyle(newOptions) .then((style) => { if (style && tileSet) { @@ -231,17 +244,17 @@ Layers.registerType('3dtiles', { tileSet.style = new Cesium.Cesium3DTileStyle(style); } }); - }); + }, 'style'); } - if (!isEqual(newOptions.pointCloudShading, oldOptions.pointCloudShading) && tileSet) { - ensureReady(tileSet, () => { + if (!isEqual(newOptions.pointCloudShading, oldOptions.pointCloudShading)) { + ensureReady(layer, (tileSet) => { updateShading(tileSet, newOptions, map); - }); + }, 'shading'); } - if (tileSet && newOptions.heightOffset !== oldOptions.heightOffset) { - ensureReady(tileSet, () => { + if (newOptions.heightOffset !== oldOptions.heightOffset) { + ensureReady(layer, (tileSet) => { updateModelMatrix(tileSet, newOptions); - }); + }, 'matrix'); } return null; } diff --git a/web/client/components/mapviews/MapViewSettings.jsx b/web/client/components/mapviews/MapViewSettings.jsx index 2ada681d68..80a29eb697 100644 --- a/web/client/components/mapviews/MapViewSettings.jsx +++ b/web/client/components/mapviews/MapViewSettings.jsx @@ -31,6 +31,7 @@ function ViewSettings({ view, api, layers = [], + groups = [], onChange, onUpdateResource = () => { }, onCaptureView, @@ -77,6 +78,26 @@ function ViewSettings({ }); } + function handleChangeGroup(groupId, options) { + const viewGroup = view?.groups?.find(vGroup => vGroup.id === groupId); + const viewGroups = viewGroup + ? (view?.groups || []) + .map((vGroup) => vGroup.id === groupId ? ({ ...viewGroup, ...options }) : vGroup) + : [...(view?.groups || []), { id: groupId, ...options }]; + onChange({ + ...view, + groups: viewGroups + }); + } + + function handleResetGroup(groupId) { + const viewGroups = view?.groups?.filter(vGroup => vGroup.id !== groupId); + onChange({ + ...view, + groups: viewGroups + }); + } + function updateLayerRequest({ layer, inverse = false, offset = 0 } = {}) { return getResourceFromLayer({ layer, @@ -108,11 +129,14 @@ function ViewSettings({ onChange={handleChange} resources={resources} layers={layers.filter(({ type }) => !(api?.options?.unsupportedLayers || []).includes(type))} + groups={groups} vectorLayers={availableVectorLayers} updateLayerRequest={updateLayerRequest} locale={locale} onChangeLayer={handleChangeLayer} onResetLayer={handleResetLayer} + onChangeGroup={handleChangeGroup} + onResetGroup={handleResetGroup} showClipGeometries={showClipGeometries} onShowClipGeometries={onShowClipGeometries} onCaptureView={onCaptureView} diff --git a/web/client/components/mapviews/MapViewsSupport.jsx b/web/client/components/mapviews/MapViewsSupport.jsx index f489588660..0ac177e87f 100644 --- a/web/client/components/mapviews/MapViewsSupport.jsx +++ b/web/client/components/mapviews/MapViewsSupport.jsx @@ -161,6 +161,7 @@ function MapViewsSupport({ selectedId, defaultTitle = 'Map View', layers, + groups, locale, resources: resourcesProp = [], services, @@ -597,6 +598,7 @@ function MapViewsSupport({ onChange={handleUpdateView} onCaptureView={handleCaptureView} layers={layers} + groups={groups} locale={locale} services={services} selectedService={selectedService} diff --git a/web/client/components/mapviews/settings/LayerOverridesNode.jsx b/web/client/components/mapviews/settings/LayerOverridesNode.jsx deleted file mode 100644 index 3b48893efa..0000000000 --- a/web/client/components/mapviews/settings/LayerOverridesNode.jsx +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright 2022, GeoSolutions Sas. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -import React, { useState } from 'react'; -import { - ButtonGroup, - Glyphicon, - Checkbox, - FormGroup, - ControlLabel, - Alert -} from 'react-bootstrap'; -import Select from 'react-select'; -import {clamp} from 'lodash'; -import FormControl from '../../misc/DebouncedFormControl'; -import { formatClippingFeatures } from '../../../utils/MapViewsUtils'; -import Message from '../../I18N/Message'; -import ButtonMS from '../../misc/Button'; -import tooltip from '../../misc/enhancers/tooltip'; -const Button = tooltip(ButtonMS); - -function LayerOverridesNode({ - layer = {}, - onChange, - onReset, - title, - updateLayerRequest, - vectorLayers, - clippingFeatures, - clippingLayerResource, - initialExpanded -}) { - - const [expanded, setExpanded] = useState(initialExpanded); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(false); - const isVisibilitySupported = !['terrain'].includes(layer.type); - const isOpacitySupported = !['3dtiles', 'terrain'].includes(layer.type); - const isClippingSupported = !!['3dtiles', 'terrain'].includes(layer.type); - - function handleUpdateLayer({ layer: resourceLayer }) { - setError(false); - if (!resourceLayer) { - return onChange({ - clippingLayerResourceId: undefined, - clippingPolygonFeatureId: undefined, - clippingPolygonUnion: undefined - }); - } - setLoading(true); - return updateLayerRequest({ layer: resourceLayer }) - .then((clippingLayerResourceId) => { - onChange({ clippingLayerResourceId }); - setLoading(false); - }) - .catch(() => { - setError(true); - setLoading(false); - }); - } - - const formattedClippingFeatures = formatClippingFeatures(clippingFeatures); - - return ( -
  • -
    - -
    {title}
    - - {layer.changed && } - {isVisibilitySupported && } - -
    - {expanded &&
    - {isOpacitySupported && - - { - const opacity = value && clamp(parseFloat(value), 0, 1); - onChange({ opacity: opacity || 0 }); - }} - /> - } - {isClippingSupported &&
    - - - ({ value: feature.id, label: feature.id, feature }))} - onChange={(option) => onChange({ clippingPolygonFeatureId: option?.feature?.id })} - /> - {(!!layer.clippingLayerResourceId && formattedClippingFeatures?.length === 0) && - - } - - - onChange({ clippingPolygonUnion: !layer.clippingPolygonUnion })}> - - - - {loading &&
    } -
    } -
    } -
  • - ); -} - -export default LayerOverridesNode; diff --git a/web/client/components/mapviews/settings/LayerOverridesNodeContent.jsx b/web/client/components/mapviews/settings/LayerOverridesNodeContent.jsx new file mode 100644 index 0000000000..e71ca5e674 --- /dev/null +++ b/web/client/components/mapviews/settings/LayerOverridesNodeContent.jsx @@ -0,0 +1,148 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import { + Checkbox, + FormGroup, + ControlLabel, + Alert +} from 'react-bootstrap'; +import Select from 'react-select'; +import { formatClippingFeatures } from '../../../utils/MapViewsUtils'; +import Message from '../../I18N/Message'; +import { getTitle } from '../../../utils/LayersUtils'; +import PropTypes from 'prop-types'; + +/** + * LayerOverridesNodeContent render additional content inside the layer node in toc for map views + * @prop {object} node layer object + * @prop {object} config optional configuration available for the nodes + * @prop {function} onChange return the changes of this node + */ +function LayerOverridesNodeContent({ + node = {}, + config, + onChange +}) { + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + const isClippingSupported = !!['3dtiles', 'terrain'].includes(node.type); + + if (!isClippingSupported) { + return null; + } + + const { + updateLayerRequest, + vectorLayers, + resources, + locale + } = config?.mapViews || {}; + + const vectorLayersOptions = vectorLayers + ?.filter((layer) => { + if (layer.type === 'wfs') { + return true; + } + if (layer.type === 'vector') { + return !!layer?.features?.find(({ geometry }) => ['Polygon'].includes(geometry?.type)); + } + return false; + }) + .map((layer) => ({ + label: getTitle(layer.title, locale) || layer.name || layer.id, + value: layer.id, + layer + })); + + function handleUpdateLayer({ layer: resourceLayer }) { + setError(false); + if (!resourceLayer) { + return onChange({ + clippingLayerResourceId: undefined, + clippingPolygonFeatureId: undefined, + clippingPolygonUnion: undefined + }); + } + setLoading(true); + return updateLayerRequest({ layer: resourceLayer }) + .then((clippingLayerResourceId) => { + onChange({ clippingLayerResourceId }); + setLoading(false); + }) + .catch(() => { + setError(true); + setLoading(false); + }); + } + + const _clippingLayerResource = resources?.find(({ id }) => id === node.clippingLayerResourceId)?.data; + const vectorLayer = vectorLayers?.find(({ id }) => id === _clippingLayerResource?.id); + const clippingFeatures = _clippingLayerResource?.collection?.features || vectorLayer?.features; + const clippingLayerResource = _clippingLayerResource + ? { + value: _clippingLayerResource?.id, + label: getTitle(vectorLayer?.title, locale) || vectorLayer?.name || vectorLayer?.id, + resource: _clippingLayerResource + } : undefined; + + const formattedClippingFeatures = formatClippingFeatures(clippingFeatures); + + return ( +
    + + + ({ value: feature.id, label: feature.id, feature }))} + onChange={(option) => onChange({ clippingPolygonFeatureId: option?.feature?.id })} + /> + {(!!node.clippingLayerResourceId && formattedClippingFeatures?.length === 0) && + + } + + + onChange({ clippingPolygonUnion: !node.clippingPolygonUnion })}> + + + + {loading &&
    } +
    + ); +} + +LayerOverridesNodeContent.propTypes = { + node: PropTypes.object, + config: PropTypes.object, + onChange: PropTypes.func +}; + +export default LayerOverridesNodeContent; diff --git a/web/client/components/mapviews/settings/LayersSection.jsx b/web/client/components/mapviews/settings/LayersSection.jsx index 0f09c05d29..d944a203d7 100644 --- a/web/client/components/mapviews/settings/LayersSection.jsx +++ b/web/client/components/mapviews/settings/LayersSection.jsx @@ -6,52 +6,132 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; -import { FormGroup, Checkbox } from 'react-bootstrap'; +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormControl as FormControlRB, FormGroup, InputGroup, Glyphicon, Checkbox, Button as ButtonRB, ButtonGroup } from 'react-bootstrap'; import Section from './Section'; -import { getTitle } from '../../../utils/LayersUtils'; -import { mergeViewLayers } from '../../../utils/MapViewsUtils'; -import LayerOverridesNode from './LayerOverridesNode'; +import { mergeViewLayers, mergeViewGroups, pickViewLayerProperties, pickViewGroupProperties } from '../../../utils/MapViewsUtils'; +import LayerOverridesNodeContent from './LayerOverridesNodeContent'; import Message from '../../I18N/Message'; +import TOC from '../../../plugins/TOC/components/TOC'; +import tooltip from '../../misc/enhancers/tooltip'; +import localizedProps from '../../misc/enhancers/localizedProps'; +import { NodeTypes } from '../../../utils/LayersUtils'; +import { getMessageById } from '../../../utils/LocaleUtils'; +const Button = tooltip(ButtonRB); +const FormControl = localizedProps('placeholder')(FormControlRB); + +/** + * ResetLayerOverrides node tool to link and unlink groups and layers to TOC + * @prop {object} itemComponent default node tool component + * @prop {object} node layer object + * @prop {object} config optional configuration available for the nodes + * @prop {string} nodeType node type + * @prop {object} nodeTypes constant values for node types + * @prop {function} onChange return the changes of this node + */ +function ResetLayerOverrides({ + itemComponent, + node, + config, + nodeType, + nodeTypes, + onChange +}) { + const ItemComponent = itemComponent; + const { view } = config?.mapViews || {}; + const changed = nodeType === nodeTypes.LAYER + ? !!view?.layers?.find(layer => layer.id === node.id) + : !!view?.groups?.find(group => group.id === node.id); + function handleClick() { + if (changed) { + onChange({ resetView: true }); + } else { + onChange(nodeType === nodeTypes.LAYER + ? pickViewLayerProperties(node) + : pickViewGroupProperties(node)); + } + } + return ( + + ); +} + +ResetLayerOverrides.propTypes = { + itemComponent: PropTypes.any, + node: PropTypes.object, + config: PropTypes.object, + nodeType: PropTypes.string, + nodeTypes: PropTypes.object, + onChange: PropTypes.func +}; +/** + * LayersSection table of content for layers and groups inside a map view + * @prop {object} view view configuration + * @prop {object} expandedSections state of the expended section + * @prop {function} onExpandSection returns the new expanded state + * @prop {function} onChange returns changes on the view + * @prop {array} resources list of resources available for the views + * @prop {array} layers list of supported layers + * @prop {array} groups list of supported groups + * @prop {array} vectorLayers list of vector layers + * @prop {string} locale current locale + * @prop {function} onChangeLayer returns changes on a view layer + * @prop {function} onResetLayer requests a reset on the selected view layer + * @prop {function} onChangeGroup returns changes on a view group + * @prop {function} onResetGroup requests a reset on the selected view group + * @prop {boolean} showClipGeometries visibility state of clipping features + * @prop {function} onShowClipGeometries return the clipping checkbox state + * @prop {function} isTerrainAvailable if true shows the terrain options + * @prop {function} isClippingAvailable if true shows enable clipping options + */ function LayersSection({ view, expandedSections = {}, onExpandSection, onChange, resources, - layers, + layers = [], + groups = [], vectorLayers, updateLayerRequest, locale, onChangeLayer, onResetLayer, + onChangeGroup, + onResetGroup, showClipGeometries, onShowClipGeometries, isTerrainAvailable, isClippingAvailable -}) { +}, { messages }) { - const terrainClippingLayerResource = resources?.find(resource => resource.id === view?.terrain?.clippingLayerResourceId)?.data; - const terrainVectorLayer = vectorLayers?.find(layer => layer.id === terrainClippingLayerResource?.id); - const terrainClippingFeatures = terrainClippingLayerResource?.collection?.features || terrainVectorLayer?.features; + const [filterText, setFilterText] = useState(''); + const [expandedNodes, setExpandedNodes] = useState([ + ...groups.filter((group) => group.expanded).map(group => group.id), + ...layers.filter((layer) => layer.expanded).map(layer => layer.id) + ]); const mergedLayers = mergeViewLayers(layers, view); - const vectorLayersOptions = vectorLayers - ?.filter((layer) => { - if (layer.type === 'wfs') { - return true; - } - if (layer.type === 'vector') { - return !!layer?.features?.find(({ geometry }) => ['Polygon'].includes(geometry?.type)); - } - return false; - }) - .map((layer) => ({ - label: getTitle(layer.title, locale) || layer.name || layer.id, - value: layer.id, - layer - })); - + const mergedGroups = mergeViewGroups(groups, view); + const tocMapViewConfig = { + view, + updateLayerRequest, + vectorLayers, + resources, + locale + }; + function applyExpandedProperty(nodes) { + return nodes.map(node => ({ ...node, expanded: expandedNodes.includes(node.id) })); + } + function areAllNodesUnlinked() { + return layers.every(layer => (view?.layers || []).some(vLayer => vLayer.id === layer.id)) + && groups.every(group => (view?.groups || []).some(vGroup => vGroup.id === group.id)); + } return (
    } @@ -69,55 +149,150 @@ function LayersSection({
    } - + title: getMessageById(messages, 'mapViews.terrain') + }] + }} + nodeContentItems={[ + { name: 'LayerOverridesNodeContent', Component: LayerOverridesNodeContent } + ]} + config={{ + sortable: false, + hideOpacitySlider: true, + hideVisibilityButton: true, + layerOptions: { + hideFilter: true, + hideLegend: true + }, + mapViews: tocMapViewConfig + }} + onChangeNode={(nodeId, nodeType, options) => { + if (nodeId === 'terrain' && nodeType === NodeTypes.LAYER) { + onChange({ terrain: { ...view?.terrain, ...options }}); + } + }} + /> : null} + { + if (options.expanded !== undefined) { + return setExpandedNodes( + options.expanded + ? [...expandedNodes, nodeId] + : expandedNodes.filter(expandedNodeId => expandedNodeId !== nodeId)); + } + if (options.resetView) { + return nodeType === NodeTypes.LAYER + ? onResetLayer(nodeId) + : onResetGroup(nodeId); + } + return nodeType === NodeTypes.LAYER + ? onChangeLayer(nodeId, options) + : onChangeGroup(nodeId, options); + }} + /> ); } +LayersSection.propTypes = { + view: PropTypes.object, + expandedSections: PropTypes.object, + onExpandSection: PropTypes.func, + onChange: PropTypes.func, + resources: PropTypes.array, + layers: PropTypes.array, + groups: PropTypes.array, + vectorLayers: PropTypes.array, + updateLayerRequest: PropTypes.func, + locale: PropTypes.string, + onChangeLayer: PropTypes.func, + onResetLayer: PropTypes.func, + onChangeGroup: PropTypes.func, + onResetGroup: PropTypes.func, + showClipGeometries: PropTypes.bool, + onShowClipGeometries: PropTypes.func, + isTerrainAvailable: PropTypes.bool, + isClippingAvailable: PropTypes.bool +}; + +LayersSection.contextTypes = { + messages: PropTypes.object +}; + export default LayersSection; diff --git a/web/client/components/mapviews/settings/Section.jsx b/web/client/components/mapviews/settings/Section.jsx index f40718dec1..25df460d9b 100644 --- a/web/client/components/mapviews/settings/Section.jsx +++ b/web/client/components/mapviews/settings/Section.jsx @@ -29,7 +29,7 @@ function Section({ onClick={handleExpand} style={{ borderRadius: '50%', marginRight: 4 }} > - +
    {title} diff --git a/web/client/components/mapviews/settings/__tests__/LayerOverridesNode-test.jsx b/web/client/components/mapviews/settings/__tests__/LayerOverridesNode-test.jsx deleted file mode 100644 index 9138afd124..0000000000 --- a/web/client/components/mapviews/settings/__tests__/LayerOverridesNode-test.jsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2022, GeoSolutions Sas. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import LayerOverridesNode from '../LayerOverridesNode'; -import expect from 'expect'; - -describe('LayerOverridesNode component', () => { - beforeEach((done) => { - document.body.innerHTML = '
    '; - setTimeout(done); - }); - - afterEach((done) => { - ReactDOM.unmountComponentAtNode(document.getElementById("container")); - document.body.innerHTML = ''; - setTimeout(done); - }); - - it('should render with default', () => { - ReactDOM.render(, document.getElementById("container")); - const layerNode = document.querySelector('.ms-map-views-layer-node'); - expect(layerNode).toBeTruthy(); - }); - it('should display three buttons for changed layers', () => { - ReactDOM.render(, document.getElementById("container")); - const layerNode = document.querySelector('.ms-map-views-layer-node.changed'); - expect(layerNode).toBeTruthy(); - const buttonNodes = document.querySelectorAll('button'); - expect(buttonNodes.length).toBe(3); - }); - it('should display clipping opacity option for layers', () => { - ReactDOM.render(, document.getElementById("container")); - const layerNode = document.querySelector('.ms-map-views-layer-node'); - expect(layerNode).toBeTruthy(); - const buttonNodes = document.querySelectorAll('button'); - expect(buttonNodes.length).toBe(2); - const inputNodes = document.querySelectorAll('input'); - expect(inputNodes.length).toBe(1); - }); - it('should display clipping options for 3D tiles', () => { - ReactDOM.render(, document.getElementById("container")); - const layerNode = document.querySelector('.ms-map-views-layer-node'); - expect(layerNode).toBeTruthy(); - const buttonNodes = document.querySelectorAll('button'); - expect(buttonNodes.length).toBe(2); - const selectNodes = document.querySelectorAll('.Select'); - expect(selectNodes.length).toBe(2); - const inputNodes = document.querySelectorAll('input'); - expect(inputNodes.length).toBe(2); - expect([...inputNodes].map(node => node.getAttribute('type'))) - .toEqual([ null, 'checkbox' ]); - }); -}); diff --git a/web/client/components/mapviews/settings/__tests__/LayerOverridesNodeContent-test.jsx b/web/client/components/mapviews/settings/__tests__/LayerOverridesNodeContent-test.jsx new file mode 100644 index 0000000000..bd4fff441d --- /dev/null +++ b/web/client/components/mapviews/settings/__tests__/LayerOverridesNodeContent-test.jsx @@ -0,0 +1,45 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import LayerOverridesNodeContent from '../LayerOverridesNodeContent'; +import expect from 'expect'; + +describe('LayerOverridesNodeContent component', () => { + beforeEach((done) => { + document.body.innerHTML = '
    '; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should not render with default', () => { + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelector('#container').children.length).toBe(0); + }); + it('should display clipping options for 3D tiles', () => { + ReactDOM.render(, document.getElementById("container")); + const layerNode = document.querySelector('.ms-map-views-layer-clipping'); + expect(layerNode).toBeTruthy(); + const selectNodes = document.querySelectorAll('.Select'); + expect(selectNodes.length).toBe(2); + const inputNodes = document.querySelectorAll('input'); + expect(inputNodes.length).toBe(2); + expect([...inputNodes].map(node => node.getAttribute('type'))) + .toEqual([ null, 'checkbox' ]); + }); +}); diff --git a/web/client/components/mapviews/settings/__tests__/LayersSection-test.jsx b/web/client/components/mapviews/settings/__tests__/LayersSection-test.jsx index 2262668745..eda86ea8a8 100644 --- a/web/client/components/mapviews/settings/__tests__/LayersSection-test.jsx +++ b/web/client/components/mapviews/settings/__tests__/LayersSection-test.jsx @@ -10,6 +10,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import LayersSection from '../LayersSection'; import expect from 'expect'; +import { Simulate } from 'react-dom/test-utils'; describe('LayersSection component', () => { beforeEach((done) => { @@ -44,8 +45,8 @@ describe('LayersSection component', () => { const sectionNode = document.querySelector('.ms-map-views-section'); expect(sectionNode).toBeTruthy(); const inputNodes = document.querySelectorAll('input'); - expect(inputNodes.length).toBe(1); - expect([...inputNodes].map(node => node.getAttribute('type'))).toEqual(['checkbox']); + expect(inputNodes.length).toBe(2); + expect(inputNodes[0].getAttribute('type')).toBe('checkbox'); }); it('should display terrain node', () => { ReactDOM.render( { />, document.getElementById("container")); const sectionNode = document.querySelector('.ms-map-views-section'); expect(sectionNode).toBeTruthy(); - const layerNodes = [...document.querySelectorAll('.ms-map-views-layer-node')]; + const layerNodes = [...document.querySelectorAll('.ms-node-layer')]; expect(layerNodes.length).toBe(1); }); it('should display list of layers', () => { @@ -96,13 +97,474 @@ describe('LayersSection component', () => { />, document.getElementById("container")); const sectionNode = document.querySelector('.ms-map-views-section'); expect(sectionNode).toBeTruthy(); - const layerNodes = [...document.querySelectorAll('.ms-map-views-layer-node')]; + const layerNodes = [...document.querySelectorAll('.ms-node-layer')]; expect(layerNodes.length).toBe(2); - const layerButtonNodes = [...layerNodes[0].querySelectorAll('button')]; - expect(layerButtonNodes.length).toBe(2); - const changedLayerNodes = [...document.querySelectorAll('.ms-map-views-layer-node.changed')]; - expect(changedLayerNodes.length).toBe(1); - const changedLayerButtonNodes = [...changedLayerNodes[0].querySelectorAll('button')]; - expect(changedLayerButtonNodes.length).toBe(3); + expect([...layerNodes[0].querySelectorAll('button')] + .map(layerButtonNode => layerButtonNode.querySelector('.glyphicon').getAttribute('class'))) + .toEqual([ 'glyphicon glyphicon-checkbox-off', 'glyphicon glyphicon-plug' ]); + expect([...layerNodes[1].querySelectorAll('button')] + .map(layerButtonNode => layerButtonNode.querySelector('.glyphicon').getAttribute('class'))) + .toEqual([ 'glyphicon glyphicon-checkbox-on', 'glyphicon glyphicon-unplug' ]); + }); + it('should display list of groups', () => { + ReactDOM.render(, document.getElementById("container")); + const groupsHeaders = document.querySelectorAll('.ms-node-group > .ms-node-header'); + expect(groupsHeaders.length).toBe(2); + expect([...groupsHeaders].map(group => + group.querySelector('.ms-node-title').innerText + )).toEqual(['Group 01', 'Group 02']); + expect( + [...groupsHeaders].map(group => + [...group.querySelectorAll('button > .glyphicon')] + .map((glyph) => glyph.getAttribute('class')) + )) + .toEqual([ + [ 'glyphicon glyphicon-bottom', 'glyphicon glyphicon-checkbox-on', 'glyphicon glyphicon-plug' ], + [ 'glyphicon glyphicon-bottom', 'glyphicon glyphicon-checkbox-off', 'glyphicon glyphicon-unplug' ] + ]); + }); + it('should trigger onChangeLayer by clicking on the unlink button of layers', (done) => { + ReactDOM.render( { + try { + expect(id).toBe('layer.01'); + expect(value).toEqual({ id: 'layer.01', visibility: false }); + } catch (e) { + done(e); + } + done(); + }} + expandedSections={{ layers: true }} + isClippingAvailable + />, document.getElementById("container")); + const layerHeaders = document.querySelectorAll('.ms-node-layer > .ms-node-header'); + expect(layerHeaders.length).toBe(1); + const buttons = layerHeaders[0].querySelectorAll('button'); + expect(buttons.length).toBe(2); + Simulate.click(buttons[1]); + }); + it('should trigger onResetLayer by clicking on the link button of layers', (done) => { + ReactDOM.render( { + try { + expect(id).toBe('layer.01'); + } catch (e) { + done(e); + } + done(); + }} + expandedSections={{ layers: true }} + isClippingAvailable + />, document.getElementById("container")); + const layerHeaders = document.querySelectorAll('.ms-node-layer > .ms-node-header'); + expect(layerHeaders.length).toBe(1); + const buttons = layerHeaders[0].querySelectorAll('button'); + expect(buttons.length).toBe(2); + Simulate.click(buttons[1]); + }); + it('should trigger onChangeGroup by clicking on the unlink button of groups', (done) => { + ReactDOM.render( { + try { + expect(id).toBe('group_01'); + expect(value).toEqual({ id: 'group_01', visibility: true }); + } catch (e) { + done(e); + } + done(); + }} + expandedSections={{ layers: true }} + isClippingAvailable + />, document.getElementById("container")); + const groupHeaders = document.querySelectorAll('.ms-node-group > .ms-node-header'); + expect(groupHeaders.length).toBe(1); + const buttons = groupHeaders[0].querySelectorAll('button'); + expect(buttons.length).toBe(3); + Simulate.click(buttons[2]); + }); + it('should trigger onResetGroup by clicking on the link button of groups', (done) => { + ReactDOM.render( { + try { + expect(id).toBe('group_01'); + } catch (e) { + done(e); + } + done(); + }} + expandedSections={{ layers: true }} + isClippingAvailable + />, document.getElementById("container")); + const groupHeaders = document.querySelectorAll('.ms-node-group > .ms-node-header'); + expect(groupHeaders.length).toBe(1); + const buttons = groupHeaders[0].querySelectorAll('button'); + expect(buttons.length).toBe(3); + Simulate.click(buttons[2]); + }); + + it('should show toolbar with search input and global link and unlink buttons', () => { + ReactDOM.render(, document.getElementById("container")); + const formGroup = document.querySelectorAll('.form-group'); + expect(formGroup.length).toBe(2); + const input = formGroup[1].querySelector('input'); + expect(input).toBeTruthy(); + const buttons = formGroup[1].querySelectorAll('button'); + expect(buttons.length).toBe(2); + }); + + it('should trigger onChange after clicking to link button', (done) => { + ReactDOM.render( { + try { + expect(value).toEqual({ groups: undefined, layers: undefined }); + } catch (e) { + done(e); + } + done(); + }} + expandedSections={{ layers: true }} + isClippingAvailable + />, document.getElementById("container")); + const formGroup = document.querySelectorAll('.form-group'); + expect(formGroup.length).toBe(2); + const buttons = formGroup[1].querySelectorAll('button'); + expect(buttons.length).toBe(2); + Simulate.click(buttons[0]); + }); + + it('should trigger onChange after clicking to unlink button', (done) => { + ReactDOM.render( { + try { + expect(value).toEqual({ + groups: [ { id: 'group_01', visibility: false } ], + layers: [ { id: 'layer.01', visibility: false } ] + }); + } catch (e) { + done(e); + } + done(); + }} + expandedSections={{ layers: true }} + isClippingAvailable + />, document.getElementById("container")); + const formGroup = document.querySelectorAll('.form-group'); + expect(formGroup.length).toBe(2); + const buttons = formGroup[1].querySelectorAll('button'); + expect(buttons.length).toBe(2); + Simulate.click(buttons[1]); }); }); diff --git a/web/client/epics/__tests__/mapviews-test.js b/web/client/epics/__tests__/mapviews-test.js index 94e910ef0a..4717bf4bcf 100644 --- a/web/client/epics/__tests__/mapviews-test.js +++ b/web/client/epics/__tests__/mapviews-test.js @@ -90,6 +90,8 @@ describe('mapviews epics', () => { UPDATE_ADDITIONAL_LAYER ]); + expect(actions[3].options.visibility).toBe(false); + expect(actions[4].options.visibility).toBe(true); expect(actions[4].options.style).toBeTruthy(); expect(actions[4].options.style).toEqual({ format: 'geostyler', @@ -114,9 +116,13 @@ describe('mapviews epics', () => { } }, { layers: { + groups: [ + { id: 'group_01', visibility: false }, + { id: 'group_02', visibility: false } + ], flat: [ - { id: 'layer.01', type: '3dtiles', visibility: true }, - { id: 'layer.02', type: 'vector', visibility: true, features: [feature] } + { id: 'layer.01', group: 'group_01', type: '3dtiles', visibility: true }, + { id: 'layer.02', group: 'group_02', type: 'vector', visibility: true, features: [feature] } ] }, maptype: { @@ -160,6 +166,12 @@ describe('mapviews epics', () => { clippingLayerResourceId: 'resource.01', clippingPolygonFeatureId: 'feature.01' } + ], + groups: [ + { + id: 'group_02', + visibility: true + } ] } ], diff --git a/web/client/epics/mapviews.js b/web/client/epics/mapviews.js index accf8bbe6f..e53a40f903 100644 --- a/web/client/epics/mapviews.js +++ b/web/client/epics/mapviews.js @@ -40,14 +40,15 @@ import { } from '../selectors/mapviews'; import { BROWSE_DATA } from '../actions/layers'; import { CLOSE_FEATURE_GRID } from '../actions/featuregrid'; -import { layersSelector } from '../selectors/layers'; +import { layersSelector, rawGroupsSelector } from '../selectors/layers'; import { isShallowEqualBy } from '../utils/ReselectUtils'; import { getResourceFromLayer } from '../api/MapViews'; -import { MAP_VIEWS_LAYERS_OWNER, formatClippingFeatures } from '../utils/MapViewsUtils'; +import { MAP_VIEWS_LAYERS_OWNER, formatClippingFeatures, isViewLayerChanged, mergeViewGroups, mergeViewLayers } from '../utils/MapViewsUtils'; import { isCesium } from '../selectors/maptype'; +import { getDerivedLayersVisibility } from '../utils/LayersUtils'; const deepCompare = isShallowEqualBy(); @@ -117,22 +118,32 @@ export const updateMapViewsLayers = (action$, store) => const state = store.getState(); const previousView = getPreviousView(state); const currentView = getSelectedMapView(state); - const { layers = [], mask = {}, id: viewId } = currentView || {}; + const { layers = [], groups = [], mask = {}, id: viewId } = currentView || {}; const shouldUpdate = !!( action.type === VISUALIZATION_MODE_CHANGED || !deepCompare(previousView?.layers || [], layers) || !deepCompare(previousView?.mask || {}, mask) + || !deepCompare(previousView?.groups || [], groups) ); if (!shouldUpdate) { return Observable.of( setPreviousView(currentView) ); } + const mapLayers = layersSelector(state); + const mergedGroups = mergeViewGroups( + rawGroupsSelector(state), + currentView, true); + const mergedLayers = mergeViewLayers(mapLayers, currentView); + const updatedLayers = getDerivedLayersVisibility(mergedLayers, mergedGroups); + const changedLayers = updatedLayers.filter((uLayer) => { + const currentLayer = (mapLayers || []).find(layer => layer.id === uLayer.id); + return isViewLayerChanged(uLayer, currentLayer); + }); const resources = getMapViewsResources(state); return updateResourcesObservable(currentView, store) .switchMap((allResources) => { const checkedResources = allResources.filter(({ error }) => !error); - const mapLayers = layersSelector(state); const updatedResources = checkedResources.filter(resource => resource.updated); const maskLayerResource = isString(mask.resourceId) && checkedResources.find((resource) => resource.id === mask.resourceId); return Observable.of( @@ -144,8 +155,7 @@ export const updateMapViewsLayers = (action$, store) => ] : []), setPreviousView(currentView), removeAdditionalLayer({ owner: MAP_VIEWS_LAYERS_OWNER }), - ...layers - .filter((layer) => !!mapLayers.find(mapLayer => mapLayer.id === layer.id)) + ...changedLayers .map((layer) => { const clipPolygonLayerResource = isString(layer.clippingLayerResourceId) && checkedResources.find((resource) => resource.id === layer.clippingLayerResourceId); const clippingPolygon = isString(layer.clippingPolygonFeatureId) @@ -156,7 +166,7 @@ export const updateMapViewsLayers = (action$, store) => 'override', { ...layer, - clippingPolygon + ...(clippingPolygon && { clippingPolygon }) } ); }), diff --git a/web/client/plugins/TOC/components/DefaultGroup.jsx b/web/client/plugins/TOC/components/DefaultGroup.jsx index 5b4ba4a559..1b27b1a071 100644 --- a/web/client/plugins/TOC/components/DefaultGroup.jsx +++ b/web/client/plugins/TOC/components/DefaultGroup.jsx @@ -49,6 +49,7 @@ const DefaultGroupNode = ({ onChange, nodeType, nodeTypes, + config, itemComponent: NodeTool }; return ( diff --git a/web/client/plugins/TOC/components/DefaultLayer.jsx b/web/client/plugins/TOC/components/DefaultLayer.jsx index aa04502634..2a6ddcddde 100644 --- a/web/client/plugins/TOC/components/DefaultLayer.jsx +++ b/web/client/plugins/TOC/components/DefaultLayer.jsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, { useRef, useLayoutEffect, useState } from 'react'; import { castArray, find } from 'lodash'; import { Glyphicon } from 'react-bootstrap'; import { isInsideResolutionsLimits, getLayerTypeGlyph } from '../../../utils/LayersUtils'; @@ -44,6 +44,86 @@ const getLayerVisibilityWarningMessageId = (node, config = {}) => { } return ''; }; + +const NodeLegend = ({ + config, + node, + visible, + onChange +}) => { + + if (config?.layerOptions?.hideLegend) { + return null; + } + const layerType = node?.type; + if (['wfs', 'vector'].includes(layerType)) { + const hasStyle = node?.style?.format === 'geostyler' && node?.style?.body?.rules?.length > 0; + return hasStyle + ? ( + <> +
  • + {visible ? : null} +
  • + + ) + : null; + } + if (layerType === 'wms') { + return ( + <> +
  • + {visible ? : null} +
  • + + ); + } + if (layerType === 'arcgis') { + return ( + <> +
  • + {visible ? : null} +
  • + + ); + } + return null; +}; + +const NodeContent = ({ + error, + config, + node, + visible, + onChange, + items +}) => { + if (error) { + return null; + } + const contentProps = { + config, + node, + onChange, + visible + }; + return <> + {items.map(({ Component, name }) => { + return (); + })} + + ; +}; /** * DefaultLayerNode renders internal part of the layer node * @prop {string} node layer node properties @@ -51,6 +131,7 @@ const getLayerVisibilityWarningMessageId = (node, config = {}) => { * @prop {function} onChange return the changes of a specific node * @prop {object} config optional configuration available for the nodes * @prop {array} nodeToolItems list of node tool component to customize specific tool available on a node, expected structure [ { name, Component } ] + * @prop {array} nodeContentItems list of node content component to customize specific content available after expanding the node, expected structure [ { name, Component } ] * @prop {function} onSelect return the current selected node on click event * @prop {string} nodeType node type * @prop {object} nodeTypes constant values for node types @@ -67,6 +148,7 @@ const DefaultLayerNode = ({ sortHandler, config = {}, nodeToolItems = [], + nodeContentItems = [], onSelect, nodeType, nodeTypes, @@ -76,68 +158,21 @@ const DefaultLayerNode = ({ nodeIcon }) => { - const getContent = () => { - - // currently the only content of the layer is the legend - // so we hide it if not visible - if (error || config?.layerOptions?.hideLegend) { - return null; - } - - const layerType = node?.type; - if (['wfs', 'vector'].includes(layerType)) { - const hasStyle = node?.style?.format === 'geostyler' && node?.style?.body?.rules?.length > 0; - return hasStyle - ? ( - <> -
  • - -
  • - - ) - : null; - } - if (layerType === 'wms') { - return ( - <> -
  • - -
  • - - ); - } - if (layerType === 'arcgis') { - return ( - <> -
  • - -
  • - - ); - } - return null; - }; + const contentNode = useRef(); + const [hasContent, setHasContent] = useState(false); + useLayoutEffect(() => { + setHasContent(!!contentNode?.current?.children?.length); + }, [error, node, config]); const forceExpanded = config?.expanded !== undefined; const expanded = forceExpanded ? config?.expanded : node?.expanded; - const content = getContent(error); const componentProps = { node, onChange, nodeType, nodeTypes, + config, itemComponent: NodeTool }; @@ -160,7 +195,7 @@ const DefaultLayerNode = ({ <> {sortHandler} @@ -184,9 +219,16 @@ const DefaultLayerNode = ({ } /> - {expanded && content ?
      - {content} -
    : null} +
      + +
    { event.stopPropagation(); + event.preventDefault(); if (!disabled) { onChange({ expanded: !expanded }); } diff --git a/web/client/plugins/TOC/components/LayersTree.jsx b/web/client/plugins/TOC/components/LayersTree.jsx index d3597b722a..94dd104080 100644 --- a/web/client/plugins/TOC/components/LayersTree.jsx +++ b/web/client/plugins/TOC/components/LayersTree.jsx @@ -62,6 +62,7 @@ const loopGroupCondition = (groupNode, condition) => { * @prop {string} theme layers tree theme, one of undefined or `legend` * @prop {string} className additional class name for the layer tree * @prop {array} nodeItems list of node component to customize specific nodes, expected structure [ { name, Component, selector } ] + * @prop {array} nodeContentItems list of node content component to customize specific content available after expanding the node, expected structure [ { name, Component } ] * @prop {array} nodeToolItems list of node tool component to customize specific tool available on a node, expected structure [ { name, Component } ] * @prop {object} singleDefaultGroup if true it hides the default group nodes * @prop {string} noFilteredResultsMsgId message id for no result on filter @@ -87,6 +88,7 @@ const LayersTree = ({ className, nodeItems, nodeToolItems, + nodeContentItems, singleDefaultGroup = isSingleDefaultGroup(tree), theme }) => { @@ -204,6 +206,7 @@ const LayersTree = ({ onSelect={onSelect} nodeItems={nodeItems} nodeToolItems={nodeToolItems} + nodeContentItems={nodeContentItems} /> ); })} diff --git a/web/client/plugins/TOC/components/NodeTool.jsx b/web/client/plugins/TOC/components/NodeTool.jsx index 9ba1c95b00..e307373d08 100644 --- a/web/client/plugins/TOC/components/NodeTool.jsx +++ b/web/client/plugins/TOC/components/NodeTool.jsx @@ -49,6 +49,7 @@ function NodeTool({ tooltip={tooltipProp} onClick={(event) => { event.stopPropagation(); + event.preventDefault(); if (!disabled) { onClick(event); } diff --git a/web/client/plugins/TOC/components/TOC.jsx b/web/client/plugins/TOC/components/TOC.jsx index 6801d3f446..c53bc8d795 100644 --- a/web/client/plugins/TOC/components/TOC.jsx +++ b/web/client/plugins/TOC/components/TOC.jsx @@ -34,6 +34,7 @@ import { * @prop {string} filterText filter to apply to layers title * @prop {string} theme layers tree theme, one of undefined or `legend` * @prop {string} className additional class name for the layer tree + * @prop {array} nodeContentItems list of node content component to customize specific content available after expanding the node, expected structure [ { name, Component } ] * @prop {array} nodeItems list of node component to customize specific nodes, expected structure [ { name, Component, selector } ] * @prop {array} nodeToolItems list of node tool component to customize specific tool available on a node, expected structure [ { name, Component } ] * @prop {object} singleDefaultGroup if true it hides the default group nodes @@ -74,6 +75,7 @@ export function ControlledTOC({ className, nodeItems, nodeToolItems, + nodeContentItems, singleDefaultGroup, theme }) { @@ -100,6 +102,7 @@ export function ControlledTOC({ config={config} nodeItems={nodeItems} nodeToolItems={nodeToolItems} + nodeContentItems={nodeContentItems} singleDefaultGroup={singleDefaultGroup} /> ); @@ -108,6 +111,7 @@ export function ControlledTOC({ /** * TOC component that supports map configuration * @prop {object} map map configuration + * @prop {function} onChangeNode return the changed node configuration * @prop {function} onChangeMap return the changed map configuration * @prop {array} selectedNodes list of selected node identifiers * @prop {function} onSelectNode return the current selected node on click event @@ -115,6 +119,7 @@ export function ControlledTOC({ * @prop {string} className additional class name for the layer tree * @prop {array} nodeItems list of node component to customize specific nodes, expected structure [ { name, Component, selector } ] * @prop {array} nodeToolItems list of node tool component to customize specific tool available on a node, expected structure [ { name, Component } ] + * @prop {array} nodeContentItems list of node content component to customize specific content available after expanding the node, expected structure [ { name, Component } ] * @prop {object} singleDefaultGroup if true it hides the default group nodes * @prop {object} config optional configuration available for the nodes * @prop {number} config.resolution map resolution @@ -138,15 +143,18 @@ export function ControlledTOC({ */ function TOC({ map = { layers: [], groups: [] }, + onChangeNode = () => {}, onChangeMap = () => {}, selectedNodes = [], onSelectNode = () => {}, config, className, nodeToolItems, + nodeContentItems, singleDefaultGroup, nodeItems, - theme + theme, + filterText }) { const { layers } = splitMapAndLayers(map) || {}; const tree = denormalizeGroups(layers.flat || [], layers.groups || []).groups; @@ -173,6 +181,7 @@ function TOC({ } } function handleUpdateNode(nodeId, nodeType, options) { + onChangeNode(nodeId, nodeType, options); const updatedNode = changeNodeConfiguration({ groups: layers.groups, layers: layers.flat @@ -188,6 +197,7 @@ function TOC({ className={className} theme={theme} tree={tree} + filterText={filterText} selectedNodes={selectedNodesIdsToObject(selectedNodes, layers.flat, tree)} onSelectNode={onSelectNode} onSort={handleOnSort} @@ -206,6 +216,7 @@ function TOC({ }} nodeItems={nodeItems} nodeToolItems={nodeToolItems} + nodeContentItems={nodeContentItems} singleDefaultGroup={singleDefaultGroup} /> ); diff --git a/web/client/plugins/TOC/components/VisibilityCheck.jsx b/web/client/plugins/TOC/components/VisibilityCheck.jsx index 1281afef79..8005c2dafe 100644 --- a/web/client/plugins/TOC/components/VisibilityCheck.jsx +++ b/web/client/plugins/TOC/components/VisibilityCheck.jsx @@ -40,6 +40,7 @@ const VisibilityCheck = ({ className={`ms-visibility-check${value ? ' active' : ''}`} onClick={(event) => { event.stopPropagation(); + event.preventDefault(); onChange(!value); }} onContextMenu={(event) => { diff --git a/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx b/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx index b5c9647e32..7aab1562cc 100644 --- a/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx +++ b/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx @@ -100,9 +100,27 @@ describe('test DefaultLayer module component', () => { }}/>, document.getElementById("container")); const expand = document.querySelector('.ms-node-expand'); expect(expand).toBeTruthy(); - expect(document.querySelector('.ms-node-layer ul')).toBeFalsy(); + expect(document.querySelector('.ms-node-layer ul').style.display).toBe('none'); TestUtils.Simulate.click(expand); - expect(document.querySelector('.ms-node-layer ul')).toBeTruthy(); + }); + + it('should include custom content with nodeContentItems prop when expanding layer node', () => { + const l = { + id: 'layer00', + name: 'layer00', + title: 'Layer', + visibility: true, + type: 'wms', + expanded: true + }; + function CustomContent() { + return
    ; + } + ReactDOM.render(, document.getElementById("container")); + const expand = document.querySelector('.ms-node-expand'); + expect(expand).toBeTruthy(); + expect(document.querySelector('.ms-node-layer ul').style.display).toBe(''); + expect(document.querySelector('.ms-node-layer #custom-content')).toBeTruthy(); }); it('tests opacity tool', () => { diff --git a/web/client/plugins/mapviews/MapViews.jsx b/web/client/plugins/mapviews/MapViews.jsx index 624a7799f9..d7710054fe 100644 --- a/web/client/plugins/mapviews/MapViews.jsx +++ b/web/client/plugins/mapviews/MapViews.jsx @@ -19,7 +19,7 @@ import { hideViews } from '../../actions/mapviews'; import mapviews from '../../reducers/mapviews'; -import { layersSelector } from '../../selectors/layers'; +import { layersSelector, rawGroupsSelector } from '../../selectors/layers'; import { currentLocaleSelector } from '../../selectors/locale'; import epics from '../../epics/mapviews'; import { @@ -33,6 +33,8 @@ import { } from '../../selectors/mapviews'; import Loader from '../../components/misc/Loader'; import { MAP_VIEWS_CONFIG_KEY } from '../../utils/MapViewsUtils'; +import { flattenArrayOfObjects } from '../../utils/LayersUtils'; +import { isObject } from 'lodash'; const MapViewsSupport = lazy(() => import('../../components/mapviews/MapViewsSupport')); @@ -92,8 +94,9 @@ const ConnectedMapViews = connect( isMapViewsActive, isMapViewsHidden, isMapViewsInitialized, - getMapViewsUpdateUUID - ], (selectedId, views, layers, locale, mapConfig, resources, active, hide, initialized, updateUUID) => ({ + getMapViewsUpdateUUID, + rawGroupsSelector + ], (selectedId, views, layers, locale, mapConfig, resources, active, hide, initialized, updateUUID, groups) => ({ selectedId, views, layers: layers.filter(({ group }) => group !== 'background'), @@ -103,7 +106,8 @@ const ConnectedMapViews = connect( active, hide, initialized, - updateUUID + updateUUID, + groups: flattenArrayOfObjects(groups).filter(isObject) })), { onSelectView: selectView, diff --git a/web/client/selectors/__tests__/mapviews-test.js b/web/client/selectors/__tests__/mapviews-test.js index 95f82bcd7d..a4f104bb8b 100644 --- a/web/client/selectors/__tests__/mapviews-test.js +++ b/web/client/selectors/__tests__/mapviews-test.js @@ -99,7 +99,48 @@ describe('mapviews selectors', () => { ] } }; - expect(getMapViews(state)).toBe(state.mapviews.views); + expect(getMapViews(state)).toEqual(state.mapviews.views); + }); + it('getMapViews remove missing layers and groups', () => { + const state = { + layers: { + flat: [{ id: 'layer.01', group: 'group_01' }], + groups: [{ id: 'group_01' }] + }, + mapviews: { + views: [ + { + center: { + longitude: 8.936900, + latitude: 44.395224, + height: 0 + }, + cameraPosition: { + longitude: 8.939256, + latitude: 44.386982, + height: 655 + }, + id: 'view.1', + title: 'Map view', + description: '', + duration: 10, + flyTo: true, + zoom: 16, + bbox: [ + 8.920925, + 44.390840, + 8.948118, + 44.405544 + ], + layers: [{ id: 'layer.01' }, { id: 'layer.02' }], + groups: [{ id: 'group_01' }, { id: 'group_02' }] + } + ] + } + }; + const newMapViews = getMapViews(state); + expect(newMapViews[0].layers).toEqual([{ id: 'layer.01' }]); + expect(newMapViews[0].groups).toEqual([{ id: 'group_01' }]); }); it('getMapViewsResources', () => { const state = { @@ -205,7 +246,7 @@ describe('mapviews selectors', () => { active: true } }; - expect(getSelectedMapView(state)).toBe(state.mapviews.views[0]); + expect(getSelectedMapView(state)).toEqual(state.mapviews.views[0]); state = { mapviews: { ...state.mapviews, diff --git a/web/client/selectors/mapviews.js b/web/client/selectors/mapviews.js index 133efd7e25..fd2c5b68e7 100644 --- a/web/client/selectors/mapviews.js +++ b/web/client/selectors/mapviews.js @@ -6,10 +6,30 @@ * LICENSE file in the root directory of this source tree. */ +import { DEFAULT_GROUP_ID } from "../utils/LayersUtils"; +import { layersSelector } from "./layers"; + export const isMapViewsActive = state => !!state?.mapviews?.active; export const isMapViewsHidden = state => !!state?.mapviews?.hide; export const getSelectedMapViewId = state => !isMapViewsHidden(state) && isMapViewsActive(state) && state?.mapviews?.selectedId; -export const getMapViews = state => state?.mapviews?.views; +export const getMapViews = state => { + const layers = layersSelector(state); + const layersIds = layers.map(layer => layer.id); + const groupsIds = layers.map(layer => layer.group || DEFAULT_GROUP_ID); + return state?.mapviews?.views ? state.mapviews.views.map(view => { + const viewLayers = view?.layers; + const viewGroups = view?.groups; + return { + ...view, + ...(viewLayers && { + layers: viewLayers.filter(viewLayer => layersIds.includes(viewLayer.id)) + }), + ...(viewGroups && { + groups: viewGroups.filter(viewGroup => groupsIds.includes(viewGroup.id)) + }) + }; + }) : undefined; +}; export const getMapViewsResources = state => state?.mapviews?.resources; export const getResourceById = (state, id) => getMapViewsResources(state)?.find(resource => resource.id === id); export const getPreviousView = state => state?.mapviews?.previousView; diff --git a/web/client/themes/default/less/map-views.less b/web/client/themes/default/less/map-views.less index 364858160f..24b9ecd8af 100644 --- a/web/client/themes/default/less/map-views.less +++ b/web/client/themes/default/less/map-views.less @@ -34,12 +34,6 @@ } .border-color-var(@theme-vars[main-border-color]); } - .ms-map-views-layer-node { - &.changed { - .border-left-color-var(@theme-vars[success]); - } - .border-color-var(@theme-vars[main-border-color]); - } .ms-map-views-section, .ms-map-views-layers-options-header { .background-color-var(@theme-vars[main-bg]); @@ -135,8 +129,11 @@ top: 0; left: 40px; width: 500px; - .btn-default { - border-color: transparent !important; + .ms-layers-tree { + padding: 0; + + .ms-layers-tree { + margin-top: 0.5rem; + } } .form-group.inline { display: flex; @@ -285,31 +282,6 @@ align-items: center; justify-content: center; } - .ms-map-views-layer-node { - border: 1px solid transparent; - padding: 8px; - margin-bottom: 4px; - &.changed { - border-left: 4px solid transparent; - } - .ms-map-views-layer-node-head { - display: flex; - align-items: center; - .ms-map-views-layer-node-title { - flex: 1; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - } - .ms-map-views-layer-node-body { - padding-top: 16px; - padding-left: 32px; - } - .ms-map-views-layer-clipping { - position: relative; - } - } .ms-map-views-layers-options-header { position: sticky; z-index: 2; @@ -322,12 +294,6 @@ margin: 0; } } - .ms-map-views-layers-options-body { - width: 100%; - padding: 0; - margin: 0; - list-style: none; - } .ms-map-views-section { display: flex; align-items: center; diff --git a/web/client/translations/data.da-DK.json b/web/client/translations/data.da-DK.json index bca47de03e..b30947ab5d 100644 --- a/web/client/translations/data.da-DK.json +++ b/web/client/translations/data.da-DK.json @@ -3409,7 +3409,12 @@ "showViewsGeometries": "Show views positions", "addInitialView": "Click on the plus button to add a new view", "removeView": "Remove view", - "resetLayerOverrides": "Reset changes to align layer to default value", + "linkAllNodes": "Visibiliy and opacity properties of layers and groups will be connected with the main table of content", + "unlinkAllNodes": "Visibiliy and opacity properties of layers and groups will be disconneded from the main table of content", + "layersLinked": "Visibiliy and opacity properties are connected to the main table of content", + "layersUnlinked": "Visibiliy and opacity properties are disconnected from the main table of content", + "groupsLinked": "Visibiliy property is connected to the main table of content", + "groupsUnlinked": "Visibiliy property is disconnected from the main table of content", "hideLayer": "Hide layer", "showLayer": "Show layer", "undoChanges": "Undo changes", diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index df6b185b0e..5c61e664ab 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -3715,7 +3715,12 @@ "showViewsGeometries": "Views-Positionen anzeigen", "addInitialView": "Klicken Sie auf die Plus-Schaltfläche, um eine neue Ansicht hinzuzufügen", "removeView": "Ansicht entfernen", - "resetLayerOverrides": "Änderungen zurücksetzen, um die Ebene auf den Standardwert auszurichten", + "linkAllNodes": "Sichtbarkeits- und Opazitätseigenschaften von Ebenen und Gruppen werden mit dem Hauptinhaltsverzeichnis verknüpft", + "unlinkAllNodes": "Sichtbarkeits- und Opazitätseigenschaften von Ebenen und Gruppen werden vom Hauptinhaltsverzeichnis getrennt", + "layersLinked": "Sichtbarkeits- und Opazitätseigenschaften sind mit dem Hauptinhaltsverzeichnis verknüpft", + "layersUnlinked": "Sichtbarkeits- und Opazitätseigenschaften sind vom Hauptinhaltsverzeichnis getrennt", + "groupsLinked": "Sichtbarkeitseigenschaft ist mit dem Hauptinhaltsverzeichnis verknüpft", + "groupsUnlinked": "Sichtbarkeitseigenschaft ist vom Hauptinhaltsverzeichnis getrennt", "hideLayer": "Ebene ausblenden", "showLayer": "Ebene anzeigen", "undoChanges": "Änderungen rückgängig machen", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index d0d7e09dd9..02595d66d6 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -3688,7 +3688,12 @@ "showViewsGeometries": "Show views positions", "addInitialView": "Click on the plus button to add a new view", "removeView": "Remove view", - "resetLayerOverrides": "Reset changes to align layer to default value", + "linkAllNodes": "Visibiliy and opacity properties of layers and groups will be connected with the main table of content", + "unlinkAllNodes": "Visibiliy and opacity properties of layers and groups will be disconneded from the main table of content", + "layersLinked": "Visibiliy and opacity properties are connected to the main table of content", + "layersUnlinked": "Visibiliy and opacity properties are disconnected from the main table of content", + "groupsLinked": "Visibiliy property is connected to the main table of content", + "groupsUnlinked": "Visibiliy property is disconnected from the main table of content", "hideLayer": "Hide layer", "showLayer": "Show layer", "undoChanges": "Undo changes", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index 05ee0381eb..95460de5c8 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -3677,7 +3677,12 @@ "showViewsGeometries": "Mostrar posiciones de vistas", "addInitialView": "Haga clic en el botón más para agregar una nueva vista", "removeView": "Eliminar vista", - "resetLayerOverrides": "Restablecer cambios para alinear la capa al valor predeterminado", + "linkAllNodes": "Las propiedades de visibilidad y opacidad de las capas y los grupos se conectarán con la tabla de contenido principal", + "unlinkAllNodes": "Las propiedades de visibilidad y opacidad de las capas y los grupos se desconectarán de la tabla de contenido principal", + "layersLinked": "Las propiedades de visibilidad y opacidad están conectadas a la tabla de contenido principal", + "layersUnlinked": "Las propiedades de visibilidad y opacidad están desconectadas de la tabla de contenido principal", + "groupsLinked": "La propiedad de visibilidad está conectada a la tabla de contenido principal", + "groupsUnlinked": "La propiedad de visibilidad está desconectada de la tabla de contenido principal", "hideLayer": "Ocultar capa", "showLayer": "Mostrar capa", "undoChanges": "Deshacer cambios", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 61c8d8f522..f1cbd8864b 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -3678,7 +3678,12 @@ "showViewsGeometries": "Afficher les positions des vues", "addInitialView": "Cliquez sur le bouton plus pour ajouter une nouvelle vue", "removeView": "Supprimer la vue", - "resetLayerOverrides": "Réinitialise les modifications pour aligner le calque sur la valeur par défaut", + "linkAllNodes": "Les propriétés de visibilité et d'opacité des calques et des groupes seront connectées à la table des matières principale", + "unlinkAllNodes": "Les propriétés de visibilité et d'opacité des calques et des groupes seront déconnectées de la table des matières principale", + "layersLinked": "Les propriétés de visibilité et d'opacité sont connectées à la table des matières principale", + "layersUnlinked": "Les propriétés de visibilité et d'opacité sont déconnectées de la table des matières principale", + "groupsLinked": "La propriété de visibilité est connectée à la table des matières principale", + "groupsUnlinked": "La propriété de visibilité est déconnectée de la table des matières principale", "hideLayer": "Masquer le calque", "showLayer": "Afficher le calque", "undoChanges": "Annuler les modifications", diff --git a/web/client/translations/data.is-IS.json b/web/client/translations/data.is-IS.json index 0a63e5d63a..c4dac582a1 100644 --- a/web/client/translations/data.is-IS.json +++ b/web/client/translations/data.is-IS.json @@ -3433,7 +3433,12 @@ "showViewsGeometries": "Show views positions", "addInitialView": "Click on the plus button to add a new view", "removeView": "Remove view", - "resetLayerOverrides": "Reset changes to align layer to default value", + "linkAllNodes": "Visibiliy and opacity properties of layers and groups will be connected with the main table of content", + "unlinkAllNodes": "Visibiliy and opacity properties of layers and groups will be disconneded from the main table of content", + "layersLinked": "Visibiliy and opacity properties are connected to the main table of content", + "layersUnlinked": "Visibiliy and opacity properties are disconnected from the main table of content", + "groupsLinked": "Visibiliy property is connected to the main table of content", + "groupsUnlinked": "Visibiliy property is disconnected from the main table of content", "hideLayer": "Hide layer", "showLayer": "Show layer", "undoChanges": "Undo changes", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 9c2e32872f..8d2905dbc3 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -3676,7 +3676,12 @@ "showViewsGeometries": "Mostra le posizioni delle viste", "addInitialView": "Fai clic sul pulsante più per aggiungere una nuova vista", "removeView": "Rimuovi vista", - "resetLayerOverrides": "Ripristina le modifiche per allineare il livello al valore predefinito", + "linkAllNodes": "Le proprietà di visibilità e opacità di livelli e gruppi saranno collegate alla lista dei layer principale", + "unlinkAllNodes": "Le proprietà di visibilità e opacità di livelli e gruppi saranno scollegate dalla lista dei layer principale", + "layersLinked": "Le proprietà di visibilità e opacità sono collegate alla lista dei layer principale", + "layersUnlinked": "Le proprietà di visibilità e opacità sono scollegate dalla lista dei layer principale", + "groupsLinked": "La proprietà di visibilità è collegata alla lista dei layer principale", + "groupsUnlinked": "La proprietà di visibilità è scollegata dalla lista dei layer principale", "hideLayer": "Nascondi layer", "showLayer": "Mostra layer", "undoChanges": "Annulla modifiche", diff --git a/web/client/translations/data.nl-NL.json b/web/client/translations/data.nl-NL.json index f88e2c7bf2..79610ea350 100644 --- a/web/client/translations/data.nl-NL.json +++ b/web/client/translations/data.nl-NL.json @@ -3676,7 +3676,12 @@ "showViewsGeometries": "Toon positie van de overzichten", "addInitialView": "Klik op de plusknop om een nieuw overzicht toe te voegen", "removeView": "Verwijder overzicht", - "resetLayerOverrides": "Aanpassingen resetten naar standaardwaarden", + "linkAllNodes": "Visibiliy and opacity properties of layers and groups will be connected with the main table of content", + "unlinkAllNodes": "Visibiliy and opacity properties of layers and groups will be disconneded from the main table of content", + "layersLinked": "Visibiliy and opacity properties are connected to the main table of content", + "layersUnlinked": "Visibiliy and opacity properties are disconnected from the main table of content", + "groupsLinked": "Visibiliy property is connected to the main table of content", + "groupsUnlinked": "Visibiliy property is disconnected from the main table of content", "hideLayer": "Verberg laag", "showLayer": "Toon laag", "undoChanges": "Aanpassingen ongedaan maken", diff --git a/web/client/utils/MapViewsUtils.js b/web/client/utils/MapViewsUtils.js index e66030ca0b..63cc35cc0a 100644 --- a/web/client/utils/MapViewsUtils.js +++ b/web/client/utils/MapViewsUtils.js @@ -5,7 +5,7 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ -import isNumber from 'lodash/isNumber'; +import { isUndefined, omitBy, isNumber, isObject } from 'lodash'; const METERS_PER_DEGREES = 111194.87428468118; @@ -99,6 +99,77 @@ export const mergeViewLayers = (layers, { layers: viewLayers = [] } = {}) => { return layer; }); }; +/** + * detect if a view layer is different from the map layers + * @param {object} viewLayer layer object + * @param {object} mapLayer layer object + */ +export const isViewLayerChanged = (viewLayer, mapLayer) => { + return viewLayer.visibility !== mapLayer.visibility + || viewLayer.opacity !== mapLayer.opacity + || viewLayer.clippingLayerResourceId !== mapLayer.clippingLayerResourceId + || viewLayer.clippingPolygonFeatureId !== mapLayer.clippingPolygonFeatureId + || viewLayer.clippingPolygonUnion !== mapLayer.clippingPolygonUnion; +}; +/** + * pick view layer properties + * @param {object} node layer object + */ +export const pickViewLayerProperties = (node) => { + return omitBy({ + id: node.id, + visibility: node.visibility, + opacity: node.opacity, + clippingLayerResourceId: node.clippingLayerResourceId, + clippingPolygonFeatureId: node.clippingPolygonFeatureId, + clippingPolygonUnion: node.clippingPolygonUnion + }, isUndefined); +}; +/** + * pick view group properties + * @param {object} node group object + */ +export const pickViewGroupProperties = (node) => { + return omitBy({ + id: node.id, + visibility: node.visibility + }, isUndefined); +}; +/** + * merge the configuration of view groups in the main groups array + * @param {array} rawGroups array of group object + * @param {object} view map view configuration + * @param {boolean} recursive apply recursive merge instead of flat one + */ +export const mergeViewGroups = (groups, { groups: viewGroups = [] } = {}, recursive) => { + if (viewGroups.length === 0) { + return groups || []; + } + if (recursive) { + const recursiveMerge = (nodes) => { + return nodes.map((node) => { + if (isObject(node)) { + const viewGroup = viewGroups.find(vGroup => vGroup.id === node.id); + return { + ...node, + ...viewGroup, + ...(node.nodes && { nodes: recursiveMerge(node.nodes) }), + changed: true + }; + } + return node; + }); + }; + return recursiveMerge(groups || []); + } + return (groups || []).map((group) => { + const viewGroup = viewGroups.find(vGroup => vGroup.id === group.id); + if (viewGroup) { + return { ...group, ...viewGroup, changed: true }; + } + return group; + }); +}; /** * Exclude all geometry but polygons and ensure each feature has an identifier * @param {array} features array of GeoJSON features diff --git a/web/client/utils/__tests__/MapViewsUtils-test.js b/web/client/utils/__tests__/MapViewsUtils-test.js index 75a9532b0a..9518af5660 100644 --- a/web/client/utils/__tests__/MapViewsUtils-test.js +++ b/web/client/utils/__tests__/MapViewsUtils-test.js @@ -12,7 +12,11 @@ import { formatClippingFeatures, getZoomFromHeight, getHeightFromZoom, - cleanMapViewSavedPayload + cleanMapViewSavedPayload, + isViewLayerChanged, + pickViewLayerProperties, + pickViewGroupProperties, + mergeViewGroups } from '../MapViewsUtils'; describe('Test MapViewsUtils', () => { @@ -390,4 +394,55 @@ describe('Test MapViewsUtils', () => { ] ); }); + it('isViewLayerChanged', () => { + expect(isViewLayerChanged({ }, { })).toBe(false); + expect(isViewLayerChanged({ title: 'Old title' }, { title: 'New title' })).toBe(false); + expect(isViewLayerChanged({ visibility: false }, { visibility: true })).toBe(true); + expect(isViewLayerChanged({ opacity: 0 }, { opacity: 1 })).toBe(true); + expect(isViewLayerChanged({ clippingLayerResourceId: '01' }, { clippingLayerResourceId: '02' })).toBe(true); + expect(isViewLayerChanged({ clippingPolygonFeatureId: '01' }, { clippingPolygonFeatureId: '02' })).toBe(true); + expect(isViewLayerChanged({ clippingPolygonUnion: false }, { clippingPolygonUnion: true })).toBe(true); + }); + it('pickViewLayerProperties', () => { + expect(pickViewLayerProperties({ + id: '01', + title: 'Layer', + url: '/url/to/wms', + type: 'wms', + layer: 'layer', + visibility: true, + opacity: 1, + clippingLayerResourceId: '01', + clippingPolygonFeatureId: '01', + clippingPolygonUnion: false, + expanded: false + })).toEqual({ + id: '01', + visibility: true, + opacity: 1, + clippingLayerResourceId: '01', + clippingPolygonFeatureId: '01', + clippingPolygonUnion: false + }); + }); + it('pickViewGroupProperties', () => { + expect(pickViewGroupProperties({ + id: '01', + title: 'Group', + visibility: true, + expanded: false + })).toEqual({ + id: '01', + visibility: true + }); + }); + it('mergeViewGroups', () => { + expect(mergeViewGroups([{ id: '01', visibility: false }])).toEqual([{ id: '01', visibility: false }]); + expect(mergeViewGroups([{ id: '01', visibility: false }, { id: '02', visibility: false }], { groups: [{ id: '01', visibility: true }] })) + .toEqual([{ id: '01', visibility: true, changed: true }, { id: '02', visibility: false }]); + expect(mergeViewGroups( + [{ id: '01', visibility: false, nodes: ['layer01', { id: '02', visibility: false }] }], + { groups: [{ id: '02', visibility: true }] } + ), true).toEqual([ { id: '01', visibility: false, nodes: [ 'layer01', { id: '02', visibility: false } ] } ]); + }); });