From d1ebec1ff7cd8546d8aec56f3b02da427e76d1f9 Mon Sep 17 00:00:00 2001 From: Suren Date: Thu, 19 Dec 2024 20:06:23 +0530 Subject: [PATCH] #10684: Legend filtering for GeoServer WMS layers (#10718) --- web/client/api/WMS.js | 20 +- .../TOC/fragments/settings/Display.jsx | 16 +- .../widgets/builder/wizard/map/TOC.jsx | 3 + .../wizard/map/enhancers/nodeEditor.js | 3 + .../widgets/enhancers/legendWidget.js | 8 +- .../components/widgets/widget/LegendView.jsx | 7 +- web/client/plugins/Print.jsx | 3 +- web/client/plugins/TOC/components/Legend.jsx | 49 ++- .../components/StyleBasedWMSJsonLegend.jsx | 133 ++++++--- web/client/plugins/TOC/components/TOC.jsx | 13 +- .../plugins/TOC/components/WMSLegend.jsx | 23 +- .../StyleBasedWMSJsonLegend-test.jsx | 170 +++++++++-- .../components/__tests__/WMSLegend-test.jsx | 8 +- web/client/plugins/TOC/index.js | 14 +- .../tocitemssettings/defaultSettingsTabs.js | 2 +- web/client/translations/data.de-DE.json | 7 +- web/client/translations/data.en-US.json | 7 +- web/client/translations/data.es-ES.json | 7 +- web/client/translations/data.fr-FR.json | 7 +- web/client/translations/data.it-IT.json | 5 +- web/client/utils/FilterUtils.js | 16 +- web/client/utils/LegendUtils.js | 123 ++++++++ web/client/utils/PrintUtils.js | 50 ++-- .../utils/__tests__/FilterUtils-test.js | 19 +- .../utils/__tests__/LegendUtils-test.js | 280 ++++++++++++++++++ 25 files changed, 824 insertions(+), 169 deletions(-) create mode 100644 web/client/utils/LegendUtils.js create mode 100644 web/client/utils/__tests__/LegendUtils-test.js diff --git a/web/client/api/WMS.js b/web/client/api/WMS.js index 6f680ff988..94f3e05aac 100644 --- a/web/client/api/WMS.js +++ b/web/client/api/WMS.js @@ -10,7 +10,7 @@ import urlUtil from 'url'; import { isArray, castArray, get } from 'lodash'; import xml2js from 'xml2js'; import axios from '../libs/ajax'; -import { getConfigProp } from '../utils/ConfigUtils'; +import ConfigUtils, { getConfigProp } from '../utils/ConfigUtils'; import { getWMSBoundingBox } from '../utils/CoordinatesUtils'; import { isValidGetMapFormat, isValidGetFeatureInfoFormat } from '../utils/WMSUtils'; const capabilitiesCache = {}; @@ -323,15 +323,25 @@ export const getSupportedFormat = (url, includeGFIFormats = false) => { let layerLegendJsonData = {}; export const getJsonWMSLegend = (url) => { - const request = layerLegendJsonData[url] - ? () => Promise.resolve(layerLegendJsonData[url]) - : () => axios.get(url).then((response) => { + let request; + + // enables caching of the JSON legend for a specified duration, + // while providing the possibility of re-fetching the legend data in case of external modifications + const cached = layerLegendJsonData[url]; + if (cached && new Date().getTime() < cached.timestamp + (ConfigUtils.getConfigProp('cacheExpire') || 60) * 1000) { + request = () => Promise.resolve(cached.data); + } else { + request = () => axios.get(url).then((response) => { if (typeof response?.data === 'string' && response.data.includes("Exception")) { throw new Error("Faild to get json legend"); } - layerLegendJsonData[url] = response?.data?.Legend; + layerLegendJsonData[url] = { + timestamp: new Date().getTime(), + data: response?.data?.Legend + }; return response?.data?.Legend || []; }); + } return request().then((data) => data).catch(err => { throw err; }); diff --git a/web/client/components/TOC/fragments/settings/Display.jsx b/web/client/components/TOC/fragments/settings/Display.jsx index 2b7a97d497..93eb288ce5 100644 --- a/web/client/components/TOC/fragments/settings/Display.jsx +++ b/web/client/components/TOC/fragments/settings/Display.jsx @@ -6,10 +6,14 @@ * LICENSE file in the root directory of this source tree. */ -import { clamp, isNil, isNumber } from 'lodash'; -import PropTypes from 'prop-types'; import React from 'react'; +import clamp from 'lodash/clamp'; +import isNil from 'lodash/isNil'; +import isNumber from 'lodash/isNumber'; +import pick from 'lodash/pick'; +import PropTypes from 'prop-types'; import {Checkbox, Col, ControlLabel, FormGroup, Glyphicon, Grid, Row, Button as ButtonRB } from 'react-bootstrap'; + import tooltip from '../../../misc/enhancers/buttonTooltip'; const Button = tooltip(ButtonRB); import IntlNumberFormControl from '../../../I18N/IntlNumberFormControl'; @@ -26,6 +30,7 @@ import ThreeDTilesSettings from './ThreeDTilesSettings'; import ModelTransformation from './ModelTransformation'; import StyleBasedWMSJsonLegend from '../../../../plugins/TOC/components/StyleBasedWMSJsonLegend'; import { getMiscSetting } from '../../../../utils/ConfigUtils'; + export default class extends React.Component { static propTypes = { opacityText: PropTypes.node, @@ -38,6 +43,8 @@ export default class extends React.Component { isLocalizedLayerStylesEnabled: PropTypes.bool, isCesiumActive: PropTypes.bool, projection: PropTypes.string, + mapSize: PropTypes.object, + mapBbox: PropTypes.object, resolutions: PropTypes.array, zoom: PropTypes.number, hideInteractiveLegendOption: PropTypes.bool @@ -122,6 +129,9 @@ export default class extends React.Component { } return null; }; + getLegendProps = () => { + return pick(this.props, ['projection', 'mapSize', 'mapBbox']); + } render() { const formatValue = this.props.element && this.props.element.format || "image/png"; const experimentalInteractiveLegend = getMiscSetting('experimentalInteractiveLegend', false); @@ -324,6 +334,7 @@ export default class extends React.Component { this.useLegendOptions() && this.state.legendOptions.legendWidth || undefined} language={ this.props.isLocalizedLayerStylesEnabled ? this.props.currentLocaleLanguage : undefined} + {...this.getLegendProps()} /> : } diff --git a/web/client/components/widgets/builder/wizard/map/TOC.jsx b/web/client/components/widgets/builder/wizard/map/TOC.jsx index 17c899c28c..e71827eb05 100644 --- a/web/client/components/widgets/builder/wizard/map/TOC.jsx +++ b/web/client/components/widgets/builder/wizard/map/TOC.jsx @@ -35,6 +35,9 @@ function WidgetTOC({ visualizationMode: map?.visualizationMode, layerOptions: { legendOptions: { + projection: map?.projection, + mapSize: map?.size, + mapBbox: map?.bbox, WMSLegendOptions: 'forceLabels:on', scaleDependent: true, legendWidth: 12, diff --git a/web/client/components/widgets/builder/wizard/map/enhancers/nodeEditor.js b/web/client/components/widgets/builder/wizard/map/enhancers/nodeEditor.js index 29e682fa63..aab1cb246f 100644 --- a/web/client/components/widgets/builder/wizard/map/enhancers/nodeEditor.js +++ b/web/client/components/widgets/builder/wizard/map/enhancers/nodeEditor.js @@ -80,6 +80,9 @@ export default compose( : 1 } }, + projection: map.projection, + mapSize: map.size, + mapBbox: map.bbox, groups: get(splitMapAndLayers(map), 'layers.groups') })), // adapter for handlers diff --git a/web/client/components/widgets/enhancers/legendWidget.js b/web/client/components/widgets/enhancers/legendWidget.js index 635e3b23a6..dc28131d24 100644 --- a/web/client/components/widgets/enhancers/legendWidget.js +++ b/web/client/components/widgets/enhancers/legendWidget.js @@ -13,6 +13,7 @@ import { editableWidget, defaultIcons, withHeaderTools } from './tools'; import { getScales } from '../../../utils/MapUtils'; import { WIDGETS_MAPS_REGEX } from "../../../actions/widgets"; import { getInactiveNode, DEFAULT_GROUP_ID } from '../../../utils/LayersUtils'; +import { updateLayerWithLegendFilters } from '../../../utils/LegendUtils'; /** * map dependencies to layers, scales and current zoom level to show legend items for current zoom. @@ -22,19 +23,22 @@ export default compose( withProps(({ dependencies = {}, dependenciesMap = {} }) => { const allLayers = dependencies[dependenciesMap.layers] || dependencies.layers || []; const groups = castArray(dependencies[dependenciesMap.groups] || dependencies.groups || []); - const layers = allLayers + let layers = allLayers // filter backgrounds and inactive layer // the inactive layers are the one with a not visible parent group .filter((layer = {}) => layer.group !== 'background' && !getInactiveNode(layer?.group || DEFAULT_GROUP_ID, groups) ) .map(({ group, ...layer }) => layer); + layers = updateLayerWithLegendFilters(layers, dependencies); return { allLayers, map: { layers, // use empty so it creates the default group that will be hidden in the layers tree - groups: [] + groups: [], + projection: dependencies.projection, + bbox: dependencies.viewport }, dependencyMapPath: dependenciesMap.layers || '', scales: getScales( diff --git a/web/client/components/widgets/widget/LegendView.jsx b/web/client/components/widgets/widget/LegendView.jsx index 69a3b35fac..b7dbe1e004 100644 --- a/web/client/components/widgets/widget/LegendView.jsx +++ b/web/client/components/widgets/widget/LegendView.jsx @@ -35,7 +35,12 @@ export default ({ scales, zoom: currentZoomLvl, layerOptions: { - legendOptions: legendProps, + legendOptions: { + ...legendProps, + projection: map?.projection, + mapSize: map?.size, + mapBbox: map?.bbox + }, hideFilter: true } }} diff --git a/web/client/plugins/Print.jsx b/web/client/plugins/Print.jsx index 045f83f430..12d830b4af 100644 --- a/web/client/plugins/Print.jsx +++ b/web/client/plugins/Print.jsx @@ -605,7 +605,8 @@ export default { this.props.onBeforePrint(); this.props.printingService.print({ layers: this.getMapConfiguration()?.layers, - scales: this.props.useFixedScales ? getPrintScales(this.props.capabilities) : undefined + scales: this.props.useFixedScales ? getPrintScales(this.props.capabilities) : undefined, + bbox: this.props.map?.bbox }) .then((spec) => this.props.onPrint(this.props.capabilities.createURL, { ...spec, ...this.props.overrideOptions }) diff --git a/web/client/plugins/TOC/components/Legend.jsx b/web/client/plugins/TOC/components/Legend.jsx index 9646c8c71a..7f2e3bada7 100644 --- a/web/client/plugins/TOC/components/Legend.jsx +++ b/web/client/plugins/TOC/components/Legend.jsx @@ -6,12 +6,12 @@ * LICENSE file in the root directory of this source tree. */ -import urlUtil from 'url'; - -import { isArray, isNil } from 'lodash'; -import assign from 'object-assign'; import PropTypes from 'prop-types'; import React from 'react'; +import urlUtil from 'url'; +import isArray from 'lodash/isArray'; +import isNil from 'lodash/isNil'; +import pick from 'lodash/pick'; import { addAuthenticationToSLD, @@ -21,6 +21,8 @@ import Message from '../../../components/I18N/Message'; import SecureImage from '../../../components/misc/SecureImage'; import { randomInt } from '../../../utils/RandomUtils'; +import { normalizeSRS } from '../../../utils/CoordinatesUtils'; +import { getWMSLegendConfig, LEGEND_FORMAT } from '../../../utils/LegendUtils'; /** * Legend renders the wms legend image @@ -44,7 +46,10 @@ class Legend extends React.Component { currentZoomLvl: PropTypes.number, scales: PropTypes.array, scaleDependent: PropTypes.bool, - language: PropTypes.string + language: PropTypes.string, + projection: PropTypes.string, + mapSize: PropTypes.object, + bbox: PropTypes.object }; static defaultProps = { @@ -86,26 +91,20 @@ class Legend extends React.Component { const cleanParams = clearNilValuesForParams(layer.params); const scale = this.getScale(props); - let query = assign( - {}, - { - service: "WMS", - request: "GetLegendGraphic", - format: "image/png", - height: props.legendHeight, - width: props.legendWidth, - layer: layer.name, - style: layer.style || null, - version: layer.version || "1.3.0", - SLD_VERSION: "1.1.0", - LEGEND_OPTIONS: props.legendOptions - }, - layer.legendParams || {}, - props.language && layer.localizedLayerStyles ? {LANGUAGE: props.language} : {}, - addAuthenticationToSLD(cleanParams || {}, props.layer), - cleanParams && cleanParams.SLD_BODY ? {SLD_BODY: cleanParams.SLD_BODY} : {}, - scale !== null ? { SCALE: scale } : {} - ); + const projection = normalizeSRS(this.props.projection || 'EPSG:3857', layer.allowedSRS); + const query = { + ...getWMSLegendConfig({ + layer, + format: LEGEND_FORMAT.IMAGE, + ...pick(props, ['legendHeight', 'legendWidth', 'mapSize', 'legendOptions', 'mapBbox']), + projection + }), + ...layer.legendParams, + ...(props.language && layer.localizedLayerStyles ? { LANGUAGE: props.language } : {}), + ...addAuthenticationToSLD(cleanParams || {}, props.layer), + ...(cleanParams && cleanParams.SLD_BODY ? { SLD_BODY: cleanParams.SLD_BODY } : {}), + ...(scale !== null ? { SCALE: scale } : {}) + }; return urlUtil.format({ host: urlObj.host, diff --git a/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx b/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx index 4a0a54bdc3..0bac5e6f36 100644 --- a/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx +++ b/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx @@ -6,24 +6,31 @@ * LICENSE file in the root directory of this source tree. */ -import urlUtil from 'url'; - -import { isArray, isNil } from 'lodash'; -import assign from 'object-assign'; import PropTypes from 'prop-types'; import React from 'react'; -import { Tooltip, Glyphicon } from 'react-bootstrap'; +import urlUtil from 'url'; +import isArray from 'lodash/isArray'; +import isNil from 'lodash/isNil'; +import pick from 'lodash/pick'; +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import isEmpty from 'lodash/isEmpty'; +import { Alert, Tooltip, Glyphicon } from 'react-bootstrap'; + +import { ButtonWithTooltip } from '../../../components/misc/Button'; import Loader from '../../../components/misc/Loader'; import WMSJsonLegendIcon from '../../../components/styleeditor/WMSJsonLegendIcon'; +import Message from '../../../components/I18N/Message'; +import OverlayTrigger from '../../../components/misc/OverlayTrigger'; import { addAuthenticationParameter, addAuthenticationToSLD, clearNilValuesForParams } from '../../../utils/SecurityUtils'; import { getJsonWMSLegend } from '../../../api/WMS'; -import Message from '../../../components/I18N/Message'; -import {updateLayerLegendFilter} from '../../../utils/FilterUtils'; -import OverlayTrigger from '../../../components/misc/OverlayTrigger'; +import { updateLayerLegendFilter } from '../../../utils/FilterUtils'; +import { normalizeSRS } from '../../../utils/CoordinatesUtils'; +import { getLayerFilterByLegendFormat, getWMSLegendConfig, INTERACTIVE_LEGEND_ID, LEGEND_FORMAT } from '../../../utils/LegendUtils'; class StyleBasedWMSJsonLegend extends React.Component { static propTypes = { layer: PropTypes.object, @@ -36,7 +43,10 @@ class StyleBasedWMSJsonLegend extends React.Component { scaleDependent: PropTypes.bool, language: PropTypes.string, onChange: PropTypes.func, - owner: PropTypes.string + owner: PropTypes.string, + projection: PropTypes.string, + mapSize: PropTypes.object, + mapBbox: PropTypes.object }; static defaultProps = { @@ -60,12 +70,24 @@ class StyleBasedWMSJsonLegend extends React.Component { componentDidUpdate(prevProps) { const prevLayerStyle = prevProps?.layer?.style; const currentLayerStyle = this.props?.layer?.style; - // get the new json legend and rerender it in case change style - if (currentLayerStyle !== prevLayerStyle) { + + const [prevFilter, currFilter] = [prevProps?.layer, this.props?.layer] + .map(_layer => getLayerFilterByLegendFormat(_layer, LEGEND_FORMAT.JSON)); + + // get the new json legend and rerender in case of change in style or layer filter + if (!isEqual(prevLayerStyle, currentLayerStyle) + || !isEqual(prevFilter, currFilter) + || !isEqual(prevProps.mapBbox, this.props.mapBbox) + ) { this.getLegendData(); } } + onResetLegendFilter = () => { + const newLayerFilter = updateLayerLegendFilter(this.props?.layer?.layerFilter); + this.props.onChange({ layerFilter: newLayerFilter }); + } + getLegendData() { let jsonLegendUrl = this.getUrl(this.props); if (!jsonLegendUrl) { @@ -88,6 +110,7 @@ class StyleBasedWMSJsonLegend extends React.Component { } return null; }; + getUrl = (props, urlIdx) => { if (props.layer && props.layer.type === "wms" && props.layer.url) { const layer = props.layer; @@ -101,22 +124,20 @@ class StyleBasedWMSJsonLegend extends React.Component { const cleanParams = clearNilValuesForParams(layer.params); const scale = this.getScale(props); - let query = assign({}, { - service: "WMS", - request: "GetLegendGraphic", - format: "application/json", - height: props.legendHeight, - width: props.legendWidth, - layer: layer.name, - style: layer.style || null, - version: layer.version || "1.3.0", - SLD_VERSION: "1.1.0", - LEGEND_OPTIONS: props.legendOptions - }, layer.legendParams || {}, - props.language && layer.localizedLayerStyles ? {LANGUAGE: props.language} : {}, - addAuthenticationToSLD(cleanParams || {}, props.layer), - cleanParams && cleanParams.SLD_BODY ? {SLD_BODY: cleanParams.SLD_BODY} : {}, - scale !== null ? { SCALE: scale } : {}); + const projection = normalizeSRS(props.projection || 'EPSG:3857', layer.allowedSRS); + const query = { + ...getWMSLegendConfig({ + layer, + format: LEGEND_FORMAT.JSON, + ...pick(props, ['legendHeight', 'legendWidth', 'mapSize', 'legendOptions', 'mapBbox']), + projection + }), + ...layer.legendParams, + ...(props.language && layer.localizedLayerStyles ? { LANGUAGE: props.language } : {}), + ...addAuthenticationToSLD(cleanParams || {}, props.layer), + ...(cleanParams && cleanParams.SLD_BODY ? { SLD_BODY: cleanParams.SLD_BODY } : {}), + ...(scale !== null ? { SCALE: scale } : {}) + }; addAuthenticationParameter(url, query); return urlUtil.format({ @@ -128,24 +149,52 @@ class StyleBasedWMSJsonLegend extends React.Component { } return ''; } + renderRules = (rules) => { - const isLegendFilterIncluded = this.props?.layer?.layerFilter?.filters?.find(f=>f.id === 'interactiveLegend'); - const legendFilters = isLegendFilterIncluded ? isLegendFilterIncluded?.filters : []; - return (rules || []).map((rule) => { - const isFilterExistBefore = legendFilters?.find(f => f.id === rule.filter); - const isFilterDisabled = this.props?.layer?.layerFilter?.disabled; - const activeFilter = rule.filter && isFilterExistBefore; - return (
this.filterWMSLayerHandler(rule.filter)}> - - {rule.name || rule.title || ''} -
); - }); + const layerFilter = get(this.props, 'layer.layerFilter', {}); + const interactiveLegendFilters = get(layerFilter, 'filters', []).find(f => f.id === INTERACTIVE_LEGEND_ID); + const legendFilters = get(interactiveLegendFilters, 'filters', []); + const showResetWarning = !this.checkPreviousFiltersAreValid(rules, legendFilters) && !layerFilter.disabled; + return ( + <> + {showResetWarning ? +
+ + + +
: null} + {isEmpty(rules) + ? + : rules.map((rule, idx) => { + const activeFilter = legendFilters?.some(f => f.id === rule.filter); + const isFilterDisabled = this.props?.layer?.layerFilter?.disabled; + return ( +
this.filterWMSLayerHandler(rule.filter)}> + + {rule.name || rule.title || ''} +
+ ); + }) + } + + ); }; + render() { if (!this.state.error && this.props.layer && this.props.layer.type === "wms" && this.props.layer.url) { return <>
- { this.state.loading ? : this.renderRules(this.state.jsonLegend?.rules || [])} + { this.state.loading && isEmpty(this.state?.jsonLegend?.rules) + ? + : this.renderRules(this.state.jsonLegend?.rules || []) + }
; } @@ -161,12 +210,18 @@ class StyleBasedWMSJsonLegend extends React.Component { ); } + filterWMSLayerHandler = (filter) => { const isFilterDisabled = this.props?.layer?.layerFilter?.disabled; if (!filter || isFilterDisabled) return; const newLayerFilter = updateLayerLegendFilter(this.props?.layer?.layerFilter, filter); this.props.onChange({ layerFilter: newLayerFilter }); }; + + checkPreviousFiltersAreValid = (rules, prevLegendFilters) => { + const rulesFilters = rules.map(rule => rule.filter); + return prevLegendFilters?.every(f => rulesFilters.includes(f.id)); + } } export default StyleBasedWMSJsonLegend; diff --git a/web/client/plugins/TOC/components/TOC.jsx b/web/client/plugins/TOC/components/TOC.jsx index d726c4e7e0..6801d3f446 100644 --- a/web/client/plugins/TOC/components/TOC.jsx +++ b/web/client/plugins/TOC/components/TOC.jsx @@ -192,7 +192,18 @@ function TOC({ onSelectNode={onSelectNode} onSort={handleOnSort} onChange={handleUpdateNode} - config={config} + config={{ + ...config, + layerOptions: { + ...config?.layerOptions, + legendOptions: { + ...config?.layerOptions?.legendOptions, + mapSize: map?.size, + mapBbox: map?.bbox, + projection: map?.projection + } + } + }} nodeItems={nodeItems} nodeToolItems={nodeToolItems} singleDefaultGroup={singleDefaultGroup} diff --git a/web/client/plugins/TOC/components/WMSLegend.jsx b/web/client/plugins/TOC/components/WMSLegend.jsx index 6435f3fad7..cd8db22cea 100644 --- a/web/client/plugins/TOC/components/WMSLegend.jsx +++ b/web/client/plugins/TOC/components/WMSLegend.jsx @@ -9,7 +9,9 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { isEmpty, isNumber } from 'lodash'; +import pick from 'lodash/pick'; +import isEmpty from 'lodash/isEmpty'; +import isNumber from 'lodash/isNumber'; import StyleBasedWMSJsonLegend from './StyleBasedWMSJsonLegend'; import Legend from './Legend'; import { getMiscSetting } from '../../../utils/ConfigUtils'; @@ -40,7 +42,10 @@ class WMSLegend extends React.Component { language: PropTypes.string, legendWidth: PropTypes.number, legendHeight: PropTypes.number, - onChange: PropTypes.func + onChange: PropTypes.func, + projection: PropTypes.string, + mapSize: PropTypes.object, + mapBbox: PropTypes.object }; static defaultProps = { @@ -63,7 +68,9 @@ class WMSLegend extends React.Component { const containerWidth = this.containerRef.current && this.containerRef.current.clientWidth; this.setState({ containerWidth, ...this.state }); } - + getLegendProps = () => { + return pick(this.props, ['currentZoomLvl', 'scales', 'scaleDependent', 'language', 'projection', 'mapSize', 'mapBbox']); + } render() { let node = this.props.node || {}; const experimentalInteractiveLegend = getMiscSetting('experimentalInteractiveLegend', false); @@ -76,8 +83,6 @@ class WMSLegend extends React.Component { ); @@ -105,8 +109,6 @@ class WMSLegend extends React.Component { ); diff --git a/web/client/plugins/TOC/components/__tests__/StyleBasedWMSJsonLegend-test.jsx b/web/client/plugins/TOC/components/__tests__/StyleBasedWMSJsonLegend-test.jsx index c648c63694..b3d5107ec7 100644 --- a/web/client/plugins/TOC/components/__tests__/StyleBasedWMSJsonLegend-test.jsx +++ b/web/client/plugins/TOC/components/__tests__/StyleBasedWMSJsonLegend-test.jsx @@ -14,8 +14,39 @@ import axios from '../../../../libs/ajax'; import StyleBasedWMSJsonLegend from '../StyleBasedWMSJsonLegend'; import expect from 'expect'; import TestUtils from 'react-dom/test-utils'; +import { INTERACTIVE_LEGEND_ID } from '../../../../utils/LegendUtils'; let mockAxios; +const rules = [ + { + "name": ">= 159.05 and < 5062.5", + "filter": "[field >= '159.05' AND field < '5062.5']", + "symbolizers": [{"Polygon": { + "uom": "in/72", + "stroke": "#ffffff", + "stroke-width": "1.0", + "stroke-opacity": "0.35", + "stroke-linecap": "butt", + "stroke-linejoin": "miter", + "fill": "#8DD3C7", + "fill-opacity": "0.75" + }}] + }, + { + "name": ">= 5062.5 and < 20300.35", + "filter": "[field >= '5062.5' AND field < '20300.35']", + "symbolizers": [{"Polygon": { + "uom": "in/72", + "stroke": "#ffffff", + "stroke-width": "1.0", + "stroke-opacity": "0.35", + "stroke-linecap": "butt", + "stroke-linejoin": "miter", + "fill": "#ABD9C5", + "fill-opacity": "0.75" + }}] + } +]; describe('test StyleBasedWMSJsonLegend module component', () => { beforeEach((done) => { @@ -45,35 +76,7 @@ describe('test StyleBasedWMSJsonLegend module component', () => { "Legend": [{ "layerName": "layer00", "title": "Layer", - "rules": [ - { - "name": ">= 159.05 and < 5062.5", - "filter": "[field >= '159.05' AND field < '5062.5']", - "symbolizers": [{"Polygon": { - "uom": "in/72", - "stroke": "#ffffff", - "stroke-width": "1.0", - "stroke-opacity": "0.35", - "stroke-linecap": "butt", - "stroke-linejoin": "miter", - "fill": "#8DD3C7", - "fill-opacity": "0.75" - }}] - }, - { - "name": ">= 5062.5 and < 20300.35", - "filter": "[field >= '5062.5' AND field < '20300.35']", - "symbolizers": [{"Polygon": { - "uom": "in/72", - "stroke": "#ffffff", - "stroke-width": "1.0", - "stroke-opacity": "0.35", - "stroke-linecap": "butt", - "stroke-linejoin": "miter", - "fill": "#ABD9C5", - "fill-opacity": "0.75" - }}] - }] + rules }] }]; }); @@ -89,4 +92,113 @@ describe('test StyleBasedWMSJsonLegend module component', () => { expect(legendRuleElem).toBeTruthy(); expect(legendRuleElem.length).toEqual(2); }); + it('tests legend with empty rules', async() => { + const l = { + name: 'layer00', + title: 'Layer', + visibility: true, + storeIndex: 9, + type: 'wms', + url: 'http://localhost:8080/geoserver1/wms' + }; + mockAxios.onGet(/geoserver1/).reply(() => { + return [200, { + "Legend": [{ + "layerName": "layer01", + "title": "Layer1", + "rules": [] + }] + }]; + }); + const comp = ReactDOM.render(, document.getElementById("container")); + await TestUtils.act(async() => comp); + + const domNode = ReactDOM.findDOMNode(comp); + expect(domNode).toBeTruthy(); + + const legendElem = document.querySelector('.wms-legend'); + expect(legendElem).toBeTruthy(); + expect(legendElem.innerText).toBe('layerProperties.interactiveLegend.noLegendData'); + const legendRuleElem = domNode.querySelectorAll('.wms-json-legend-rule'); + expect(legendRuleElem.length).toBe(0); + }); + it('tests legend with incompatible filter rules', async() => { + const l = { + name: 'layer00', + title: 'Layer', + visibility: true, + storeIndex: 9, + type: 'wms', + url: 'http://localhost:8080/geoserver2/wms', + layerFilter: { + filters: [{ + id: INTERACTIVE_LEGEND_ID, + filters: [{ + id: 'filter1' + }] + }], + disabled: false + } + }; + mockAxios.onGet(/geoserver2/).reply(() => { + return [200, { + "Legend": [{ + "layerName": "layer01", + "title": "Layer1", + rules + }] + }]; + }); + const comp = ReactDOM.render(, document.getElementById("container")); + await TestUtils.act(async() => comp); + + const domNode = ReactDOM.findDOMNode(comp); + expect(domNode).toBeTruthy(); + + const legendElem = document.querySelector('.wms-legend'); + expect(legendElem).toBeTruthy(); + const legendRuleElem = domNode.querySelector('.wms-legend .alert-warning'); + expect(legendRuleElem).toBeTruthy(); + expect(legendRuleElem.innerText).toContain('layerProperties.interactiveLegend.incompatibleFilterWarning'); + const resetLegendFilter = domNode.querySelector('.wms-legend .alert-warning button'); + expect(resetLegendFilter).toBeTruthy(); + }); + it('tests hide warning when layer filter is disabled', async() => { + const l = { + name: 'layer00', + title: 'Layer', + visibility: true, + storeIndex: 9, + type: 'wms', + url: 'http://localhost:8080/geoserver3/wms', + layerFilter: { + filters: [{ + id: INTERACTIVE_LEGEND_ID, + filters: [{ + id: 'filter1' + }] + }], + disabled: true + } + }; + mockAxios.onGet(/geoserver3/).reply(() => { + return [200, { + "Legend": [{ + "layerName": "layer01", + "title": "Layer1", + rules + }] + }]; + }); + const comp = ReactDOM.render(, document.getElementById("container")); + await TestUtils.act(async() => comp); + + const domNode = ReactDOM.findDOMNode(comp); + expect(domNode).toBeTruthy(); + + const legendElem = document.querySelector('.wms-legend'); + expect(legendElem).toBeTruthy(); + const legendRuleElem = domNode.querySelector('.wms-legend .alert-warning'); + expect(legendRuleElem).toBeFalsy(); + }); }); diff --git a/web/client/plugins/TOC/components/__tests__/WMSLegend-test.jsx b/web/client/plugins/TOC/components/__tests__/WMSLegend-test.jsx index 878a63eb87..a2bc66f5c1 100644 --- a/web/client/plugins/TOC/components/__tests__/WMSLegend-test.jsx +++ b/web/client/plugins/TOC/components/__tests__/WMSLegend-test.jsx @@ -88,7 +88,7 @@ describe('test WMSLegend module component', () => { const params = new URLSearchParams(image[0].src); expect(params.get("width")).toBe('12'); expect(params.get("height")).toBe('12'); - expect(params.get("LEGEND_OPTIONS")).toBe('forceLabels:on'); + expect(params.get("LEGEND_OPTIONS")).toBe('hideEmptyRules:true;forceLabels:on'); }); it('tests WMSLegend component legendOptions with one or all values missing', () => { @@ -112,7 +112,7 @@ describe('test WMSLegend module component', () => { const params = new URLSearchParams(image[0].src); expect(params.get("width")).toBe('12'); expect(params.get("height")).toBe('12'); - expect(params.get("LEGEND_OPTIONS")).toBe('forceLabels:on'); + expect(params.get("LEGEND_OPTIONS")).toBe('hideEmptyRules:true;forceLabels:on'); }); it('tests WMSLegend component legendOptions with values', () => { @@ -136,7 +136,7 @@ describe('test WMSLegend module component', () => { const params = new URLSearchParams(image[0].src); expect(params.get("width")).toBe('20'); expect(params.get("height")).toBe('40'); - expect(params.get("LEGEND_OPTIONS")).toBe('forceLabels:on'); + expect(params.get("LEGEND_OPTIONS")).toBe('hideEmptyRules:true;forceLabels:on'); }); it('tests WMSLegend component legendOptions from cfg', () => { @@ -159,7 +159,7 @@ describe('test WMSLegend module component', () => { const params = new URLSearchParams(image[0].src); expect(params.get("width")).toBe('20'); expect(params.get("height")).toBe('40'); - expect(params.get("LEGEND_OPTIONS")).toBe('forceLabels:on'); + expect(params.get("LEGEND_OPTIONS")).toBe('hideEmptyRules:true;forceLabels:on'); }); it('tests WMSLegend component language property with value', () => { diff --git a/web/client/plugins/TOC/index.js b/web/client/plugins/TOC/index.js index 7ea8b64786..e0c10ef4c4 100644 --- a/web/client/plugins/TOC/index.js +++ b/web/client/plugins/TOC/index.js @@ -354,6 +354,9 @@ function TOC({ groupOptions = {}, layerOptions = {}, + projection, + mapSize, + mapBbox, currentLocale, language, scales, @@ -499,7 +502,13 @@ function TOC({ groupOptions, layerOptions: { ...layerOptions, - hideLegend: !activateLegendTool + hideLegend: !activateLegendTool, + legendOptions: { + ...layerOptions?.legendOptions, + projection, + mapSize, + mapBbox + } } }} onContextMenu={({ event, node: currentNode, nodeType, parentId }) => { @@ -599,7 +608,10 @@ const tocSelector = createShallowSelectorCreator(isEqual)( map && map.projection || 'EPSG:3857', map && map.mapOptions && map.mapOptions.view && map.mapOptions.view.DPI || null ), + projection: map && map.projection || 'EPSG:3857', zoom: map?.zoom, + mapSize: map?.size, + mapBbox: map?.bbox, resolutions, resolution, visualizationMode, diff --git a/web/client/plugins/tocitemssettings/defaultSettingsTabs.js b/web/client/plugins/tocitemssettings/defaultSettingsTabs.js index a0469544e5..350241406c 100644 --- a/web/client/plugins/tocitemssettings/defaultSettingsTabs.js +++ b/web/client/plugins/tocitemssettings/defaultSettingsTabs.js @@ -37,7 +37,7 @@ import { StyleSelector } from '../styleeditor/index'; const StyleList = defaultProps({ readOnly: true })(StyleSelector); const ConnectedDisplay = connect( - createSelector([mapSelector], ({ zoom, projection }) => ({ zoom, projection })) + createSelector([mapSelector], ({ zoom, projection, size, bbox }) => ({ zoom, projection, mapSize: size, mapBbox: bbox })) )(Display); const ConnectedVectorStyleEditor = connect( diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 770dd84292..df6b185b0e 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -240,7 +240,12 @@ "clearCustomizationConfirm": "Möchten Sie wirklich alle Anpassungen entfernen?", "error": "Die Felder konnten nicht automatisch geladen werden" }, - "disableFeaturesEditing": "Deaktivieren Sie die Bearbeitung der Attributtabelle" + "disableFeaturesEditing": "Deaktivieren Sie die Bearbeitung der Attributtabelle", + "interactiveLegend": { + "incompatibleFilterWarning": "Legendenfilter sind mit dem aktiven Ebenenfilter nicht kompatibel oder es sind keine sichtbaren Features im Kartenansichtsfenster vorhanden. Klicken Sie auf Zurücksetzen, um die Legendenfilter zu löschen", + "resetLegendFilter": "Zurücksetzen", + "noLegendData": "Keine Legenden Elemente zum Anzeigen" + } }, "localizedInput": { "localize": "Diesen Text lokalisieren...", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index ecfcea85b3..d0d7e09dd9 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -240,7 +240,12 @@ "clearCustomizationConfirm": "Are you sure you want to remove all customizations?", "error": "It was not possible to automatically load the fields" }, - "disableFeaturesEditing": "Disable editing on Attribute table" + "disableFeaturesEditing": "Disable editing on Attribute table", + "interactiveLegend": { + "incompatibleFilterWarning": "Legend filters are incompatible with the active layer filter, or no visible features are within the map view. Click reset to clear legend filters", + "resetLegendFilter": "Reset", + "noLegendData": "No legend items to show" + } }, "localizedInput": { "localize": "Localize this text...", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index ca6ee04960..05ee0381eb 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -240,7 +240,12 @@ "clearCustomizationConfirm": "¿Está seguro de que desea borrar la personalización de los campos?", "error": "Error al recuperar los campos" }, - "disableFeaturesEditing": "Deshabilitar la edición en la tabla de atributos" + "disableFeaturesEditing": "Deshabilitar la edición en la tabla de atributos", + "interactiveLegend": { + "incompatibleFilterWarning": "Los filtros de leyenda son incompatibles con el filtro de capa activo, o no hay características visibles dentro de la vista del mapa. Haga clic en restablecer para borrar los filtros de leyenda", + "resetLegendFilter": "Restablecer", + "noLegendData": "No hay elementos de leyenda para mostrar" + } }, "localizedInput": { "localize": "Localizar cadena...", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index c6c237b591..61c8d8f522 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -240,7 +240,12 @@ "clearCustomizationConfirm": "Voulez-vous vraiment supprimer les personnalisations ?", "error": "Échec de la récupération des champs" }, - "disableFeaturesEditing": "Désactiver la modification sur la table attributaire" + "disableFeaturesEditing": "Désactiver la modification sur la table attributaire", + "interactiveLegend": { + "incompatibleFilterWarning": "Les filtres de légende sont incompatibles avec le filtre de couche actif, ou aucune fonctionnalité visible n'est dans la vue de la carte. Cliquez sur réinitialiser pour effacer les filtres de légende", + "resetLegendFilter": "Réinitialiser", + "noLegendData": "Aucun élément de légende à afficher" + } }, "localizedInput": { "localize": "Localiser ce texte...", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 3f8147e03c..9c2e32872f 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -240,7 +240,10 @@ "clearCustomizationConfirm": "Sei sicuro di voler rimuovere tutte le modifiche effettuate ai campi?", "error": "Non è stato possibile recuperare i campi dalla sorgente dati" }, - "disableFeaturesEditing": "Disabilita la modifica sulla tabella degli attributi" + "disableFeaturesEditing": "Disabilita la modifica sulla tabella degli attributi", + "interactiveLegend": { + "incompatibleFilterWarning": "I filtri della legenda sono incompatibili con il filtro del layer attivo, oppure non ci sono feature visibili all'interno della vista della mappa. Clicca su reset per cancellare i filtri della legenda", "resetLegendFilter": "Reset", + "noLegendData": "Nessun elemento della legenda da mostrare" } }, "localizedInput": { "localize": "Traduci questo testo...", diff --git a/web/client/utils/FilterUtils.js b/web/client/utils/FilterUtils.js index b34ca4d18a..ce9297b606 100644 --- a/web/client/utils/FilterUtils.js +++ b/web/client/utils/FilterUtils.js @@ -30,6 +30,7 @@ export const cqlToOgc = (cqlFilter, fOpts) => { }; import { get, isNil, isArray, find, findIndex, isString, flatten } from 'lodash'; +import { INTERACTIVE_LEGEND_ID } from './LegendUtils'; let FilterUtils; const wrapValueWithWildcard = (value, condition) => { @@ -1323,12 +1324,12 @@ export const updateLayerLegendFilter = (layerFilterObj, legendFilter) => { } }; let filterObj = {...defaultLayerFilter, ...layerFilterObj}; - const isLegendFilterExist = filterObj?.filters?.find(f => f.id === 'interactiveLegend'); + const isLegendFilterExist = filterObj?.filters?.find(f => f.id === INTERACTIVE_LEGEND_ID); if (!legendFilter) { // clear legend filter with id = 'interactiveLegend' if (isLegendFilterExist) { filterObj = { - ...filterObj, filters: filterObj?.filters?.filter(f => f.id !== 'interactiveLegend') + ...filterObj, filters: filterObj?.filters?.filter(f => f.id !== INTERACTIVE_LEGEND_ID) }; } let newFilter = filterObj ? filterObj : undefined; @@ -1354,9 +1355,9 @@ export const updateLayerLegendFilter = (layerFilterObj, legendFilter) => { } let newFilter = { ...(filterObj || {}), filters: [ - ...(filterObj?.filters?.filter(f => f.id !== 'interactiveLegend') || []), ...[ + ...(filterObj?.filters?.filter(f => f.id !== INTERACTIVE_LEGEND_ID) || []), ...[ { - "id": "interactiveLegend", + "id": INTERACTIVE_LEGEND_ID, "format": "logic", "version": "1.0.0", "logic": "OR", @@ -1379,10 +1380,10 @@ export function resetLayerLegendFilter(layer, reason, value) { let filterObj = layer.layerFilter ? layer.layerFilter : undefined; if (!needReset || !isLayerWithJSONLegend || !filterObj) return false; // reset thte filter if legendCQLFilter is empty - const isLegendFilterExist = filterObj?.filters?.find(f => f.id === 'interactiveLegend'); + const isLegendFilterExist = filterObj?.filters?.find(f => f.id === INTERACTIVE_LEGEND_ID); if (isLegendFilterExist) { filterObj = { - ...filterObj, filters: filterObj?.filters?.filter(f => f.id !== 'interactiveLegend') + ...filterObj, filters: filterObj?.filters?.filter(f => f.id !== INTERACTIVE_LEGEND_ID) }; return filterObj; } @@ -1414,5 +1415,6 @@ FilterUtils = { processOGCSpatialFilter, createFeatureFilter, mergeFiltersToOGC, - convertFiltersToOGC + convertFiltersToOGC, + INTERACTIVE_LEGEND_ID }; diff --git a/web/client/utils/LegendUtils.js b/web/client/utils/LegendUtils.js new file mode 100644 index 0000000000..18405b938e --- /dev/null +++ b/web/client/utils/LegendUtils.js @@ -0,0 +1,123 @@ +/* + * Copyright 2024, 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 { isEmpty } from "lodash"; +import { getExtentFromViewport } from "./CoordinatesUtils"; +import { ServerTypes } from "./LayersUtils"; +import { optionsToVendorParams } from "./VendorParamsUtils"; +import { composeFilterObject } from "../components/widgets/enhancers/utils"; +import { toCQLFilter } from "./FilterUtils"; +import { arrayUpdate } from "./ImmutableUtils"; + +export const INTERACTIVE_LEGEND_ID = "interactiveLegend"; +export const LEGEND_FORMAT = { + IMAGE: "image/png", + JSON: "application/json" +}; + +export const getLayerFilterByLegendFormat = (layer, format = LEGEND_FORMAT.JSON) => { + const layerFilter = layer?.layerFilter; + if (layer && layer.type === "wms" && layer.url) { + if (format === LEGEND_FORMAT.JSON && !isEmpty(layerFilter)) { + return { + ...layerFilter, + filters: (layerFilter?.filters ?? [])?.filter(f => f.id !== INTERACTIVE_LEGEND_ID) + }; + } + return layerFilter; + } + return layerFilter; +}; + +export const getWMSLegendConfig = ({ + format, + legendHeight, + legendWidth, + layer, + mapSize, + projection, + mapBbox, + legendOptions +}) => { + const baseParams = { + service: "WMS", + request: "GetLegendGraphic", + format, + height: legendHeight, + width: legendWidth, + layer: layer.name, + style: layer.style || null, + version: layer.version || "1.3.0", + SLD_VERSION: "1.1.0", + LEGEND_OPTIONS: legendOptions + }; + + if (layer.serverType !== ServerTypes.NO_VENDOR) { + return { + ...baseParams, + // hideEmptyRules is applied for all layers except background layers + LEGEND_OPTIONS: `hideEmptyRules:${layer.group !== "background"};${legendOptions}`, + SRCWIDTH: mapSize?.width ?? 512, + SRCHEIGHT: mapSize?.height ?? 512, + SRS: projection, + CRS: projection, + ...(mapBbox?.bounds && {BBOX: getExtentFromViewport(mapBbox, projection)?.join(',')}), + ...optionsToVendorParams({ ...layer, layerFilter: getLayerFilterByLegendFormat(layer, format) }) + }; + } + + return { + ...baseParams, + ...layer.params + }; +}; + +/** + * Updates the layers with the filters from dependencies + * to perform legend filtering in the legend widget + */ +export const updateLayerWithLegendFilters = (layers, dependencies) => { + const targetLayerName = dependencies?.layer?.name; + const filterObj = dependencies?.filter || {}; + const layerInCommon = layers?.find(l => l.name === targetLayerName) || {}; + let filterObjCollection = {}; + let layersUpdatedWithCql = {}; + let cqlFilter = undefined; + + if (dependencies?.mapSync && !isEmpty(layerInCommon) + && (filterObj.featureTypeName ? filterObj.featureTypeName === targetLayerName : true)) { + if (dependencies?.quickFilters) { + filterObjCollection = { + ...filterObjCollection, + ...composeFilterObject(filterObj, dependencies?.quickFilters, dependencies?.options) + }; + } + cqlFilter = toCQLFilter(filterObjCollection); + if (!isEmpty(filterObjCollection) && cqlFilter) { + layersUpdatedWithCql = arrayUpdate(false, + { + ...layerInCommon, + params: optionsToVendorParams({ params: {CQL_FILTER: cqlFilter}}) + }, + {name: targetLayerName}, + layers + ); + } + } else { + layersUpdatedWithCql = layers.map(l => ({...l, params: {...l.params, CQL_FILTER: undefined}})); + } + return layersUpdatedWithCql; +}; + +export default { + INTERACTIVE_LEGEND_ID, + getLayerFilterByLegendFormat, + getWMSLegendConfig, + updateLayerWithLegendFilters +}; + diff --git a/web/client/utils/PrintUtils.js b/web/client/utils/PrintUtils.js index 9068137b69..4a011618a2 100644 --- a/web/client/utils/PrintUtils.js +++ b/web/client/utils/PrintUtils.js @@ -38,6 +38,7 @@ import trimEnd from 'lodash/trimEnd'; import { getGridGeoJson } from "./grids/MapGridsUtils"; import { isImageServerUrl } from './ArcGISUtils'; +import { getWMSLegendConfig, LEGEND_FORMAT } from './LegendUtils'; const defaultScales = getGoogleMercatorScales(0, 21); let PrintUtils; @@ -606,33 +607,30 @@ export const specCreators = { }) } ))}), - legend: (layer, spec) => ({ - "name": layer.title || layer.name, - "classes": [ - { - "name": "", - "icons": [ - PrintUtils.normalizeUrl(layer.url) + url.format({ - query: addAuthenticationParameter(PrintUtils.normalizeUrl(layer.url), { - TRANSPARENT: true, - EXCEPTIONS: "application/vnd.ogc.se_xml", - VERSION: "1.1.1", - SERVICE: "WMS", - REQUEST: "GetLegendGraphic", - LAYER: layer.name, - STYLE: layer.style || '', - SCALE: spec.scale, - ...getLegendIconsSize(spec, layer), - LEGEND_OPTIONS: "forceLabels:" + (spec.forceLabels ? "on" : "") + ";fontAntialiasing:" + spec.antiAliasing + ";dpi:" + spec.legendDpi + ";fontStyle:" + (spec.bold && "bold" || (spec.italic && "italic") || '') + ";fontName:" + spec.fontFamily + ";fontSize:" + spec.fontSize, - format: "image/png", - ...(spec.language ? {LANGUAGE: spec.language} : {}), - ...layer?.params + legend: (layer, spec) => { + const legendOptions = "forceLabels:" + (spec.forceLabels ? "on" : "") + ";fontAntialiasing:" + spec.antiAliasing + ";dpi:" + spec.legendDpi + ";fontStyle:" + (spec.bold && "bold" || (spec.italic && "italic") || '') + ";fontName:" + spec.fontFamily + ";fontSize:" + spec.fontSize; + return { + "name": layer.title || layer.name, + "classes": [ + { + "name": "", + "icons": [ + PrintUtils.normalizeUrl(layer.url) + url.format({ + query: addAuthenticationParameter(PrintUtils.normalizeUrl(layer.url), { + ...getWMSLegendConfig({layer, legendOptions, mapBbox: spec.bbox, mapSize: spec.size, projection: spec.projection, format: LEGEND_FORMAT.IMAGE}), + TRANSPARENT: true, + EXCEPTIONS: "application/vnd.ogc.se_xml", + VERSION: "1.1.1", + SCALE: spec.scale, + ...getLegendIconsSize(spec, layer), + ...(spec.language ? {LANGUAGE: spec.language} : {}) + }) }) - }) - ] - } - ] - }) + ] + } + ] + }; + } }, vector: { map: (layer, spec) => ({ diff --git a/web/client/utils/__tests__/FilterUtils-test.js b/web/client/utils/__tests__/FilterUtils-test.js index 7787c74b7f..7433c3dae2 100644 --- a/web/client/utils/__tests__/FilterUtils-test.js +++ b/web/client/utils/__tests__/FilterUtils-test.js @@ -32,6 +32,7 @@ import { isFilterEmpty, updateLayerLegendFilter, resetLayerLegendFilter } from '../FilterUtils'; +import { INTERACTIVE_LEGEND_ID } from '../LegendUtils'; describe('FilterUtils', () => { @@ -2337,8 +2338,8 @@ describe('FilterUtils', () => { const updatedFilterObj = updateLayerLegendFilter(layerFilterObj, lgegendFilter); expect(updatedFilterObj).toBeTruthy(); expect(updatedFilterObj.filters.length).toEqual(1); - expect(updatedFilterObj.filters.filter(i => i.id === 'interactiveLegend')?.length).toEqual(1); - expect(updatedFilterObj.filters.find(i => i.id === 'interactiveLegend').filters.length).toEqual(1); + expect(updatedFilterObj.filters.filter(i => i.id === INTERACTIVE_LEGEND_ID)?.length).toEqual(1); + expect(updatedFilterObj.filters.find(i => i.id === INTERACTIVE_LEGEND_ID).filters.length).toEqual(1); }); it('test updateLayerLegendFilter for wms, apply multi legend filter', () => { const layerFilterObj = { @@ -2364,7 +2365,7 @@ describe('FilterUtils', () => { }, "filters": [ { - "id": "interactiveLegend", + "id": INTERACTIVE_LEGEND_ID, "format": "logic", "version": "1.0.0", "logic": "OR", @@ -2383,8 +2384,8 @@ describe('FilterUtils', () => { const updatedFilterObj = updateLayerLegendFilter(layerFilterObj, lgegendFilter); expect(updatedFilterObj).toBeTruthy(); expect(updatedFilterObj.filters.length).toEqual(1); - expect(updatedFilterObj.filters.filter(i => i.id === 'interactiveLegend')?.length).toEqual(1); - expect(updatedFilterObj.filters.find(i => i.id === 'interactiveLegend').filters.length).toEqual(2); + expect(updatedFilterObj.filters.filter(i => i.id === INTERACTIVE_LEGEND_ID)?.length).toEqual(1); + expect(updatedFilterObj.filters.find(i => i.id === INTERACTIVE_LEGEND_ID).filters.length).toEqual(2); }); it('test reset legend filter using updateLayerLegendFilter', () => { const layerFilterObj = { @@ -2410,7 +2411,7 @@ describe('FilterUtils', () => { }, "filters": [ { - "id": "interactiveLegend", + "id": INTERACTIVE_LEGEND_ID, "format": "logic", "version": "1.0.0", "logic": "OR", @@ -2434,7 +2435,7 @@ describe('FilterUtils', () => { const updatedFilterObj = updateLayerLegendFilter(layerFilterObj); expect(updatedFilterObj).toBeTruthy(); expect(updatedFilterObj.filters.length).toEqual(0); - expect(updatedFilterObj.filters.find(i => i.id === 'interactiveLegend')).toBeFalsy(); + expect(updatedFilterObj.filters.find(i => i.id === INTERACTIVE_LEGEND_ID)).toBeFalsy(); }); it('test resetLayerLegendFilter in case change wms style', () => { const layerFilterObj = { @@ -2460,7 +2461,7 @@ describe('FilterUtils', () => { }, "filters": [ { - "id": "interactiveLegend", + "id": INTERACTIVE_LEGEND_ID, "format": "logic", "version": "1.0.0", "logic": "OR", @@ -2489,6 +2490,6 @@ describe('FilterUtils', () => { const updatedFilterObj = resetLayerLegendFilter(layer, 'style', 'style_02'); expect(updatedFilterObj).toBeTruthy(); expect(updatedFilterObj.filters.length).toEqual(0); - expect(updatedFilterObj.filters.find(i => i.id === 'interactiveLegend')).toBeFalsy(); + expect(updatedFilterObj.filters.find(i => i.id === INTERACTIVE_LEGEND_ID)).toBeFalsy(); }); }); diff --git a/web/client/utils/__tests__/LegendUtils-test.js b/web/client/utils/__tests__/LegendUtils-test.js new file mode 100644 index 0000000000..0813b9176e --- /dev/null +++ b/web/client/utils/__tests__/LegendUtils-test.js @@ -0,0 +1,280 @@ +/* + * Copyright 2024, 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 expect from 'expect'; +import { + getWMSLegendConfig, + getLayerFilterByLegendFormat, + INTERACTIVE_LEGEND_ID, + LEGEND_FORMAT, + updateLayerWithLegendFilters +} from '../LegendUtils'; +import { ServerTypes } from '../LayersUtils'; + +describe('LegendUtils', () => { + describe('getLayerFilterByLegendFormat', () => { + it('should return layer filter without interactive legend filter for JSON format', () => { + const layer = { + type: 'wms', + url: 'http://example.com', + layerFilter: { + filters: [{ id: INTERACTIVE_LEGEND_ID }, { id: 'otherFilter' }] + } + }; + const format = LEGEND_FORMAT.JSON; + const result = getLayerFilterByLegendFormat(layer, format); + expect(result.filters).toEqual([{ id: 'otherFilter' }]); + }); + + it('should return original layer filter for non-JSON format', () => { + const layer = { + type: 'wms', + url: 'http://example.com', + layerFilter: { + filters: [{ id: INTERACTIVE_LEGEND_ID }, { id: 'otherFilter' }] + } + }; + const format = LEGEND_FORMAT.IMAGE; + const result = getLayerFilterByLegendFormat(layer, format); + expect(result.filters).toEqual([{ id: INTERACTIVE_LEGEND_ID }, { id: 'otherFilter' }]); + }); + + it('should return empty filter if layerFilter is undefined', () => { + const layer = { + type: 'wms', + url: 'http://example.com' + }; + const format = LEGEND_FORMAT.JSON; + const result = getLayerFilterByLegendFormat(layer, format); + expect(result).toBe(undefined); + }); + }); + + describe('getWMSLegendConfig', () => { + it('should return correct WMS legend config for non-vendor server type', () => { + const layer = { + name: 'testLayer', + type: 'wms', + url: 'http://example.com', + serverType: ServerTypes.NO_VENDOR, + params: { customParam: 'value' } + }; + const config = getWMSLegendConfig({ + format: LEGEND_FORMAT.IMAGE, + legendHeight: 20, + legendWidth: 20, + layer, + mapSize: { width: 800, height: 600 }, + projection: 'EPSG:4326', + mapBbox: { bounds: { minx: -30, miny: 20, maxx: 50, maxy: 60 } }, + legendOptions: 'fontSize:10' + }); + expect(config).toEqual({ + service: 'WMS', + request: 'GetLegendGraphic', + format: LEGEND_FORMAT.IMAGE, + height: 20, + width: 20, + layer: 'testLayer', + style: null, + version: '1.3.0', + SLD_VERSION: '1.1.0', + LEGEND_OPTIONS: 'fontSize:10', + customParam: 'value' + }); + }); + + it('should return correct WMS legend config for vendor server type', () => { + const layer = { + name: 'testLayer', + type: 'wms', + url: 'http://example.com', + serverType: 'VENDOR', + group: 'foreground' + }; + const config = getWMSLegendConfig({ + format: LEGEND_FORMAT.IMAGE, + legendHeight: 20, + legendWidth: 20, + layer, + mapSize: { width: 800, height: 600 }, + projection: 'EPSG:4326', + mapBbox: { bounds: {minx: -30, miny: 20, maxx: 50, maxy: 60}, crs: "EPSG:4326" }, + legendOptions: 'fontSize:10' + }); + expect(config).toEqual({ + service: 'WMS', + request: 'GetLegendGraphic', + format: LEGEND_FORMAT.IMAGE, + height: 20, + width: 20, + layer: 'testLayer', + style: null, + version: '1.3.0', + SLD_VERSION: '1.1.0', + LEGEND_OPTIONS: 'hideEmptyRules:true;fontSize:10', + SRCWIDTH: 800, + SRCHEIGHT: 600, + SRS: 'EPSG:4326', + CRS: 'EPSG:4326', + BBOX: '-30,20,50,60' + }); + }); + it('should return correct WMS legend config for vendor server type with background group', () => { + const layer = { + name: 'testLayer', + type: 'wms', + url: 'http://example.com', + serverType: 'VENDOR', + group: 'background' + }; + const config = getWMSLegendConfig({ + format: LEGEND_FORMAT.IMAGE, + legendHeight: 20, + legendWidth: 20, + layer, + mapSize: { width: 800, height: 600 }, + projection: 'EPSG:4326', + mapBbox: { bounds: { minx: -30, miny: 20, maxx: 50, maxy: 60 }, crs: "EPSG:4326" }, + legendOptions: 'fontSize:10' + }); + expect(config).toEqual({ + service: 'WMS', + request: 'GetLegendGraphic', + format: LEGEND_FORMAT.IMAGE, + height: 20, + width: 20, + layer: 'testLayer', + style: null, + version: '1.3.0', + SLD_VERSION: '1.1.0', + LEGEND_OPTIONS: 'hideEmptyRules:false;fontSize:10', + SRCWIDTH: 800, + SRCHEIGHT: 600, + SRS: 'EPSG:4326', + CRS: 'EPSG:4326', + BBOX: '-30,20,50,60' + }); + }); + }); + describe('updateLayerWithLegendFilters', () => { + const filter = { + "featureTypeName": "layer1", + "groupFields": [{"id": 1, "logic": "OR", "index": 0}], + "filterFields": [], + "spatialField": { + "method": "BBOX", + "attribute": "the_geom", + "operation": "INTERSECTS", + "geometry": { + "id": "2", + "type": "Polygon", + "extent": [-12039795.482942028, 4384116.951814341, -9045909.959068244, 6702910.641873448], + "center": [-10542852.721005136, 5543513.796843895], + "coordinates": [[[-12039795.482942028, 6702910.641873448], [-12039795.482942028, 4384116.951814341], [-9045909.959068244, 4384116.951814341], [-9045909.959068244, 6702910.641873448], [-12039795.482942028, 6702910.641873448]]], + "style": {}, + "projection": "EPSG:3857" + } + }, + "pagination": null, + "filterType": "OGC", + "ogcVersion": "1.1.0", + "sortOptions": null, + "crossLayerFilter": null, + "hits": false, + "filters": [] + }; + const quickFilters = { + "STATE_NAME": {"rawValue": "mi", "value": "mi", "operator": "ilike", "type": "string", "attribute": "STATE_NAME"} + }; + it('should return layers with updated CQL_FILTER when mapSync is true and filter matches', () => { + const layers = [ + { name: 'layer1', params: {} }, + { name: 'layer2', params: {} } + ]; + const dependencies = { + layer: { name: 'layer1' }, + filter, + mapSync: true, + quickFilters: {}, + options: {} + }; + + const result = updateLayerWithLegendFilters(layers, dependencies); + + expect(result).toBeTruthy(); + expect(result.length).toBe(2); + const layer = {"name": "layer1", "params": {"CQL_FILTER": "(INTERSECTS(\"the_geom\",SRID=3857;Polygon((-12039795.482942028 6702910.641873448, -12039795.482942028 4384116.951814341, -9045909.959068244 4384116.951814341, -9045909.959068244 6702910.641873448, -12039795.482942028 6702910.641873448))))"}}; + expect(result[0]).toEqual(layer); + expect(result[1].params).toEqual({}); + }); + + it('should return layers with undefined CQL_FILTER when mapSync is false', () => { + const layers = [ + { name: 'layer1', params: { CQL_FILTER: 'some_filter' } }, + { name: 'layer2', params: { CQL_FILTER: 'some_filter' } } + ]; + const dependencies = { + layer: { name: 'layer1' }, + filter, + mapSync: false, + quickFilters, + options: {} + }; + + const result = updateLayerWithLegendFilters(layers, dependencies); + expect(result).toBeTruthy(); + expect(result).toEqual([ + { name: 'layer1', params: {CQL_FILTER: undefined} }, + { name: 'layer2', params: {CQL_FILTER: undefined} } + ]); + }); + + it('should return layers with undefined CQL_FILTER when no matching layer is found', () => { + const layers = [ + { name: 'layer1', params: { CQL_FILTER: 'some_filter' } }, + { name: 'layer2', params: { CQL_FILTER: 'some_filter' } } + ]; + const dependencies = { + layer: { name: 'layer3' }, + filter: { featureTypeName: 'layer3' }, + mapSync: true, + quickFilters: {}, + options: {} + }; + + const result = updateLayerWithLegendFilters(layers, dependencies); + expect(result).toBeTruthy(); + expect(result).toEqual([ + { name: 'layer1', params: {CQL_FILTER: undefined} }, + { name: 'layer2', params: {CQL_FILTER: undefined} } + ]); + }); + + it('should return layers with updated CQL_FILTER when quickFilters are provided', () => { + const layers = [ + { name: 'layer1', params: {} }, + { name: 'layer2', params: {} } + ]; + const dependencies = { + layer: { name: 'layer1' }, + filter, + mapSync: true, + quickFilters, + options: {propertyName: ['STATE_NAME']} + }; + + const result = updateLayerWithLegendFilters(layers, dependencies); + expect(result).toBeTruthy(); + expect(result.length).toBe(2); + const CQL_FILTER = "((strToLowerCase(\"STATE_NAME\") LIKE '%mi%')) AND (INTERSECTS(\"the_geom\",SRID=3857;Polygon((-12039795.482942028 6702910.641873448, -12039795.482942028 4384116.951814341, -9045909.959068244 4384116.951814341, -9045909.959068244 6702910.641873448, -12039795.482942028 6702910.641873448))))"; + expect(result[0].name).toBe("layer1"); + expect(result[0].params.CQL_FILTER).toBe(CQL_FILTER); + }); + }); +});