diff --git a/web/client/api/WMS.js b/web/client/api/WMS.js index 6f680ff988..32fa256465 100644 --- a/web/client/api/WMS.js +++ b/web/client/api/WMS.js @@ -321,20 +321,14 @@ export const getSupportedFormat = (url, includeGFIFormats = false) => { .catch(() => includeGFIFormats ? { imageFormats: [], infoFormats: [] } : []); }; -let layerLegendJsonData = {}; export const getJsonWMSLegend = (url) => { - const request = layerLegendJsonData[url] - ? () => Promise.resolve(layerLegendJsonData[url]) - : () => axios.get(url).then((response) => { + return 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; return response?.data?.Legend || []; - }); - return request().then((data) => data).catch(err => { - throw err; - }); + }).catch(err => { throw err; }); }; const Api = { diff --git a/web/client/components/TOC/fragments/settings/Display.jsx b/web/client/components/TOC/fragments/settings/Display.jsx index 295fd6d684..26f9fa0c65 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); @@ -332,6 +342,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/epics/__tests__/styleeditor-test.js b/web/client/epics/__tests__/styleeditor-test.js index 4204d8910d..11eea235cc 100644 --- a/web/client/epics/__tests__/styleeditor-test.js +++ b/web/client/epics/__tests__/styleeditor-test.js @@ -56,6 +56,7 @@ import { testEpic } from './epicTestUtils'; import MockAdapter from 'axios-mock-adapter'; import axios from '../../libs/ajax'; +import { INTERACTIVE_LEGEND_ID } from '../../utils/LegendUtils'; let mockAxios; @@ -474,7 +475,11 @@ describe('Test styleeditor epics', () => { name: 'layerName', url: 'base/web/client/test-resources/geoserver/', describeFeatureType: {}, - style: 'test_style' + style: 'test_style', + layerFilter: { + filters: [{id: INTERACTIVE_LEGEND_ID, "test": "test"}] + }, + enableInteractiveLegend: true } ], selected: [ @@ -503,6 +508,7 @@ describe('Test styleeditor epics', () => { case UPDATE_SETTINGS_PARAMS: const styleName = action.newParams.style.split('___'); expect(styleName[0]).toBe('style_title'); + expect(action.newParams.layerFilter).toBeTruthy(); expect(action.update).toBe(true); break; case UPDATE_STATUS: @@ -568,6 +574,7 @@ describe('Test styleeditor epics', () => { case UPDATE_SETTINGS_PARAMS: const styleName = action.newParams.style.split('___'); expect(styleName[0]).toBe(`${workspace}:style_title`); + expect(action.newParams.layerFilter).toBeFalsy(); expect(action.update).toBe(true); break; case UPDATE_STATUS: diff --git a/web/client/epics/styleeditor.js b/web/client/epics/styleeditor.js index 81ee11b6b7..3ce815e77d 100644 --- a/web/client/epics/styleeditor.js +++ b/web/client/epics/styleeditor.js @@ -60,6 +60,7 @@ import { getSelectedLayer, layerSettingSelector } from '../selectors/layers'; import { generateTemporaryStyleId, generateStyleId, STYLE_OWNER_NAME, getNameParts, detectStyleCodeChanges } from '../utils/StyleEditorUtils'; import { updateStyleService } from '../api/StyleEditor'; import { getDefaultUrl } from '../utils/URLUtils'; +import { resetLayerLegendFilter } from '../utils/FilterUtils'; /* * Observable to get code of a style, it works only in edit status @@ -528,6 +529,7 @@ export const createStyleEpic = (action$, store) => const format = formatStyleSelector(state); const { title = '', _abstract = '' } = action.settings || {}; const { baseUrl = '' } = styleServiceSelector(state); + const layerFilter = resetLayerLegendFilter(layer, 'style', styleName); const editorMetadata = { msStyleJSON: null, @@ -559,7 +561,7 @@ export const createStyleEpic = (action$, store) => ) .switchMap(() => Rx.Observable.of( updateOptionsByOwner(STYLE_OWNER_NAME, [{}]), - updateSettingsParams({style: styleName || ''}, true), + updateSettingsParams({ ...(layerFilter && {layerFilter}), style: styleName || ''}, true), updateStatus(''), loadedStyle()) .merge( @@ -637,7 +639,8 @@ export const updateStyleCodeEpic = (action$, store) => 'layer', { _v_: Date.now(), - availableStyles + availableStyles, + styleVersion: `${styleName}-${Date.now()}` }), updateSettingsParams({ availableStyles 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..f23be58a8f 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,28 @@ 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 prevLayerStyleVersion = prevProps?.layer?.styleVersion; + const currLayerStyleVersion = this.props?.layer?.styleVersion; + + 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(prevLayerStyleVersion, currLayerStyleVersion) + || !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 +114,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 +128,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 +153,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 +214,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 dc778cb373..4bd1a6e62c 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 01ba6c743e..1484b91dab 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 25533a1267..f9680ccf01 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 660421ef98..86d2cd174b 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 af17ce1603..d5eb40f463 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); + }); + }); +});