From 9835e28e5ffba8f0105359357cc2833f5e5331cc Mon Sep 17 00:00:00 2001 From: Binabh Date: Fri, 31 Jul 2026 22:15:11 +0545 Subject: [PATCH 1/5] #12593 Improving Identify UX making some parameters configurable from UI (#12609) Fix #12593 Improving Identify UX making some parameters configurable from UI --- web/client/api/catalog/WMS.js | 17 ++-- web/client/api/catalog/__tests__/WMS-test.js | 28 ++++++- .../TOC/fragments/settings/FeatureInfo.jsx | 10 +++ .../settings/__tests__/FeatureInfo-test.jsx | 63 +++++++++++++++ .../RasterAdvancedSettings.js | 14 ++++ .../__tests__/RasterAdvancedSettings-test.js | 27 ++++++- .../misc/FeatureInfoRequestOptions.jsx | 79 +++++++++++++++++++ web/client/translations/data.de-DE.json | 8 ++ web/client/translations/data.en-US.json | 8 ++ web/client/translations/data.es-ES.json | 8 ++ web/client/translations/data.fr-FR.json | 8 ++ web/client/translations/data.it-IT.json | 8 ++ web/client/utils/FeatureInfoRequestUtils.js | 38 +++++++++ .../utils/mapinfo/__tests__/wms-test.js | 55 +++++++++++++ web/client/utils/mapinfo/wms.js | 8 +- 15 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 web/client/components/misc/FeatureInfoRequestOptions.jsx create mode 100644 web/client/utils/FeatureInfoRequestUtils.js diff --git a/web/client/api/catalog/WMS.js b/web/client/api/catalog/WMS.js index f95a84bf014..0e67dba3716 100644 --- a/web/client/api/catalog/WMS.js +++ b/web/client/api/catalog/WMS.js @@ -87,9 +87,16 @@ const recordToLayer = (record, { const format = supportedGetMapFormats?.find((value) => value === defaultFormat) || supportedGetMapFormats[0] || defaultFormat; - const featureInfo = infoFormat && INFO_FORMATS_BY_MIME_TYPE[infoFormat] + const { featureInfo: serviceFeatureInfo, ...serviceLayerOptions } = layerOptions || {}; + const { featureInfo: recordFeatureInfo, ...recordLayerOptions } = record.layerOptions || {}; + const computedFeatureInfo = infoFormat && INFO_FORMATS_BY_MIME_TYPE[infoFormat] ? { format: INFO_FORMATS_BY_MIME_TYPE[infoFormat] } - : null; + : {}; + const featureInfo = { + ...computedFeatureInfo, + ...(serviceFeatureInfo || {}), + ...(recordFeatureInfo || {}) + }; let security; if (service?.protectedId) { security = {sourceId: service?.protectedId, type: "basic"}; @@ -99,7 +106,7 @@ const recordToLayer = (record, { requestEncoding: record.requestEncoding, // WMTS KVP vs REST, KVP by default style: record.style, format, - featureInfo: featureInfo, + featureInfo: isEmpty(featureInfo) ? null : featureInfo, url: layerURL, capabilitiesURL: record.capabilitiesURL, queryable: record.queryable, @@ -124,8 +131,8 @@ const recordToLayer = (record, { allowedSRS: allowedSRS, catalogURL, ...layerBaseConfig, - ...layerOptions, - ...record.layerOptions, + ...serviceLayerOptions, + ...recordLayerOptions, localizedLayerStyles: !isNil(localizedLayerStyles) ? localizedLayerStyles : undefined, imageFormats: supportedGetMapFormats, infoFormats: supportedGetFeatureInfoFormats, diff --git a/web/client/api/catalog/__tests__/WMS-test.js b/web/client/api/catalog/__tests__/WMS-test.js index 16e4e3fcc10..2bcd997a3e5 100644 --- a/web/client/api/catalog/__tests__/WMS-test.js +++ b/web/client/api/catalog/__tests__/WMS-test.js @@ -100,6 +100,33 @@ describe('Test correctness of the WMS APIs', () => { expect(layer.serverType).toBe("no-vendor"); }); + it('wms feature info layer options preserve catalog info format', () => { + const records = getCatalogRecords({ + records: [{}] + }, { + url: 'http://sample' + }); + expect(records.length).toBe(1); + const layer = getLayerFromRecord(records[0], { + service: { + infoFormat: 'text/html', + layerOptions: { + serverType: "geoserver", + featureInfo: { + maxItems: 20, + buffer: 4 + } + } + } + }); + expect(layer.serverType).toBe("geoserver"); + expect(layer.featureInfo).toEqual({ + format: "HTML", + maxItems: 20, + buffer: 4 + }); + }); + it('wms layer with visibility limits', () => { const records = getCatalogRecords({ records: [{ @@ -222,4 +249,3 @@ describe('Test correctness of the WMS APIs', () => { expect(layer.forceProxy).toBeTruthy(); }); }); - diff --git a/web/client/components/TOC/fragments/settings/FeatureInfo.jsx b/web/client/components/TOC/fragments/settings/FeatureInfo.jsx index 243dc8dfc00..fadea8ef74e 100644 --- a/web/client/components/TOC/fragments/settings/FeatureInfo.jsx +++ b/web/client/components/TOC/fragments/settings/FeatureInfo.jsx @@ -18,6 +18,8 @@ import Message from '../../../I18N/Message'; import includes from 'lodash/includes'; import isEmpty from 'lodash/isEmpty'; import { getDefaultInfoViewMode } from '../../../../utils/MapInfoUtils'; +import FeatureInfoRequestOptions from '../../../misc/FeatureInfoRequestOptions'; +import { isGeoServerLayer } from '../../../../utils/FeatureInfoRequestUtils'; const supportedFormatRequests = { wms: getSupportedFormatWMS, @@ -121,6 +123,14 @@ export default class extends React.Component { ) : ( + {this.props.element.type === 'wms' ? ( +
+ this.props.onChange("featureInfo", featureInfo)} /> +
+ ) : null} { expect(sideCards[4].textContent).toBe('layerProperties.templateFormatTitle'); }); + it('test WMS feature info request options preserve existing feature info configuration', done => { + ReactDOM.render(${properties.name}

" + } + }} + onChange={(key, value) => { + try { + expect(key).toBe('featureInfo'); + expect(value).toEqual({ + format: "TEXT", + template: "

${properties.name}

", + maxItems: 25 + }); + done(); + } catch (e) { + done(e); + } + }} + formatCards={formatCards} + defaultInfoFormat={defaultInfoFormat} />, document.getElementById("container")); + expect(document.querySelector('[data-qa="feature-info-buffer"]')).toBeTruthy(); + expect(document.querySelector('[data-qa="feature-info-buffer"]').max).toBe('1000'); + expect(document.querySelector('[data-qa="feature-info-max-items"]').value).toBe('10'); + TestUtils.Simulate.change(document.querySelector('[data-qa="feature-info-max-items"]'), {target: {value: "25"}}); + }); + + it('test WMS feature info buffer option is hidden when server type is No Vendor', () => { + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelector('[data-qa="feature-info-max-items"]')).toBeTruthy(); + expect(document.querySelector('[data-qa="feature-info-max-items"]').value).toBe('10'); + expect(document.querySelector('[data-qa="feature-info-buffer"]')).toNotExist(); + }); + + it('test WMS feature info buffer is clamped to the maximum immediately', done => { + ReactDOM.render( { + try { + expect(key).toBe('featureInfo'); + expect(value.buffer).toBe(1000); + done(); + } catch (e) { + done(e); + } + }} + formatCards={formatCards} + defaultInfoFormat={defaultInfoFormat} />, document.getElementById("container")); + const bufferInput = document.querySelector('[data-qa="feature-info-buffer"]'); + TestUtils.Simulate.change(bufferInput, {target: {value: "1001"}}); + }); + it('should request WMS GetCapabilities for wms layers', (done) => { mockAxios.onGet().reply((req) => { diff --git a/web/client/components/catalog/editor/AdvancedSettings/RasterAdvancedSettings.js b/web/client/components/catalog/editor/AdvancedSettings/RasterAdvancedSettings.js index 56ce5a4f8b9..175705abd4c 100644 --- a/web/client/components/catalog/editor/AdvancedSettings/RasterAdvancedSettings.js +++ b/web/client/components/catalog/editor/AdvancedSettings/RasterAdvancedSettings.js @@ -18,6 +18,7 @@ import InfoPopover from '../../../widgets/widget/InfoPopover'; import CSWFilters from "./CSWFilters"; import Message from "../../../I18N/Message"; import WMSDomainAliases from "./WMSDomainAliases"; +import FeatureInfoRequestOptions from "../../../misc/FeatureInfoRequestOptions"; import tooltip from '../../../misc/enhancers/buttonTooltip'; import OverlayTrigger from '../../../misc/OverlayTrigger'; import FormControl from '../../../misc/DebouncedFormControl'; @@ -185,6 +186,19 @@ export default ({  } /> } + {service.type === "wms" ? <> +
+ + + + onChangeServiceProperty("layerOptions", { + ...service.layerOptions, + featureInfo + })} /> + : null}
diff --git a/web/client/components/catalog/editor/AdvancedSettings/__tests__/RasterAdvancedSettings-test.js b/web/client/components/catalog/editor/AdvancedSettings/__tests__/RasterAdvancedSettings-test.js index 2d5b58e4f8d..997a68aff19 100644 --- a/web/client/components/catalog/editor/AdvancedSettings/__tests__/RasterAdvancedSettings-test.js +++ b/web/client/components/catalog/editor/AdvancedSettings/__tests__/RasterAdvancedSettings-test.js @@ -33,7 +33,7 @@ describe('Test Raster advanced settings', () => { const advancedSettingPanel = document.getElementsByClassName("mapstore-switch-panel"); expect(advancedSettingPanel).toBeTruthy(); const fields = document.querySelectorAll(".form-group"); - expect(fields.length).toBe(15); + expect(fields.length).toBe(17); // check disabled refresh button }); @@ -42,7 +42,7 @@ describe('Test Raster advanced settings', () => { const advancedSettingPanel = document.getElementsByClassName("mapstore-switch-panel"); expect(advancedSettingPanel).toBeTruthy(); const fields = document.querySelectorAll(".form-group"); - expect(fields.length).toBe(13); + expect(fields.length).toBe(15); const refreshButton = document.querySelectorAll('button')[0]; expect(refreshButton).toBeTruthy(); expect(refreshButton.disabled).toBe(false); @@ -267,6 +267,29 @@ describe('Test Raster advanced settings', () => { expect(spyOn).toHaveBeenCalled(); expect(spyOn.calls[0].arguments).toEqual([ 'layerOptions', { serverType: "geoserver" } ]); }); + it('test component onChangeServiceProperty feature info request options', () => { + const action = { + onChangeServiceProperty: () => {} + }; + const spyOn = expect.spyOn(action, 'onChangeServiceProperty'); + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelector('[data-qa="feature-info-buffer"]')).toBeTruthy(); + expect(document.querySelector('[data-qa="feature-info-max-items"]').value).toBe('10'); + TestUtils.Simulate.change(document.querySelector('[data-qa="feature-info-max-items"]'), { target: { value: "15" }}); + expect(spyOn).toHaveBeenCalled(); + expect(spyOn.calls[0].arguments).toEqual([ 'layerOptions', { serverType: "geoserver", featureInfo: { maxItems: 15 } } ]); + }); + it('test component hides catalog feature info buffer for non GeoServer', () => { + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelector('[data-qa="feature-info-max-items"]')).toBeTruthy(); + expect(document.querySelector('[data-qa="feature-info-max-items"]').value).toBe('10'); + expect(document.querySelector('[data-qa="feature-info-buffer"]')).toNotExist(); + }); it('test component onChangeServiceProperty infoFormat', () => { const action = { onChangeServiceProperty: () => {} diff --git a/web/client/components/misc/FeatureInfoRequestOptions.jsx b/web/client/components/misc/FeatureInfoRequestOptions.jsx new file mode 100644 index 00000000000..817db1b4ec5 --- /dev/null +++ b/web/client/components/misc/FeatureInfoRequestOptions.jsx @@ -0,0 +1,79 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { ControlLabel, FormControl, FormGroup } from 'react-bootstrap'; + +import Message from '../I18N/Message'; +import InfoPopover from '../widgets/widget/InfoPopover'; +import { + DEFAULT_FEATURE_COUNT, + MAX_FEATURE_INFO_BUFFER, + sanitizeFeatureInfoBuffer, + sanitizeFeatureInfoMaxItems +} from '../../utils/FeatureInfoRequestUtils'; + +const updateOption = (featureInfo, key, value, sanitize) => { + const sanitized = sanitize(value); + const nextFeatureInfo = { ...(featureInfo || {}) }; + if (sanitized === undefined) { + delete nextFeatureInfo[key]; + } else { + nextFeatureInfo[key] = sanitized; + } + return nextFeatureInfo; +}; + +const FeatureInfoRequestOptions = ({ + featureInfo = {}, + onChange = () => {}, + showBuffer = false +}) => { + return ( + <> + + + +  } /> + + onChange(updateOption(featureInfo, 'maxItems', event.target.value, sanitizeFeatureInfoMaxItems))} /> + + {showBuffer ? ( + + + +  } /> + + onChange(updateOption(featureInfo, 'buffer', event.target.value, sanitizeFeatureInfoBuffer))} /> + + ) : null} + + ); +}; + +FeatureInfoRequestOptions.propTypes = { + featureInfo: PropTypes.object, + onChange: PropTypes.func, + showBuffer: PropTypes.bool +}; + +export default FeatureInfoRequestOptions; diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index c186fa5f87a..d72af26ca9a 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -110,6 +110,14 @@ "groupProperties": "Gruppeneigenschaften", "featureInfo": "Feature Info", "featureInfoFormatLbl": "Antwortformat festlegen", + "featureInfoRequestOptions": { + "title": "Anfrageoptionen", + "description": "Limits für WMS-Identify-Anfragen konfigurieren", + "maxItems": "Anzahl der Features", + "maxItemsTooltip": "Maximale Anzahl von Features, die von einer Identify-Anfrage zurückgegeben werden. Der Standardwert ist 10.", + "buffer": "Puffer (px)", + "bufferTooltip": "GeoServer-Anbieterparameter, der den Identify-Suchradius um den angeklickten Punkt erweitert, gemessen in Bildschirmpixeln." + }, "guideText": "In der URL des Bildes können Sie Platzhalter wie $\\{properties.YOUR_ATTRIBUTE\\} verwenden, um die URL abhängig von den Feature-Attributen parametrisch zu machen. Es unterstützt sowohl URLs als auch „Data URI“, die in Base64 codiert sind", "imageNotFound": "Für die korrekte Darstellung dieses Bildes ist möglicherweise eine Feature-Info-Anfrage erforderlich", "legenderror": "Legende ist nicht verfügbar", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 05c9e9dc490..4bbdfc83327 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -110,6 +110,14 @@ "groupProperties": "Group properties", "featureInfo": "Feature Info", "featureInfoFormatLbl": "Identify response format", + "featureInfoRequestOptions": { + "title": "Request options", + "description": "Configure WMS Identify request limits", + "maxItems": "Feature count", + "maxItemsTooltip": "Maximum number of features returned by an Identify request. The default value is 10.", + "buffer": "Buffer (px)", + "bufferTooltip": "GeoServer vendor parameter that expands the Identify search radius around the clicked point, measured in screen pixels." + }, "guideText": "In the URL of the image you can use placeholders like $\\{properties.YOUR_ATTRIBUTE\\} to make the URL parametric, depending by the feature attributes. It supports both URLs or \"Data URIs\" encoded in base64", "imageNotFound": "This image may need a feature info request to be rendered correctly", "legenderror": "Legend is not available", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index 5f1a516a0e8..2d3d1fd4a5d 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -110,6 +110,14 @@ "groupProperties": "Propiedades del grupo", "featureInfo": "Información de la capa", "featureInfoFormatLbl": "Identificar el formato de respuesta", + "featureInfoRequestOptions": { + "title": "Opciones de solicitud", + "description": "Configurar los límites de la solicitud de identificación WMS", + "maxItems": "Número de entidades", + "maxItemsTooltip": "Número máximo de entidades devueltas por una solicitud de identificación. El valor predeterminado es 10.", + "buffer": "Búfer (px)", + "bufferTooltip": "Parámetro del proveedor de GeoServer que amplía el radio de búsqueda de identificación alrededor del punto seleccionado, medido en píxeles de pantalla." + }, "guideText": "En la URL de la imagen, puede utilizar marcadores de posición como $\\{properties.YOUR_ATTRIBUTE\\} para hacer que la URL sea paramétrica, según los atributos de la característica. Admite URL o \"Data URI\" codificados en base64", "imageNotFound": "Es posible que esta imagen necesite una solicitud de información de función para renderizarse correctamente", "legenderror": "La leyenda no está disponible", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 9251f841d68..a41584ed393 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -110,6 +110,14 @@ "groupProperties": "Propriétés du groupe", "featureInfo": "Informations attributaires de l'objet", "featureInfoFormatLbl": "Information, format de la réponse", + "featureInfoRequestOptions": { + "title": "Options de requête", + "description": "Configurer les limites de la requête d'identification WMS", + "maxItems": "Nombre d'entités", + "maxItemsTooltip": "Nombre maximum d'entités renvoyées par une requête d'identification. La valeur par défaut est 10.", + "buffer": "Tampon (px)", + "bufferTooltip": "Paramètre fournisseur GeoServer qui étend le rayon de recherche d'identification autour du point cliqué, mesuré en pixels d'écran." + }, "guideText": "Dans l'URL de l'image, vous pouvez utiliser des espaces réservés comme $\\{properties.YOUR_ATTRIBUTE\\} pour rendre l'URL paramétrique, en fonction des attributs de la fonctionnalité. Il prend en charge à la fois les URL ou les « Data URI » codés en base64.", "imageNotFound": "Cette image peut nécessiter une demande d\\'informations sur les fonctionnalités pour être rendue correctement", "legenderror": "La légende n'est pas disponible", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 0ab5574b875..74b4e92e5e3 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -110,6 +110,14 @@ "groupProperties": "Proprietà del gruppo", "featureInfo": "Feature Info", "featureInfoFormatLbl": "Formato risposta interrogazioni su mappa", + "featureInfoRequestOptions": { + "title": "Opzioni di richiesta", + "description": "Configura i limiti della richiesta di identificazione WMS", + "maxItems": "Numero di elementi", + "maxItemsTooltip": "Numero massimo di elementi restituiti da una richiesta di identificazione. Il valore predefinito è 10.", + "buffer": "Buffer (px)", + "bufferTooltip": "Parametro del fornitore GeoServer che espande il raggio di ricerca di identificazione attorno al punto cliccato, misurato in pixel dello schermo." + }, "guideText": "Nell'URL dell'immagine puoi utilizzare variabili come $\\{properties.YOUR_ATTRIBUTE\\} per rendere parametrico l'URL a seconda degli attributi della feature. Supporta sia URL che \"Data URI\" codificati in base64", "imageNotFound": "Questa immagine può necessitare di una richiesta informazioni sulle feature per essere renderizzata correttamente", "legenderror": "Legenda non disponibile", diff --git a/web/client/utils/FeatureInfoRequestUtils.js b/web/client/utils/FeatureInfoRequestUtils.js new file mode 100644 index 00000000000..23b806ae13b --- /dev/null +++ b/web/client/utils/FeatureInfoRequestUtils.js @@ -0,0 +1,38 @@ +/* + * Copyright 2026, 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 { clamp, isNil } from 'lodash'; + +import { ServerTypes } from './LayersUtils'; + +export const DEFAULT_FEATURE_COUNT = 10; +export const MAX_FEATURE_INFO_BUFFER = 1000; + +const sanitizeInteger = (value, min) => { + const parsed = value === '' || isNil(value) ? NaN : Number(value); + return Number.isInteger(parsed) && parsed >= min ? parsed : undefined; +}; + +export const sanitizeFeatureInfoMaxItems = (value) => sanitizeInteger(value, 1); + +export const sanitizeFeatureInfoBuffer = (value) => { + const buffer = sanitizeInteger(value, 0); + return buffer === undefined ? undefined : clamp(buffer, 0, MAX_FEATURE_INFO_BUFFER); +}; + +export const isGeoServerLayer = (layer = {}) => { + return layer.serverType !== ServerTypes.NO_VENDOR; +}; + +export const getFeatureInfoMaxItems = (featureInfo = {}, fallback = DEFAULT_FEATURE_COUNT) => + sanitizeFeatureInfoMaxItems(featureInfo.maxItems) + ?? sanitizeFeatureInfoMaxItems(fallback) + ?? DEFAULT_FEATURE_COUNT; + +export const getFeatureInfoBuffer = (featureInfo = {}, layer = {}) => + isGeoServerLayer(layer) ? sanitizeFeatureInfoBuffer(featureInfo.buffer) : undefined; diff --git a/web/client/utils/mapinfo/__tests__/wms-test.js b/web/client/utils/mapinfo/__tests__/wms-test.js index be75b0e0559..421a5ae9176 100644 --- a/web/client/utils/mapinfo/__tests__/wms-test.js +++ b/web/client/utils/mapinfo/__tests__/wms-test.js @@ -12,6 +12,7 @@ import MockAdapter from "axios-mock-adapter"; import axios from '../../../libs/ajax'; import {INFO_FORMATS} from "../../FeatureInfoUtils"; +import {ServerTypes} from "../../LayersUtils"; import {getFeatureInfo} from "../../../api/identify"; import wms from '../wms'; @@ -28,6 +29,60 @@ describe('mapinfo wms utils', () => { mockAxios = null; setTimeout(done); }); + it('should build a WMS GetFeatureInfo request with default feature_count', () => { + const { request } = wms.buildRequest({ + type: "wms", + id: "test_layer", + name: "test_layer", + url: "/geoserver/wms" + }, { + point: { latlng: { lat: 0, lng: 0 } }, + map: { projection: "EPSG:4326", resolution: 1 } + }); + + expect(request.feature_count).toBe(10); + expect(request.buffer).toNotExist(); + }); + it('should build a WMS GetFeatureInfo request with layer featureInfo maxItems', () => { + const layer = { + type: "wms", + id: "test_layer", + name: "test_layer", + url: "/geoserver/wms", + featureInfo: { + maxItems: 25 + } + }; + const { request } = wms.buildRequest(layer, { + point: { latlng: { lat: 0, lng: 0 } }, + map: { projection: "EPSG:4326", resolution: 1 }, + maxItems: 50 + }, undefined, undefined, layer.featureInfo); + + expect(request.feature_count).toBe(25); + }); + it('should include buffer only for GeoServer WMS GetFeatureInfo requests', () => { + const layer = { + type: "wms", + id: "test_layer", + name: "test_layer", + url: "/geoserver/wms", + serverType: ServerTypes.GEOSERVER, + featureInfo: { + buffer: 8 + } + }; + const options = { + point: { latlng: { lat: 0, lng: 0 } }, + map: { projection: "EPSG:4326", resolution: 1 } + }; + + expect(wms.buildRequest(layer, options, undefined, undefined, layer.featureInfo).request.buffer).toBe(8); + expect(wms.buildRequest(layer, options, undefined, undefined, { buffer: 1000 }).request.buffer).toBe(1000); + expect(wms.buildRequest(layer, options, undefined, undefined, { buffer: 1001 }).request.buffer).toBe(1000); + expect(wms.buildRequest(layer, options, undefined, undefined, { buffer: 'invalid' }).request.buffer).toNotExist(); + expect(wms.buildRequest({ ...layer, serverType: ServerTypes.NO_VENDOR }, options, undefined, undefined, layer.featureInfo).request.buffer).toNotExist(); + }); it('should return the response object from getIdentifyFlow in case of 200 with empty features,', (done) => { const SAMPLE_LAYER = { type: "wms", diff --git a/web/client/utils/mapinfo/wms.js b/web/client/utils/mapinfo/wms.js index 414791bc3d7..c75e7b861bf 100644 --- a/web/client/utils/mapinfo/wms.js +++ b/web/client/utils/mapinfo/wms.js @@ -13,6 +13,7 @@ import {getLayerUrl} from '../LayersUtils'; import {isObject, isNil} from 'lodash'; import { optionsToVendorParams } from '../VendorParamsUtils'; import { generateEnvString } from '../LayerLocalizationUtils'; +import { getFeatureInfoBuffer, getFeatureInfoMaxItems } from '../FeatureInfoRequestUtils'; import axios from "../../libs/ajax"; // import {parseString} from "xml2js"; // import {stripPrefix} from "xml2js/lib/processors"; @@ -56,6 +57,8 @@ export default { filterObj: layer.filterObj, params: Object.assign({}, layer.baseParams, layer.params, defaultParams) }); + const featureInfoMaxItems = getFeatureInfoMaxItems(featureInfo, maxItems); + const featureInfoBuffer = getFeatureInfoBuffer(featureInfo, layer); return { request: addAuthenticationToSLD({ service: 'WMS', @@ -74,11 +77,12 @@ export default { bounds.miny + "," + bounds.maxx + "," + bounds.maxy, - feature_count: maxItems, info_format: infoFormat, format: layer.format, ENV, - ...Object.assign({}, params) + ...Object.assign({}, params), + feature_count: featureInfoMaxItems, + ...(featureInfoBuffer !== undefined ? { buffer: featureInfoBuffer } : {}) }, layer), metadata: { title: isObject(layer.title) ? layer.title[currentLocale] || layer.title.default : layer.title, From 98560a6e21fdb1612b0bf0a43896a48e359d036d Mon Sep 17 00:00:00 2001 From: RowHeat <40065760+rowheat02@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:38:02 +0545 Subject: [PATCH 2/5] Multi View Identify #12595 (#12664) --------- Co-authored-by: allyoucanmap --- .../mapstore-migration-guide.md | 28 ++ web/client/actions/__tests__/mapInfo-test.js | 53 ++- web/client/actions/mapInfo.js | 22 +- web/client/api/__tests__/identify-test.js | 141 +++++++- web/client/api/identify.jsx | 63 +++- .../TOC/enhancers/tocItemsSettings.js | 15 +- .../TOC/fragments/settings/FeatureInfo.jsx | 316 +++++++++++++++--- .../fragments/settings/FeatureInfoEditor.jsx | 22 +- .../settings/__tests__/FeatureInfo-test.jsx | 212 +++++++----- .../__tests__/FeatureInfoEditor-test.jsx | 18 +- .../common/enhancers/withIdentifyPopup.jsx | 63 ++-- .../dashboard/hooks/useCheckScroll.js | 50 +-- .../data/identify/DefaultViewer.jsx | 142 +++++++- .../identify/__tests__/DefaultViewer-test.jsx | 262 +++++++++++++++ .../data/identify/viewers/JSONViewer.jsx | 2 +- .../common/enhancers/withPopupSupport.jsx | 66 ++-- web/client/components/misc/ScrollableTabs.jsx | 72 ++++ .../components/misc/panels/Accordion.jsx | 56 ---- .../misc/panels/__tests__/Accordion-test.jsx | 97 ------ web/client/epics/__tests__/identify-test.js | 283 ++++++++++++---- web/client/epics/identify.js | 108 +++--- web/client/hooks/useCheckScroll.js | 55 +++ .../tocitemssettings/defaultSettingsTabs.js | 10 +- .../tabs/FeatureInfo/index.jsx | 87 +---- .../FeatureInfo/previews/responseHTML.txt | 54 --- .../FeatureInfo/previews/responseJSON.txt | 99 ------ .../FeatureInfo/previews/responseText.txt | 26 -- web/client/reducers/__tests__/mapInfo-test.js | 98 +++--- web/client/reducers/mapInfo.js | 67 ++-- web/client/themes/default/less/mapstore.less | 1 + web/client/themes/default/less/panels.less | 57 ---- .../default/less/resources-catalog/_base.less | 21 +- .../themes/default/less/scrollable-tabs.less | 37 ++ .../themes/default/less/toc-settings.less | 64 ++++ web/client/translations/data.de-DE.json | 17 +- web/client/translations/data.en-US.json | 17 +- web/client/translations/data.es-ES.json | 17 +- web/client/translations/data.fr-FR.json | 17 +- web/client/translations/data.it-IT.json | 17 +- web/client/utils/MapInfoUtils.js | 132 +++++++- .../utils/__tests__/MapInfoUtils-test.js | 196 +++++++++++ 41 files changed, 2124 insertions(+), 1056 deletions(-) create mode 100644 web/client/components/misc/ScrollableTabs.jsx delete mode 100644 web/client/components/misc/panels/Accordion.jsx delete mode 100644 web/client/components/misc/panels/__tests__/Accordion-test.jsx create mode 100644 web/client/hooks/useCheckScroll.js delete mode 100644 web/client/plugins/tocitemssettings/tabs/FeatureInfo/previews/responseHTML.txt delete mode 100644 web/client/plugins/tocitemssettings/tabs/FeatureInfo/previews/responseJSON.txt delete mode 100644 web/client/plugins/tocitemssettings/tabs/FeatureInfo/previews/responseText.txt create mode 100644 web/client/themes/default/less/scrollable-tabs.less diff --git a/docs/developer-guide/mapstore-migration-guide.md b/docs/developer-guide/mapstore-migration-guide.md index 87cdab98883..26202347369 100644 --- a/docs/developer-guide/mapstore-migration-guide.md +++ b/docs/developer-guide/mapstore-migration-guide.md @@ -20,6 +20,34 @@ This is a list of things to check if you want to update from a previous version - Optionally check also accessory files like `.eslinrc`, if you want to keep aligned with lint standards. - Follow the instructions below, in order, from your version to the one you want to update to. +## Migration from 2026.02.02 to 2026.03.00 + +### Identify supports multiple views per layer + +The `featureInfo` of a layer describes a list of views instead of a single format. The identify panel renders one tab per view. + +```json +{ + "featureInfo": { + "disabled": false, + "views": [ + { "id": "properties", "type": "PROPERTIES" }, + { "id": "report", "title": "Report", "type": "TEMPLATE", "template": "

${properties.NAME}

" } + ] + } +} +``` + +The previous configuration is still read, so existing maps keep working. `format: "HIDDEN"` is equivalent to `disabled: true`. + +```json +{ "featureInfo": { "format": "TEMPLATE", "template": "

${properties.NAME}

" } } +``` + +Saving the Feature Info settings replaces `format`, `template` and `viewer` with `disabled` and `views`, and previous versions do not read that shape: they fall back to the identify format of the map settings and they query again a layer with identify disabled. + +Custom code reading the identify results from the state finds the response of every view in `viewResponses`, keyed by view id, instead of a single `response` and `queryParams`. + ## Migration from 2026.02.00 to 2026.02.01 ### Login `hideGroupUserInfo` configuration diff --git a/web/client/actions/__tests__/mapInfo-test.js b/web/client/actions/__tests__/mapInfo-test.js index 3e05bb5031a..b583d7aa3f6 100644 --- a/web/client/actions/__tests__/mapInfo-test.js +++ b/web/client/actions/__tests__/mapInfo-test.js @@ -44,7 +44,9 @@ import { onInitPlugin, INIT_PLUGIN, loadFeatureInfo, - LOAD_FEATURE_INFO + LOAD_FEATURE_INFO, + errorFeatureInfo, + ERROR_FEATURE_INFO } from '../mapInfo'; describe('Test correctness of the map actions', () => { @@ -60,15 +62,13 @@ describe('Test correctness of the map actions', () => { it('add new info request', () => { const reqIdVal = 100; - const requestVal = {p: "p"}; - const e = newMapInfoRequest(reqIdVal, requestVal); + const e = newMapInfoRequest(reqIdVal); expect(e).toExist(); expect(e.type).toBe(NEW_MAPINFO_REQUEST); expect(e.reqId).toExist(); expect(e.reqId).toBeA('number'); expect(e.reqId).toBe(100); - expect(e.request).toExist(); - expect(e.request.p).toBe("p"); + expect(e.request).toNotExist(); }); it('delete all results', () => { @@ -172,40 +172,63 @@ describe('Test correctness of the map actions', () => { }); it('test loadFeatureInfo default', () => { const reqId = "123"; - const data = {id: "layer.1"}; - const rParams = {cql_filter: "ID_ORIG=1234"}; const lMetaData = {features: [], featuresCrs: "EPSG:4326"}; + const viewResponses = {'default': {response: {id: "layer.1"}, queryParams: {cql_filter: "ID_ORIG=1234"}}}; const layer = {name: "layer01"}; - const action = loadFeatureInfo(reqId, data, rParams, lMetaData, layer); + const action = loadFeatureInfo(reqId, lMetaData, viewResponses, layer); expect(action).toExist(); expect(action.type).toEqual(LOAD_FEATURE_INFO); - expect(action.data).toEqual(data); expect(action.reqId).toEqual(reqId); - expect(action.requestParams).toEqual(rParams); expect(action.layerMetadata).toEqual(lMetaData); + expect(action.viewResponses).toEqual(viewResponses); expect(action.layer).toEqual(layer); expect(action.queryParamZoomOption).toEqual(null); }); it('test loadFeatureInfo with queryParamZoomOption', () => { const reqId = "123"; - const data = {id: "layer.1"}; - const rParams = {cql_filter: "ID_ORIG=1234"}; const lMetaData = {features: [], featuresCrs: "EPSG:4326"}; + const viewResponses = {'default': {response: {id: "layer.1"}, queryParams: {cql_filter: "ID_ORIG=1234"}}}; const layer = {name: "layer01"}; const queryParamZoomOption = { overrideZoomLvl: 5, isCoordsProvided: false }; - const action = loadFeatureInfo(reqId, data, rParams, lMetaData, layer, queryParamZoomOption); + const action = loadFeatureInfo(reqId, lMetaData, viewResponses, layer, queryParamZoomOption); expect(action).toExist(); expect(action.type).toEqual(LOAD_FEATURE_INFO); - expect(action.data).toEqual(data); expect(action.reqId).toEqual(reqId); - expect(action.requestParams).toEqual(rParams); expect(action.layerMetadata).toEqual(lMetaData); + expect(action.viewResponses).toEqual(viewResponses); expect(action.layer).toEqual(layer); expect(action.queryParamZoomOption).toEqual(queryParamZoomOption); }); + it('preserves responses for multiple identify views', () => { + const viewResponses = { + properties: { + response: {features: [{id: 'feature-1'}]}, + queryParams: {info_format: 'application/json'} + }, + html: { + response: '

Feature 1

', + queryParams: {info_format: 'text/html'} + } + }; + const action = loadFeatureInfo('123', {}, viewResponses, {name: 'layer01'}); + + expect(action.viewResponses).toEqual(viewResponses); + expect(action.viewResponses.properties.queryParams.info_format).toBe('application/json'); + expect(action.viewResponses.html.response).toBe('

Feature 1

'); + }); + it('creates an error feature-info action with its request ID and error', () => { + const error = new Error('GetFeatureInfo failed'); + const action = errorFeatureInfo('123', error); + + expect(action).toEqual({ + type: ERROR_FEATURE_INFO, + error, + reqId: '123' + }); + }); it('reset reverse geocode data', () => { const e = hideMapinfoRevGeocode(); expect(e).toExist(); diff --git a/web/client/actions/mapInfo.js b/web/client/actions/mapInfo.js index b1373d78551..7f152429183 100644 --- a/web/client/actions/mapInfo.js +++ b/web/client/actions/mapInfo.js @@ -43,15 +43,14 @@ export const toggleEmptyMessageGFI = () => ({type: TOGGLE_EMPTY_MESSAGE_GFI}); /** * Private - * @return a LOAD_FEATURE_INFO action with the response data to a wms GetFeatureInfo + * @return a LOAD_FEATURE_INFO action containing the responses for all configured views */ -export function loadFeatureInfo(reqId, data, rParams, lMetaData, layer, queryParamZoomOption = null) { +export function loadFeatureInfo(reqId, layerMetadata, viewResponses, layer, queryParamZoomOption = null) { return { type: LOAD_FEATURE_INFO, - data: data, - reqId: reqId, - requestParams: rParams, - layerMetadata: lMetaData, + reqId, + layerMetadata, + viewResponses, layer, queryParamZoomOption }; @@ -61,13 +60,11 @@ export function loadFeatureInfo(reqId, data, rParams, lMetaData, layer, queryPar * Private * @return a ERROR_FEATURE_INFO action with the error occurred */ -export function errorFeatureInfo(reqId, e, rParams, lMetaData) { +export function errorFeatureInfo(reqId, e) { return { type: ERROR_FEATURE_INFO, error: e, - reqId: reqId, - requestParams: rParams, - layerMetadata: lMetaData + reqId }; } @@ -98,11 +95,10 @@ export function clearWarning() { }; } -export function newMapInfoRequest(reqId, reqConfig) { +export function newMapInfoRequest(reqId) { return { type: NEW_MAPINFO_REQUEST, - reqId: reqId, - request: reqConfig + reqId }; } diff --git a/web/client/api/__tests__/identify-test.js b/web/client/api/__tests__/identify-test.js index 9e30bac7a9d..172f7c58f68 100644 --- a/web/client/api/__tests__/identify-test.js +++ b/web/client/api/__tests__/identify-test.js @@ -10,7 +10,7 @@ import expect from 'expect'; import MockAdapter from "axios-mock-adapter"; import axios from "../../libs/ajax"; -import { getFeatureInfo } from '../identify'; +import { getFeatureInfo, getFeatureInfoForViews } from '../identify'; let mockAxios; @@ -260,4 +260,143 @@ describe('identify API', () => { }); }); + describe('getFeatureInfoForViews', () => { + const VIEWS_LAYER = { + type: "wms", + name: "test_layer", + url: "TEST_URL", + featureInfo: { + views: [ + { id: 'properties', type: 'PROPERTIES' }, + { id: 'template', type: 'TEMPLATE' }, + { id: 'html', type: 'HTML' } + ] + } + }; + const IDENTIFY_OPTIONS = { + map: { zoom: 0, projection: 'EPSG:4326' }, + point: { latlng: { lat: 0, lng: 0 } } + }; + const mockInfoFormats = (failing = []) => { + mockAxios = new MockAdapter(axios); + mockAxios.onGet().reply((req) => { + if (failing.includes(req.params.info_format)) { + return [500, "ERROR"]; + } + switch (req.params.info_format) { + case INFO_FORMATS.HTML: + return [200, WMS_HTML]; + case INFO_FORMATS.JSON: + return [200, WMS_JSON]; + default: + return [404, "NOT FOUND"]; + } + }); + }; + afterEach((done) => { + if (mockAxios) { + mockAxios.restore(); + } + mockAxios = null; + setTimeout(done); + }); + it('groups the responses by view and shares the deduplicated request', (done) => { + mockInfoFormats(); + getFeatureInfoForViews(VIEWS_LAYER, IDENTIFY_OPTIONS).subscribe( + ({ views, viewResponses, features, primaryResponse, error }) => { + try { + expect(error).toNotExist(); + expect(views.length).toBe(3); + expect(Object.keys(viewResponses).sort()).toEqual(['html', 'properties', 'template']); + expect(viewResponses.properties.queryParams.info_format).toBe(INFO_FORMATS.JSON); + expect(viewResponses.template.queryParams.info_format).toBe(INFO_FORMATS.JSON); + expect(viewResponses.html.queryParams.info_format).toBe(INFO_FORMATS.HTML); + expect(viewResponses.properties.response).toBe(viewResponses.template.response); + expect(viewResponses.html.response.indexOf('= 0).toBeTruthy(); + expect(features).toBeTruthy(); + expect(primaryResponse.response.features).toBeTruthy(); + done(); + } catch (ex) { + done(ex); + } + }, + error => done(error) + ); + }); + it('keeps the views that succeeded when one request fails', (done) => { + mockInfoFormats([INFO_FORMATS.HTML]); + getFeatureInfoForViews(VIEWS_LAYER, IDENTIFY_OPTIONS).subscribe( + ({ views, viewResponses, error }) => { + try { + expect(error).toNotExist(); + expect(views.length).toBe(3); + expect(Object.keys(viewResponses).sort()).toEqual(['properties', 'template']); + done(); + } catch (ex) { + done(ex); + } + }, + error => done(error) + ); + }); + it('returns the error when every request fails', (done) => { + mockInfoFormats([INFO_FORMATS.HTML, INFO_FORMATS.JSON]); + getFeatureInfoForViews(VIEWS_LAYER, IDENTIFY_OPTIONS).subscribe( + ({ views, viewResponses, error }) => { + try { + expect(views.length).toBe(3); + expect(viewResponses).toNotExist(); + expect(error).toExist(); + done(); + } catch (ex) { + done(ex); + } + }, + error => done(error) + ); + }); + it('returns null when the layer has no request to perform', () => { + expect(getFeatureInfoForViews({ + ...VIEWS_LAYER, + featureInfo: { ...VIEWS_LAYER.featureInfo, disabled: true } + }, IDENTIFY_OPTIONS)).toBe(null); + }); + // these fields are the ones withCarouselMarkerInteraction reads + it('keeps the flat vector response used to resolve a clicked carousel marker', (done) => { + const feature = { + type: 'Feature', + geometry: null, + properties: { sectionId: 'section-1', contentId: 'content-1', title: 'Marker' } + }; + getFeatureInfoForViews({ + id: 'vector-layer', + type: 'vector', + name: 'vector_layer', + features: [feature] + }, { + format: INFO_FORMATS.JSON, + map: { zoom: 0, projection: 'EPSG:4326' }, + point: { + latlng: { lat: 0, lng: 0 }, + intersectedFeatures: [{ id: 'vector-layer', features: [feature] }] + } + }).subscribe( + ({ layerMetadata, viewResponses, primaryResponse, error }) => { + try { + expect(error).toNotExist(); + expect(layerMetadata.layerId).toBe('vector-layer'); + expect(primaryResponse.response.features.length).toBe(1); + expect(primaryResponse.response.features[0].properties.sectionId).toBe('section-1'); + expect(primaryResponse.queryParams.request).toNotExist(); + expect(Object.keys(viewResponses).length).toBe(1); + done(); + } catch (ex) { + done(ex); + } + }, + error => done(error) + ); + }); + }); + }); diff --git a/web/client/api/identify.jsx b/web/client/api/identify.jsx index 7fd522f45ed..e29affa8f71 100644 --- a/web/client/api/identify.jsx +++ b/web/client/api/identify.jsx @@ -8,7 +8,7 @@ import { isString, isNil } from 'lodash'; import { Observable } from 'rxjs'; -import {getIdentifyFlow, isDataFormat} from '../utils/MapInfoUtils'; +import {buildIdentifyRequest, buildIdentifyRequestPlan, getIdentifyFlow, isDataFormat} from '../utils/MapInfoUtils'; import axios from '../libs/ajax'; import {parseURN} from '../utils/CoordinatesUtils'; import { GEOJSON_MIME_TYPE, JSON_MIME_TYPE } from '../utils/FeatureInfoUtils'; @@ -59,3 +59,64 @@ export const getFeatureInfo = (basePath, param, layer, {attachJSON, itemId = nul })) ); }; + +const associateResponsesToViews = (responses) => responses.reduce((viewResponses, { response, requestParams, viewIds = [] }) => { + viewIds.forEach((viewId) => { + viewResponses[viewId] = { + response: response.data, + queryParams: requestParams + }; + }); + return viewResponses; +}, {}); + +/** + * Runs the identify requests needed by the views configured on a layer, grouping the responses by view id. + * @param {object} layer the layer object + * @param {object} identifyOptions options used to build the requests (map, point, format, env) + * @param {object} options + * @param {object} options.params params applied to every request + * @param {object} options.requestOptions options forwarded to `getFeatureInfo` + * @param {function} options.mapRequestParams overrides the params of every request + * @return {Observable|null} null when the layer has no request to perform + */ +export const getFeatureInfoForViews = (layer, identifyOptions, { + params = {}, + requestOptions = {}, + mapRequestParams = (requestParams) => requestParams +} = {}) => { + const { views, requests } = buildIdentifyRequestPlan(layer, identifyOptions); + if (!requests.length) { + return null; + } + // metadata belongs to the layer, not to one of its view requests + const { metadata: layerMetadata = {} } = buildIdentifyRequest(layer, identifyOptions); + return Observable.forkJoin(requests.map(({ url, request, viewIds }) => { + const requestParams = mapRequestParams(request); + return getFeatureInfo(url, { ...params, ...requestParams }, layer, requestOptions) + // vector/3dtiles responses are synchronous, the delay lets the panel render its spinner + // and avoids freezing the app when many layers are queried at once + .delay(0) + .map((response) => ({ response, requestParams, viewIds })) + .catch((error) => Observable.of({ error, requestParams, viewIds })); + })).map((results) => { + const responses = results.filter(({ error }) => !error); + if (!responses.length) { + return { views, layerMetadata, error: results.find(({ error }) => error)?.error }; + } + const featureResponse = responses.find(({ response }) => response.features?.length) + || responses.find(({ response }) => response.features) + || responses[0]; + return { + views, + layerMetadata, + viewResponses: associateResponsesToViews(responses), + features: featureResponse.response.features, + featuresCrs: featureResponse.response.featuresCrs, + primaryResponse: { + response: featureResponse.response.data, + queryParams: featureResponse.requestParams + } + }; + }); +}; diff --git a/web/client/components/TOC/enhancers/tocItemsSettings.js b/web/client/components/TOC/enhancers/tocItemsSettings.js index c00642303dc..802233a8d13 100644 --- a/web/client/components/TOC/enhancers/tocItemsSettings.js +++ b/web/client/components/TOC/enhancers/tocItemsSettings.js @@ -7,18 +7,7 @@ */ import { isArray, isFunction, isNil } from 'lodash'; -import { compose, lifecycle, withHandlers, withState } from 'recompose'; - -/** - * Enhancer for settings state needed in TOCItemsSettings plugin - * - onShowAlertModal, alert modal appears on close in case of changes in TOCItemsSettings - * - onShowEditor, edit modal appears on format info in TOCItemsSettings - * @memberof enhancers.settingsState - * @class - */ -export const settingsState = compose( - withState('showEditor', 'onShowEditor', false) -); +import { compose, lifecycle, withHandlers } from 'recompose'; /** * Basic toc settings lificycle used in TOCItemsSettings plugin with TOCItemsSettings component @@ -76,12 +65,10 @@ export const settingsLifecycle = compose( ); export const updateSettingsLifecycle = compose( - settingsState, settingsLifecycle ); export default { - settingsState, settingsLifecycle, /** * Enhancer for compose together settings lifecycle and state diff --git a/web/client/components/TOC/fragments/settings/FeatureInfo.jsx b/web/client/components/TOC/fragments/settings/FeatureInfo.jsx index fadea8ef74e..d4f0d855b72 100644 --- a/web/client/components/TOC/fragments/settings/FeatureInfo.jsx +++ b/web/client/components/TOC/fragments/settings/FeatureInfo.jsx @@ -9,32 +9,153 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Accordion from '../../../misc/panels/Accordion'; import { getSupportedFormat as getSupportedFormatWMS } from '../../../../api/WMS'; import { getSupportedFormat as getSupportedFormatWFS } from '../../../../api/WFS'; import Loader from '../../../misc/Loader'; -import { Glyphicon } from 'react-bootstrap'; -import Message from '../../../I18N/Message'; +import { Button, Checkbox, FormControl as FormControlRB, Glyphicon } from 'react-bootstrap'; +import Select from 'react-select'; +import { DragSource as dragSource, DropTarget as dropTarget } from 'react-dnd'; import includes from 'lodash/includes'; import isEmpty from 'lodash/isEmpty'; -import { getDefaultInfoViewMode } from '../../../../utils/MapInfoUtils'; +import { v1 as uuidv1 } from 'uuid'; +import { + getDefaultInfoViewMode, + getLayerFeatureInfoViews, + isLayerFeatureInfoDisabled +} from '../../../../utils/MapInfoUtils'; +import Message from '../../../I18N/Message'; +import FeatureInfoEditor from './FeatureInfoEditor'; +import localizedProps from '../../../misc/enhancers/localizedProps'; import FeatureInfoRequestOptions from '../../../misc/FeatureInfoRequestOptions'; import { isGeoServerLayer } from '../../../../utils/FeatureInfoRequestUtils'; +const FormControl = localizedProps('placeholder')(FormControlRB); + const supportedFormatRequests = { wms: getSupportedFormatWMS, wfs: getSupportedFormatWFS }; +const FeatureInfoView = ({ + view, + canEdit, + connectDragSource = cmp => cmp, + connectDragPreview = cmp => cmp, + connectDropTarget = cmp => cmp, + isDisabled = false, + isDraggable, + onEdit = () => {}, + onRemove = () => {}, + onUpdateView = () => {}, + renderTypeSelect = () => null +}) => { + const content = ( +
+ {isDraggable ? connectDragSource( +
event.stopPropagation()}> + +
+ ) : ( +
+ +
+ )} + onUpdateView(view.id, { title: event.target.value })}/> +
+ {renderTypeSelect(view)} +
+ + +
+ ); + return isDraggable ? connectDragPreview(connectDropTarget(content)) : content; +}; + +const ITEM_KEY = 'feature-info-view'; + +const drag = dragSource(ITEM_KEY, + { + beginDrag: ({ view, index }) => ({ + id: view.id, + index + }) + }, + (connect, monitor) => ({ + connectDragSource: connect.dragSource(), + connectDragPreview: connect.dragPreview(), + isDragging: monitor.isDragging() + }) +); + +const drop = dropTarget(ITEM_KEY, + { + hover: (props, monitor) => { + const item = monitor.getItem(); + const { index, view, onMove = () => {} } = props; + const node = document.querySelector(`[data-id="feature-info-view-${view.id}"]`); + + if (!node?.getBoundingClientRect) { + return null; + } + const dragIndex = item.index; + const hoverIndex = index; + if (dragIndex === hoverIndex) { + return null; + } + + const hoverBoundingRect = node.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return null; + } + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return null; + } + + onMove(dragIndex, hoverIndex); + item.index = hoverIndex; + return null; + } + }, + (connect, monitor) => ({ + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }) +); + +const DraggableFeatureInfoView = drag(drop(FeatureInfoView)); + /** - * Component for rendering FeatureInfo an Accordion with current available format for get feature info + * Component for rendering the list of identify views configured on a layer * @memberof components.TOC.fragments.settings * @name FeatureInfo * @class * @prop {object} element data of the current selected node - * @prop {array} defaultInfoFormat array of formats - * @prop {object} formatCards object that represents the panels of accordion, e.g.: { FORMAT_NAME: { titleId: 'titleMsgId', descId: 'descMsgId', glyph: 'ext-empty', body: () =>
} } - * @prop {function} onChange called when a format has been selected + * @prop {object} defaultInfoFormat supported info formats, by view type + * @prop {object} formatCards label and glyph of every view type, e.g.: { FORMAT_NAME: { titleId: 'titleMsgId', glyph: 'ext-empty' } } + * @prop {function} onChange called when the views configuration changes */ export default class extends React.Component { static propTypes = { @@ -52,7 +173,8 @@ export default class extends React.Component { }; state = { - loading: false + loading: false, + editingViewId: null }; componentDidMount() { @@ -71,20 +193,8 @@ export default class extends React.Component { } } - getInfoViews = (infoFormats) => { - return Object.keys(infoFormats).map((infoFormat) => { - const Body = this.props.formatCards[infoFormat] && this.props.formatCards[infoFormat].body; - return { - id: infoFormat, - head: { - preview: , - title: this.props.formatCards[infoFormat] && this.props.formatCards[infoFormat].titleId && || '', - description: this.props.formatCards[infoFormat] && this.props.formatCards[infoFormat].descId && || '', - size: 'sm' - }, - body: Body && || null - }; - }); + getTypeOptions = () => { + return Object.keys(this.transformInfoFormatsToViews(this.supportedInfoFormats())); } transformInfoFormatsToViews = (infoFormats) => { @@ -99,16 +209,111 @@ export default class extends React.Component { return infoFormats; } - render() { - // the selected value if missing on that layer should be set to the general info format value and not the first one. - const data = this.getInfoViews( - this.transformInfoFormatsToViews( - { - 'HIDDEN': true, - ...this.supportedInfoFormats() - } - ) + getFeatureInfo = (disabled, views) => { + const { format, template, viewer, ...featureInfo } = this.props.element.featureInfo || {}; + return { + ...featureInfo, + disabled, + views + }; + } + + updateFeatureInfo = (disabled, views) => { + this.props.onChange("featureInfo", this.getFeatureInfo(disabled, views)); + } + + getViews = () => { + return getLayerFeatureInfoViews(this.props.element, { includeDisabled: true }); + } + + updateView = (viewId, changes) => { + const views = this.getViews().map((view) => view.id === viewId ? { + ...view, + ...changes + } : view); + this.updateFeatureInfo(isLayerFeatureInfoDisabled(this.props.element), views); + } + + addView = () => { + const views = this.getViews(); + const defaultType = this.getTypeOptions()[0] || 'PROPERTIES'; + this.updateFeatureInfo(isLayerFeatureInfoDisabled(this.props.element), [ + ...views, + { + id: `view-${uuidv1()}`, + title: '', + type: defaultType + } + ]); + } + + removeView = (viewId) => { + const views = this.getViews().filter((view) => view.id !== viewId); + this.updateFeatureInfo(isLayerFeatureInfoDisabled(this.props.element), views); + } + + reorderView = (sourceIndex, targetIndex) => { + const views = this.getViews(); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) { + return; + } + const updatedViews = [...views]; + const [view] = updatedViews.splice(sourceIndex, 1); + updatedViews.splice(targetIndex, 0, view); + this.updateFeatureInfo(isLayerFeatureInfoDisabled(this.props.element), updatedViews); + } + + renderTypeSelect = (view, isDisabled) => { + const options = this.getTypeOptions().map((type) => ({ + value: type, + label: this.props.formatCards[type]?.titleId + ? + : type, + glyph: this.props.formatCards[type]?.glyph || 'ext-empty' + })); + return ( + ({ + value: featureType.name, + label: featureType.title === featureType.name + ? featureType.name + : `${featureType.title} (${featureType.name})` + }))} + onChange={onLayerChange}/> + + + + * + updateValue({ cqlFilter: event.target.value })}/> + + + + + +
+ + {validation.messageId ? ( + + + + ) : null} + {validation.status === 'success' ? ( + + + + ) : null} + {validation.cqlFilter ? ( +
+ + + +
{validation.cqlFilter}
+
+ ) : null} +
+ + {typeName ? ( +
+ } + fields={attributes} + currentLocale={currentLocale} + loading={attributesStatus === 'loading'} + error={attributesStatus === 'error'} + showVisibility + onChange={(name, property, nextValue) => updateAttribute(name, { + [property]: nextValue + })} + onChangeAll={updateAllAttributes} + onLoadFields={() => loadAttributes(typeName, attributes)} + onClear={() => loadAttributes(typeName, [])}/> +
+ ) : null} +
+ ); +}; + +ExternalDataEditor.propTypes = { + value: PropTypes.object, + onChange: PropTypes.func, + sourceLayer: PropTypes.object, + currentLocale: PropTypes.string +}; + +export default ExternalDataEditor; diff --git a/web/client/components/TOC/fragments/settings/FeatureInfo.jsx b/web/client/components/TOC/fragments/settings/FeatureInfo.jsx index d4f0d855b72..1f478efd30e 100644 --- a/web/client/components/TOC/fragments/settings/FeatureInfo.jsx +++ b/web/client/components/TOC/fragments/settings/FeatureInfo.jsx @@ -28,8 +28,12 @@ import FeatureInfoEditor from './FeatureInfoEditor'; import localizedProps from '../../../misc/enhancers/localizedProps'; import FeatureInfoRequestOptions from '../../../misc/FeatureInfoRequestOptions'; import { isGeoServerLayer } from '../../../../utils/FeatureInfoRequestUtils'; +import ExternalDataEditor from './ExternalDataEditor'; +import PropertiesEditor from './PropertiesEditor'; +import { EXTERNAL_DATA, validateExternalDataConfiguration } from '../../../../utils/mapinfo/ExternalDataUtils'; const FormControl = localizedProps('placeholder')(FormControlRB); +const GlyphiconWithTitle = localizedProps('title')(Glyphicon); const supportedFormatRequests = { wms: getSupportedFormatWMS, @@ -44,6 +48,8 @@ const FeatureInfoView = ({ connectDropTarget = cmp => cmp, isDisabled = false, isDraggable, + isEditing = false, + isInvalid = false, onEdit = () => {}, onRemove = () => {}, onUpdateView = () => {}, @@ -52,7 +58,7 @@ const FeatureInfoView = ({ const content = (
+ className={`ms-feature-info-view${isDisabled ? ' disabled' : ''}${isInvalid ? ' has-error' : ''}`}> {isDraggable ? connectDragSource(
+ {isInvalid ? ( + + ) : null}
) : null} {views.map((view, index) => this.renderView(view, views, index, disabled))} - {!disabled && editingView ? ( + {!disabled && editingView?.type === 'TEMPLATE' ? ( { + const type = field.type || ''; + return GEOMETRY_FIELD_TYPES.has(type) || isGeometryType({ ...field, type }); +}; + +const getAttributes = (fields = [], configuredAttributes = []) => { + const sourceFields = fields.length ? fields : configuredAttributes; + return sourceFields + .filter((field) => !isGeometryField(field)) + .map((field) => { + const configuredAttribute = configuredAttributes.find(({ name }) => name === field.name); + return { + ...field, + ...configuredAttribute, + visible: configuredAttribute?.visible ?? field.visible ?? true + }; + }); +}; + +/** + * Configures the attributes rendered by one Properties Identify view. + * It loads the current layer schema when field metadata is not already available. + */ +const PropertiesEditor = ({ sourceLayer = {}, value = [], onChange = () => {}, currentLocale }) => { + const [describedFields, setDescribedFields] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const isMounted = useIsMounted(); + const layerFields = sourceLayer.fields || EMPTY_FIELDS; + const schemaFields = describedFields ?? (layerFields.length ? layerFields : EMPTY_FIELDS); + const attributes = getAttributes(schemaFields, value); + + const loadAttributes = (merge) => { + const layerName = sourceLayer.search?.name + || sourceLayer.search?.typeName + || sourceLayer.name; + const sourceUrl = sourceLayer.describeFeatureTypeURL + || sourceLayer.search?.url + || sourceLayer.url; + if (!sourceUrl || !layerName) { + setError(true); + return; + } + setError(false); + setLoading(true); + describeFeatureType({ + layer: { + ...sourceLayer, + name: layerName + } + }).toPromise() + .then(({ data }) => isMounted(() => { + const nextFields = (data?.featureTypes?.[0]?.properties || []) + .filter((field) => !isGeometryType(field)) + .map((field) => ({ + name: field.name, + type: field.localType || field.type, + alias: '' + })); + setLoading(false); + setDescribedFields(nextFields); + onChange(getAttributes(nextFields, merge ? value : [])); + })) + .catch(() => isMounted(() => { + setLoading(false); + setError(true); + })); + }; + + useEffect(() => { + if (!layerFields.length && !value.length) { + loadAttributes(true); + } + }, []); + + const updateAttribute = (name, property, nextValue) => { + onChange(attributes.map((attribute) => attribute.name === name + ? { ...attribute, [property]: nextValue } + : attribute)); + }; + + const updateAllAttributes = (property, nextValue) => { + onChange(attributes.map((attribute) => ({ ...attribute, [property]: nextValue }))); + }; + + return ( +
+ } + fields={attributes} + currentLocale={currentLocale} + loading={loading} + error={error} + showVisibility + onChange={updateAttribute} + onChangeAll={updateAllAttributes} + onLoadFields={() => loadAttributes(true)} + onClear={() => loadAttributes(false)}/> + {!loading && !error && !attributes.length ? ( + + + + ) : null} +
+ ); +}; + +PropertiesEditor.propTypes = { + sourceLayer: PropTypes.object, + value: PropTypes.array, + onChange: PropTypes.func, + currentLocale: PropTypes.string +}; + +export default PropertiesEditor; diff --git a/web/client/components/TOC/fragments/settings/__tests__/ExternalDataEditor-test.jsx b/web/client/components/TOC/fragments/settings/__tests__/ExternalDataEditor-test.jsx new file mode 100644 index 00000000000..c42626f7e5a --- /dev/null +++ b/web/client/components/TOC/fragments/settings/__tests__/ExternalDataEditor-test.jsx @@ -0,0 +1,386 @@ +/* + * Copyright 2026, 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 MockAdapter from 'axios-mock-adapter'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-dom/test-utils'; + +import axios from '../../../../../libs/ajax'; +import ExternalDataEditor, { + getExternalAttributes, + getWFSFeatureTypes +} from '../ExternalDataEditor'; +import { validateExternalDataConfiguration } from '../../../../../utils/mapinfo/ExternalDataUtils'; + +let mockAxios; + +const getCapabilitiesResponse = (name, title) => ` + + + + ${name} + ${title} + + + +`; + +describe('ExternalDataEditor', () => { + beforeEach(() => { + mockAxios = new MockAdapter(axios); + document.body.innerHTML = '
'; + }); + + afterEach(() => { + mockAxios.restore(); + ReactDOM.unmountComponentAtNode(document.getElementById('container')); + document.body.innerHTML = ''; + }); + + it('extracts WFS feature types from capabilities', () => { + expect(getWFSFeatureTypes({ + 'wfs:WFS_Capabilities': { + FeatureTypeList: { + FeatureType: [{ Name: 'workspace:table', Title: 'Table' }] + } + } + })).toEqual([{ name: 'workspace:table', title: 'Table' }]); + }); + + it('creates visible non-geometry attributes and preserves customization', () => { + expect(getExternalAttributes({ + featureTypes: [{ + properties: [ + { name: 'id', type: 'xsd:int', localType: 'number' }, + { name: 'name', type: 'xsd:string', localType: 'string' }, + { name: 'geom', type: 'gml:Point', localType: 'Point' } + ] + }] + }, [{ name: 'name', alias: 'Label', visible: false }])).toEqual([ + { name: 'id', type: 'number', alias: '', visible: true }, + { name: 'name', type: 'string', alias: 'Label', visible: false } + ]); + }); + + it('validates mandatory fields, placeholders and CQL syntax', () => { + expect(validateExternalDataConfiguration({})).toBe( + 'layerProperties.externalData.validation.missingFields' + ); + expect(validateExternalDataConfiguration({ + url: '/geoserver/wfs', + typeName: 'workspace:table', + cqlFilter: "target_id = '${feature.id}'" + })).toBe('layerProperties.externalData.validation.invalidPlaceholder'); + expect(validateExternalDataConfiguration({ + url: '/geoserver/wfs', + typeName: 'workspace:table', + cqlFilter: "target_id = '${properties.source_id}' AND" + })).toBe('layerProperties.externalData.validation.invalidCql'); + expect(validateExternalDataConfiguration({ + url: '/geoserver/wfs', + typeName: 'workspace:table', + cqlFilter: "target_id = '${properties['source_id']}'" + })).toBe(null); + }); + + it('renders and updates CQL and attribute presentation settings', () => { + let value = { + url: '', + typeName: 'workspace:table', + cqlFilter: "target_id = '${properties.id}'", + attributes: [{ name: 'name', type: 'string', alias: '', visible: true }] + }; + const onChange = (nextValue) => { + value = nextValue; + }; + ReactDOM.render( + , + document.getElementById('container') + ); + + const cql = document.querySelector('[data-qa="external-data-cql"]'); + TestUtils.Simulate.change(cql, { target: { value: "code = '${properties.code}'" } }); + expect(value.cqlFilter).toBe("code = '${properties.code}'"); + + ReactDOM.render( + , + document.getElementById('container') + ); + const attributeRow = document.querySelector('.ms-external-data-attributes .layer-fields-row'); + TestUtils.Simulate.change(attributeRow.querySelector('.layer-field-alias input'), { target: { value: 'Display name' } }); + expect(value.attributes[0].alias).toBe('Display name'); + + TestUtils.Simulate.change(attributeRow.querySelector('.layer-field-visibility input'), { + target: { checked: false } + }); + expect(value.attributes[0].visible).toBe(false); + }); + + it('loads feature types for an initial WFS URL', (done) => { + mockAxios.onGet().reply(({ url }) => { + expect(url).toContain('/external-data-test/wfs'); + expect(url).toContain('request=GetCapabilities'); + return [200, getCapabilitiesResponse('workspace:table', 'Table')]; + }); + ReactDOM.render( + , + document.getElementById('container') + ); + + setTimeout(() => { + try { + const select = document.querySelector('.ms-external-data-layer-select'); + expect(select.classList.contains('is-disabled')).toBe(false); + TestUtils.Simulate.mouseDown(select.querySelector('.Select-arrow'), { button: 0 }); + expect(document.body.textContent).toContain('Table (workspace:table)'); + done(); + } catch (error) { + done(error); + } + }, 50); + }); + + it('ignores a capabilities response superseded by a newer URL', (done) => { + let resolveFirstRequest; + mockAxios.onGet().reply(({ url }) => { + if (url.includes('/external-data-first/wfs')) { + return new Promise((resolve) => { + resolveFirstRequest = () => resolve([ + 200, + getCapabilitiesResponse('workspace:first', 'First') + ]); + }); + } + return [200, getCapabilitiesResponse('workspace:second', 'Second')]; + }); + const ControlledEditor = () => { + const [currentValue, setCurrentValue] = React.useState({ + url: '/external-data-first/wfs', + typeName: '', + cqlFilter: '', + attributes: [] + }); + return ; + }; + ReactDOM.render(, document.getElementById('container')); + + setTimeout(() => { + const urlInput = document.querySelector('[data-qa="external-data-url"]'); + TestUtils.Simulate.change(urlInput, { + target: { value: '/external-data-second/wfs' } + }); + TestUtils.Simulate.blur(document.querySelector('[data-qa="external-data-url"]')); + setTimeout(() => { + try { + const select = document.querySelector('.ms-external-data-layer-select'); + expect(select.classList.contains('is-disabled')).toBe(false); + TestUtils.Simulate.mouseDown(select.querySelector('.Select-arrow'), { button: 0 }); + expect(document.body.textContent).toContain('Second (workspace:second)'); + resolveFirstRequest(); + setTimeout(() => { + try { + expect(document.body.textContent).toContain('Second (workspace:second)'); + expect(document.body.textContent).toNotContain('First (workspace:first)'); + done(); + } catch (error) { + done(error); + } + }); + } catch (error) { + done(error); + } + }, 50); + }, 20); + }); + + it('loads non-geometry attributes when a feature type is selected', (done) => { + mockAxios.onGet().reply(({ url }) => { + const decodedUrl = decodeURIComponent(url); + if (decodedUrl.includes('request=GetCapabilities')) { + return [200, getCapabilitiesResponse('workspace:table', 'Table')]; + } + expect(decodedUrl).toContain('request=DescribeFeatureType'); + expect(decodedUrl).toContain('typeName=workspace:table'); + return [200, { + featureTypes: [{ + properties: [ + { name: 'name', type: 'xsd:string', localType: 'string' }, + { name: 'geom', type: 'gml:Point', localType: 'Point' } + ] + }] + }]; + }); + const ControlledEditor = () => { + const [currentValue, setCurrentValue] = React.useState({ + url: '/external-data-describe/wfs', + typeName: '', + cqlFilter: '', + attributes: [] + }); + return ; + }; + ReactDOM.render(, document.getElementById('container')); + + setTimeout(() => { + try { + const select = document.querySelector('.ms-external-data-layer-select'); + TestUtils.Simulate.mouseDown(select.querySelector('.Select-arrow'), { button: 0 }); + TestUtils.Simulate.keyDown(select.querySelector('.Select-control'), { + keyCode: 40, + key: 'ArrowDown' + }); + TestUtils.Simulate.keyDown(select.querySelector('.Select-input input'), { + keyCode: 13, + key: 'Enter' + }); + setTimeout(() => { + try { + const rows = document.querySelectorAll('.ms-external-data-attributes .layer-fields-row'); + expect(rows.length).toBe(1); + expect(rows[0].querySelector('.layer-field-name input').value).toBe('name'); + done(); + } catch (error) { + done(error); + } + }, 50); + } catch (error) { + done(error); + } + }, 50); + }); + + it('preserves alias and visibility when the same feature type is selected again', (done) => { + mockAxios.onGet().reply(({ url }) => { + const decodedUrl = decodeURIComponent(url); + if (decodedUrl.includes('request=GetCapabilities')) { + return [200, getCapabilitiesResponse('workspace:table', 'Table')]; + } + return [200, { + featureTypes: [{ + properties: [ + { name: 'name', type: 'xsd:string', localType: 'string' }, + { name: 'geom', type: 'gml:Point', localType: 'Point' } + ] + }] + }]; + }); + let value = { + url: '/external-data-reselect/wfs', + typeName: 'workspace:table', + cqlFilter: '', + attributes: [{ name: 'name', type: 'string', alias: 'Label', visible: false }] + }; + const ControlledEditor = () => { + const [currentValue, setCurrentValue] = React.useState(value); + return ( + { + value = nextValue; + setCurrentValue(nextValue); + }}/> + ); + }; + ReactDOM.render(, document.getElementById('container')); + + setTimeout(() => { + try { + const select = document.querySelector('.ms-external-data-layer-select'); + TestUtils.Simulate.mouseDown(select.querySelector('.Select-arrow'), { button: 0 }); + TestUtils.Simulate.mouseDown(document.querySelector('.Select-option'), { button: 0 }); + setTimeout(() => { + try { + expect(value.attributes).toEqual([ + { name: 'name', type: 'string', alias: 'Label', visible: false } + ]); + done(); + } catch (error) { + done(error); + } + }, 50); + } catch (error) { + done(error); + } + }, 50); + }); + + it('validates the complete source and external WFS request flow', (done) => { + mockAxios.onGet().reply(({ url }) => { + const decodedUrl = decodeURIComponent(url); + if (decodedUrl.includes('request=GetCapabilities')) { + return [200, getCapabilitiesResponse('workspace:external', 'External')]; + } + if (decodedUrl.includes('/source-validation/wfs')) { + expect(decodedUrl).toContain('typeName=workspace:source'); + expect(decodedUrl).toContain('maxFeatures=1'); + return [200, { + type: 'FeatureCollection', + features: [{ + id: 'source.1', + properties: { sourceId: "A'1" } + }] + }]; + } + expect(decodedUrl).toContain('/external-validation/wfs'); + expect(decodedUrl).toContain('typeName=workspace:external'); + expect(decodedUrl).toContain("CQL_FILTER=source_id = 'A''1'"); + return [200, { + type: 'FeatureCollection', + features: [{ + id: 'external.1', + properties: { label: 'Validated result' } + }] + }]; + }); + ReactDOM.render( + , + document.getElementById('container') + ); + + TestUtils.Simulate.click( + document.querySelector('.ms-external-data-validation > button') + ); + setTimeout(() => { + try { + const validation = document.querySelector('.ms-external-data-validation'); + expect(validation.querySelector('.alert-success')).toExist(); + // the generated filter is the whole feedback, the response is not rendered + expect(validation.querySelector('.ms-external-data-generated-cql pre').textContent) + .toBe("source_id = 'A''1'"); + expect(validation.textContent).toNotContain('Validated result'); + done(); + } catch (error) { + done(error); + } + }, 100); + }); + +}); diff --git a/web/client/components/TOC/fragments/settings/__tests__/FeatureInfo-test.jsx b/web/client/components/TOC/fragments/settings/__tests__/FeatureInfo-test.jsx index f0a50ccdada..2e79884a164 100644 --- a/web/client/components/TOC/fragments/settings/__tests__/FeatureInfo-test.jsx +++ b/web/client/components/TOC/fragments/settings/__tests__/FeatureInfo-test.jsx @@ -44,6 +44,10 @@ const formatCards = { TEMPLATE: { titleId: 'layerProperties.templateFormatTitle', glyph: 'ext-empty' + }, + EXTERNAL_DATA: { + titleId: 'layerProperties.externalData.title', + glyph: 'ext-json' } }; @@ -177,22 +181,42 @@ describe("test FeatureInfo", () => { }); it('test rendering supported infoFormats for wfs layer', () => { const component = getFeatureInfoInstance({element: {type: "wfs"}, formatCards, defaultInfoFormat}); - expect(component.getTypeOptions()).toEqual(['PROPERTIES', 'TEMPLATE']); + expect(component.getTypeOptions()).toEqual(['PROPERTIES', 'TEMPLATE', 'EXTERNAL_DATA']); }); it('test rendering supported infoFormats for wfs layer with only application/json', () => { const component = getFeatureInfoInstance({element: {type: "wfs", infoFormats: ["application/json"]}, formatCards, defaultInfoFormat}); - expect(component.getTypeOptions()).toEqual(['PROPERTIES', 'TEMPLATE']); + expect(component.getTypeOptions()).toEqual(['PROPERTIES', 'TEMPLATE', 'EXTERNAL_DATA']); }); it('test rendering supported infoFormats for wfs layer with application/json and text/html', () => { const component = getFeatureInfoInstance({element: {type: "wfs", infoFormats: ["application/json", "text/html"]}, formatCards, defaultInfoFormat}); - expect(component.getTypeOptions()).toEqual(['HTML', 'PROPERTIES', 'TEMPLATE']); + expect(component.getTypeOptions()).toEqual(['HTML', 'PROPERTIES', 'TEMPLATE', 'EXTERNAL_DATA']); }); it('test rendering supported infoFormats for wms layer', () => { const component = getFeatureInfoInstance({element: {type: "wms"}, formatCards, defaultInfoFormat}); - expect(component.getTypeOptions()).toEqual(['TEXT', 'HTML', 'PROPERTIES', 'TEMPLATE']); + expect(component.getTypeOptions()).toEqual(['TEXT', 'HTML', 'PROPERTIES', 'TEMPLATE', 'EXTERNAL_DATA']); + }); + + it('marks invalid EXTERNAL_DATA and toggles its inline editor', () => { + ReactDOM.render(, document.getElementById('container')); + + const view = document.querySelector('[data-id="feature-info-view-external"]'); + expect(view.classList.contains('has-error')).toBe(true); + const editButton = view.querySelector('.ms-feature-info-view-edit'); + TestUtils.Simulate.click(editButton); + expect(document.querySelector('.ms-external-data-editor')).toExist(); + TestUtils.Simulate.click(editButton); + expect(document.querySelector('.ms-external-data-editor')).toNotExist(); }); it('test WMS feature info request options preserve existing feature info configuration', done => { diff --git a/web/client/components/TOC/fragments/settings/__tests__/PropertiesEditor-test.jsx b/web/client/components/TOC/fragments/settings/__tests__/PropertiesEditor-test.jsx new file mode 100644 index 00000000000..268a8914263 --- /dev/null +++ b/web/client/components/TOC/fragments/settings/__tests__/PropertiesEditor-test.jsx @@ -0,0 +1,85 @@ +/* + * Copyright 2026, 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 MockAdapter from 'axios-mock-adapter'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-dom/test-utils'; + +import axios from '../../../../../libs/ajax'; +import PropertiesEditor from '../PropertiesEditor'; + +describe('PropertiesEditor', () => { + let mockAxios; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + document.body.innerHTML = '
'; + }); + + afterEach(() => { + mockAxios.restore(); + ReactDOM.unmountComponentAtNode(document.getElementById('container')); + document.body.innerHTML = ''; + }); + + it('loads non-geometry attributes and updates their visibility', (done) => { + let attributes; + mockAxios.onGet().reply(({ url }) => { + const decodedUrl = decodeURIComponent(url); + expect(decodedUrl).toContain('/properties-test/wfs'); + expect(decodedUrl).toContain('request=DescribeFeatureType'); + expect(decodedUrl).toContain('typeName=workspace:source'); + return [200, { + featureTypes: [{ + properties: [ + { name: 'name', type: 'xsd:string', localType: 'string' }, + { name: 'geom', type: 'gml:Point', localType: 'Point' } + ] + }] + }]; + }); + + ReactDOM.render( + { + attributes = nextAttributes; + }}/>, + document.getElementById('container') + ); + + setTimeout(() => { + try { + const rows = document.querySelectorAll( + '.ms-properties-view-editor .layer-fields-row' + ); + expect(rows.length).toBe(1); + expect(rows[0].querySelector('.layer-field-name input').value).toBe('name'); + + TestUtils.Simulate.change( + rows[0].querySelector('.layer-field-visibility input'), + { target: { checked: false } } + ); + expect(attributes).toEqual([{ + name: 'name', + type: 'string', + alias: '', + visible: false + }]); + done(); + } catch (error) { + done(error); + } + }, 50); + }); +}); diff --git a/web/client/components/data/identify/DefaultViewer.jsx b/web/client/components/data/identify/DefaultViewer.jsx index 6b6693a3527..8616900cc4e 100644 --- a/web/client/components/data/identify/DefaultViewer.jsx +++ b/web/client/components/data/identify/DefaultViewer.jsx @@ -15,8 +15,10 @@ import Message from '../../../components/I18N/Message'; import { Alert, Panel, Accordion } from 'react-bootstrap'; import ScrollableTabs from '../../misc/ScrollableTabs'; import ViewerPage from './viewers/ViewerPage'; +import ExternalDataViewer from './viewers/ExternalDataViewer'; import { isEmpty, reverse, startsWith } from 'lodash'; import { getFormatForResponse } from '../../../utils/IdentifyUtils'; +import { clearExternalDataCacheForIdentifyRequests } from '../../../utils/mapinfo/ExternalDataCache'; class DefaultViewer extends React.Component { static propTypes = { @@ -74,6 +76,24 @@ class DefaultViewer extends React.Component { activeViewIds: {} }; + componentDidUpdate(previousProps) { + // Drop external requests that belong to identify results no longer displayed. + const currentRequestIds = new Set( + this.props.responses.map(({ reqId } = {}) => reqId).filter(Boolean) + ); + const removedRequestIds = previousProps.responses + .map(({ reqId } = {}) => reqId) + .filter((reqId) => reqId && !currentRequestIds.has(reqId)); + clearExternalDataCacheForIdentifyRequests(removedRequestIds); + } + + componentWillUnmount() { + // The module-level cache must not outlive the identify viewer. + clearExternalDataCacheForIdentifyRequests( + this.props.responses.map(({ reqId } = {}) => reqId) + ); + } + shouldComponentUpdate(nextProps, nextState) { return nextProps.responses !== this.props.responses || nextProps.missingResponses !== this.props.missingResponses @@ -248,12 +268,19 @@ class DefaultViewer extends React.Component { }; const views = getLayerFeatureInfoViews(layerWithMetadata, { defaultType }); const activeView = this.getActiveView(views, res); + const layerMetadataForView = this.getLayerMetadataForView(layerWithMetadata, activeView); return { res, views, activeView, layerMetadata, - layerMetadataForView: this.getLayerMetadataForView(layerWithMetadata, activeView), + layerMetadataForView: { + ...layerMetadataForView, + featureInfo: { + ...layerMetadataForView?.featureInfo, + identifyRequestId: res.reqId + } + }, viewResponse: this.getResponseForView(res, activeView) }; }); @@ -270,9 +297,11 @@ class DefaultViewer extends React.Component { ...res, queryParams: viewResponse.queryParams }, this.props); - const customViewer = layerMetadataForView?.viewer?.type - ? getViewer(layerMetadataForView.viewer.type) - : undefined; + const customViewer = activeView?.type === getInfoViewModes().EXTERNAL_DATA + ? ExternalDataViewer + : layerMetadataForView?.viewer?.type + ? getViewer(layerMetadataForView.viewer.type) + : undefined; return ( { expect(dom.getElementsByClassName("alert").length).toBe(0); }); + it('renders an EXTERNAL_DATA view with ExternalDataViewer', () => { + const validator = () => ({ + getValidResponses: (responses) => responses, + getNoValidResponses: () => [] + }); + ReactDOM.render( + , + document.getElementById("container") + ); + + expect(document.querySelector('.ms-external-data-viewer')).toExist(); + expect(document.querySelector('.mapstore-json-viewer')).toNotExist(); + }); + + + it('clears external data cache entries when identify responses are removed', () => { + const key = createExternalDataCacheKey({ + identifyRequestId: 'identify-cache', + sourceFeatureId: 'feature-1', + sourceFeatureIndex: 0, + url: '/external/wfs', + typeName: 'workspace:external', + cqlFilter: "source_id = '1'" + }); + setExternalDataCacheEntry(key, Promise.resolve({}), 'identify-cache'); + + ReactDOM.render( + , + document.getElementById("container") + ); + ReactDOM.render( + , + document.getElementById("container") + ); + + expect(getExternalDataCacheEntry(key)).toNotExist(); + }); + + it('clears external data cache entries when the viewer unmounts', () => { + const key = createExternalDataCacheKey({ + identifyRequestId: 'identify-unmount', + sourceFeatureId: 'feature-1', + sourceFeatureIndex: 0, + url: '/external/wfs', + typeName: 'workspace:external', + cqlFilter: "source_id = '1'" + }); + setExternalDataCacheEntry(key, Promise.resolve({}), 'identify-unmount'); + const container = document.getElementById('container'); + + ReactDOM.render( + , + container + ); + ReactDOM.unmountComponentAtNode(container); + + expect(getExternalDataCacheEntry(key)).toNotExist(); + }); + it('renders compact shared tabs for multiple identify views', () => { ReactDOM.render( { + if (typeof response === 'string') { + try { + return JSON.parse(response); + } catch (e) { + return response; + } + } + return response || {}; +}; + +/** + * Ensures the external service returned a GeoJSON FeatureCollection. + */ +const normalizeExternalResponse = (data) => { + const response = parseResponse(data); + if (!Array.isArray(response?.features)) { + const error = new Error('The external WFS did not return a GeoJSON FeatureCollection'); + error.code = 'INVALID_EXTERNAL_RESPONSE'; + error.data = response; + throw error; + } + return response; +}; + +// the response of an external service must not be rendered to map viewers +const getErrorPresentation = (error) => { + console.error('External data view could not be loaded', error); + if (error?.code === 'MISSING_SOURCE_PROPERTY') { + return { + messageId: 'layerProperties.externalData.missingProperty', + messageParams: { property: error.propertyName } + }; + } + if (error?.code === 'UNSAFE_SOURCE_VALUE') { + return { + messageId: 'layerProperties.externalData.unsafeSourceValue', + messageParams: { property: error.propertyName } + }; + } + if (error?.code === 'INVALID_INTERPOLATED_CQL') { + return { + messageId: 'layerProperties.externalData.invalidInterpolatedCql' + }; + } + if (error?.code === 'INVALID_EXTERNAL_RESPONSE') { + return { + messageId: 'layerProperties.externalData.invalidResponse' + }; + } + return { + messageId: 'layerProperties.externalData.requestError' + }; +}; + +/** + * Queries and renders related WFS features for every identified source feature. + */ +const ExternalDataViewer = ({ response, layer }) => { + const [results, setResults] = useState([]); + const isMounted = useIsMounted(); + const loadGeneration = useRef(0); + + const { identifyRequestId, featuresService = {} } = layer?.featureInfo || {}; + const { url, typeName, cqlFilter, attributes = [] } = featuresService; + const configurationError = validateExternalDataConfiguration(featuresService); + const maxItems = getFeatureInfoMaxItems(layer?.featureInfo || {}); + + const sourceFeatures = useMemo( + () => parseResponse(response)?.features || [], + [response] + ); + + const updateResult = useCallback((index, result, generation) => { + if (generation !== loadGeneration.current) { + return; + } + isMounted(() => setResults((currentResults) => + currentResults.map((currentResult, resultIndex) => + resultIndex === index ? result : currentResult) + )); + }, []); + + const loadSourceFeature = useCallback(( + sourceFeature, + index, + { force = false, generation = loadGeneration.current } = {} + ) => { + updateResult(index, { status: 'loading' }, generation); + let interpolatedCql; + try { + interpolatedCql = interpolateExternalDataCQL(cqlFilter, sourceFeature); + } catch (error) { + updateResult(index, { + status: 'error', + ...getErrorPresentation(error) + }, generation); + return; + } + const cacheKey = createExternalDataCacheKey({ + identifyRequestId, + sourceFeatureId: sourceFeature?.id, + sourceFeatureIndex: index, + url, + typeName, + cqlFilter: interpolatedCql + }); + if (force) { + deleteExternalDataCacheEntry(cacheKey); + } + const cachedRequest = getExternalDataCacheEntry(cacheKey); + // Reuse both completed and still-running requests when the view rerenders. + const request = cachedRequest || setExternalDataCacheEntry(cacheKey, + getFeature(url, typeName, { + CQL_FILTER: interpolatedCql, + maxFeatures: maxItems, + outputFormat: 'application/json' + }).then(({ data }) => normalizeExternalResponse(data)), + identifyRequestId + ); + request + .then((data) => updateResult(index, { + status: 'success', + features: data.features + }, generation)) + .catch((error) => { + deleteExternalDataCacheEntry(cacheKey); + updateResult(index, { + status: 'error', + ...getErrorPresentation(error) + }, generation); + }); + }, [url, typeName, cqlFilter, maxItems, identifyRequestId, updateResult]); + + const load = useCallback((generation) => { + setResults(sourceFeatures.map(() => ({ status: 'loading' }))); + sourceFeatures.forEach((sourceFeature, index) => + loadSourceFeature(sourceFeature, index, { generation })); + }, [sourceFeatures, loadSourceFeature]); + + useEffect(() => { + if (configurationError) { + console.error(`External data view is not configured: ${configurationError}`); + return () => {}; + } + loadGeneration.current += 1; + load(loadGeneration.current); + return () => { + loadGeneration.current += 1; + }; + }, [load, configurationError]); + + const retry = useCallback((index) => { + const sourceFeature = sourceFeatures[index]; + if (sourceFeature) { + loadSourceFeature(sourceFeature, index, { + force: true, + generation: loadGeneration.current + }); + } + }, [sourceFeatures, loadSourceFeature]); + + const renderResult = (result, index) => { + const sourceFeatureId = sourceFeatures[index]?.id; + return ( +
+ {sourceFeatureId !== undefined && sourceFeatureId !== null + ?
{sourceFeatureId}
+ : null} + {result.status === 'loading' ? ( +
+ +
+ ) : null} + {result.status === 'error' ? ( + +
+ + +
+
+ ) : null} + {result.status === 'success' && !result.features.length ? ( + + + + ) : null} + {result.status === 'success' + ? result.features.map((feature, featureIndex) => { + const row = getVisibleFeatureRow(feature, attributes); + return ( + + ); + }) + : null} +
+ ); + }; + + if (configurationError) { + return ( + + + + ); + } + if (!sourceFeatures.length) { + return ( + + + + ); + } + return ( +
+ {results.map(renderResult)} +
+ ); +}; + +ExternalDataViewer.propTypes = { + response: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + layer: PropTypes.object +}; + +export default ExternalDataViewer; diff --git a/web/client/components/data/identify/viewers/PropertiesViewer.js b/web/client/components/data/identify/viewers/PropertiesViewer.js index a05f3f3523b..e86afcbc743 100644 --- a/web/client/components/data/identify/viewers/PropertiesViewer.js +++ b/web/client/components/data/identify/viewers/PropertiesViewer.js @@ -9,12 +9,23 @@ import React from 'react'; import RowViewer from './row/RowViewer'; +import { getVisibleFeatureRow } from '../../../../utils/IdentifyUtils'; export default ({response, layer, rowViewer}) => { + const fields = Array.isArray(layer?.featureInfo?.attributes) + ? layer.featureInfo.attributes + : layer?.fields; return (
{(response?.features || []).map((feature, i) => { - return ; + const row = getVisibleFeatureRow(feature, fields); + return ( + + ); })}
); diff --git a/web/client/components/data/identify/viewers/__tests__/ExternalDataViewer-test.jsx b/web/client/components/data/identify/viewers/__tests__/ExternalDataViewer-test.jsx new file mode 100644 index 00000000000..287189fd285 --- /dev/null +++ b/web/client/components/data/identify/viewers/__tests__/ExternalDataViewer-test.jsx @@ -0,0 +1,431 @@ +/* + * Copyright 2026, 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 React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-dom/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { IntlProvider } from 'react-intl'; + +import axios from '../../../../../libs/ajax'; +import ExternalDataViewer from '../ExternalDataViewer'; +import RowViewer from '../row/RowViewer'; +import { getVisibleFeatureRow } from '../../../../../utils/IdentifyUtils'; +import { clearExternalDataCacheForIdentifyRequests } from '../../../../../utils/mapinfo/ExternalDataCache'; + +const layer = { + featureInfo: { + id: 'external-view', + identifyRequestId: 'identify-external', + featuresService: { + url: '/external/wfs', + typeName: 'workspace:external', + cqlFilter: "source_id = '${properties.sourceId}'", + attributes: [{ name: 'label', alias: 'External label', visible: true }] + } + } +}; + +const response = { + type: 'FeatureCollection', + features: [{ + id: 'source.1', + properties: { sourceId: "O'Brien" } + }] +}; + +describe('ExternalDataViewer', () => { + let mockAxios; + + beforeEach(() => { + document.body.innerHTML = '
'; + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(document.getElementById('container')); + clearExternalDataCacheForIdentifyRequests(['identify-external']); + mockAxios.restore(); + document.body.innerHTML = ''; + }); + + it('queries the external WFS and renders configured feature attributes', (done) => { + mockAxios.onGet().reply(({ url }) => { + expect(decodeURIComponent(url)).toContain("CQL_FILTER=source_id = 'O''Brien'"); + expect(decodeURIComponent(url)).toContain('typeName=workspace:external'); + expect(decodeURIComponent(url)).toContain('maxFeatures=10'); + return [200, { + type: 'FeatureCollection', + features: [{ + id: 'external.1', + properties: { label: 'Related feature', hidden: 'Not rendered' } + }] + }]; + }); + + ReactDOM.render( + , + document.getElementById('container') + ); + + setTimeout(() => { + const container = document.getElementById('container'); + expect(container.querySelector('.ms-external-data-result-title').textContent) + .toBe('source.1'); + expect(container.textContent).toContain('external.1'); + expect(container.textContent).toContain('External label'); + expect(container.textContent).toContain('Related feature'); + expect(container.textContent).toNotContain('Not rendered'); + done(); + }); + }); + + it('queries and keeps results associated with multiple source features', (done) => { + mockAxios.onGet().reply(({ url }) => { + const decodedUrl = decodeURIComponent(url); + expect(decodedUrl).toContain('maxFeatures=10'); + const sourceId = decodedUrl.includes("source_id = 'A'") ? 'A' : 'B'; + return [200, { + type: 'FeatureCollection', + features: [{ + id: `external.${sourceId}`, + properties: { label: `Related ${sourceId}` } + }] + }]; + }); + + ReactDOM.render( + , + document.getElementById('container') + ); + + setTimeout(() => { + try { + const results = document.querySelectorAll('.ms-external-data-result'); + expect(mockAxios.history.get.length).toBe(2); + expect(results.length).toBe(2); + expect(results[0].querySelector('.ms-external-data-result-title').textContent).toBe('source.A'); + expect(results[0].textContent).toContain('Related A'); + expect(results[0].textContent).toNotContain('Related B'); + expect(results[1].querySelector('.ms-external-data-result-title').textContent).toBe('source.B'); + expect(results[1].textContent).toContain('Related B'); + expect(results[1].textContent).toNotContain('Related A'); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('ignores a stale external response after the source response changes', (done) => { + let resolveOldRequest; + mockAxios.onGet().reply(({ url }) => { + const decodedUrl = decodeURIComponent(url); + if (decodedUrl.includes("source_id = 'old'")) { + return new Promise((resolve) => { + resolveOldRequest = () => resolve([200, { + type: 'FeatureCollection', + features: [{ properties: { label: 'Old result' } }] + }]); + }); + } + return [200, { + type: 'FeatureCollection', + features: [{ properties: { label: 'Current result' } }] + }]; + }); + const container = document.getElementById('container'); + + ReactDOM.render( + , + container + ); + setTimeout(() => { + ReactDOM.render( + , + container + ); + setTimeout(() => { + expect(container.textContent).toContain('Current result'); + expect(resolveOldRequest).toBeA('function'); + resolveOldRequest(); + setTimeout(() => { + expect(container.textContent).toContain('Current result'); + expect(container.textContent).toNotContain('Old result'); + done(); + }); + }); + }); + }); + + it('renders external attribute aliases in the current locale', () => { + const row = getVisibleFeatureRow( + {properties: {label: 'Valore'}}, + [{ + name: 'label', + alias: { + 'default': 'External label', + 'it-IT': 'Etichetta esterna' + }, + visible: true + }] + ); + ReactDOM.render( + + + , + document.getElementById('container') + ); + + expect(document.querySelector('.ms-properties-viewer-key').textContent) + .toBe('Etichetta esterna'); + }); + + + it('reuses the cached request when the same view mounts again', (done) => { + let requestCount = 0; + mockAxios.onGet().reply(() => { + requestCount += 1; + return [200, { type: 'FeatureCollection', features: [] }]; + }); + const container = document.getElementById('container'); + + ReactDOM.render(, container); + setTimeout(() => { + ReactDOM.unmountComponentAtNode(container); + ReactDOM.render(, container); + setTimeout(() => { + expect(requestCount).toBe(1); + done(); + }); + }); + }); + + it('limits the related features with the layer maxItems setting', (done) => { + mockAxios.onGet().reply(({ url }) => { + expect(decodeURIComponent(url)).toContain('maxFeatures=3'); + return [200, { type: 'FeatureCollection', features: [] }]; + }); + + ReactDOM.render( + , + document.getElementById('container') + ); + + setTimeout(() => { + try { + expect(mockAxios.history.get.length).toBe(1); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('does not reload when the layer object changes but the configuration does not', (done) => { + let requestCount = 0; + mockAxios.onGet().reply(() => { + requestCount += 1; + return [200, { type: 'FeatureCollection', features: [] }]; + }); + const container = document.getElementById('container'); + + ReactDOM.render(, container); + setTimeout(() => { + expect(requestCount).toBe(1); + // clearing the cache isolates "the effect refired" from "the request was cached" + clearExternalDataCacheForIdentifyRequests(['identify-external']); + ReactDOM.render( + , + container + ); + setTimeout(() => { + try { + expect(requestCount).toBe(1); + done(); + } catch (error) { + done(error); + } + }); + }); + }); + + it('reloads when the configured filter changes', (done) => { + const filters = []; + mockAxios.onGet().reply(({ url }) => { + filters.push(decodeURIComponent(url).match(/CQL_FILTER=([^&]*)/)[1]); + return [200, { type: 'FeatureCollection', features: [] }]; + }); + const container = document.getElementById('container'); + + ReactDOM.render(, container); + setTimeout(() => { + ReactDOM.render( + , + container + ); + setTimeout(() => { + try { + expect(filters.length).toBe(2); + expect(filters[0]).toContain('source_id'); + expect(filters[1]).toContain('other_id'); + done(); + } catch (error) { + done(error); + } + }); + }); + }); + + it('does not request anything when the service configuration is incomplete', (done) => { + ReactDOM.render( + , + document.getElementById('container') + ); + + setTimeout(() => { + try { + expect(mockAxios.history.get.length).toBe(0); + expect(document.querySelector('.alert-warning')).toExist(); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('shares a cached request across views with different presentation settings', (done) => { + let requestCount = 0; + mockAxios.onGet().reply(() => { + requestCount += 1; + return [200, { + type: 'FeatureCollection', + features: [{ + id: 'external.shared', + properties: { + label: 'Label value', + code: 'Code value' + } + }] + }]; + }); + const container = document.getElementById('container'); + const firstLayer = { + featureInfo: { + ...layer.featureInfo, + id: 'first-view' + } + }; + const secondLayer = { + featureInfo: { + ...layer.featureInfo, + id: 'second-view', + featuresService: { + ...layer.featureInfo.featuresService, + attributes: [{ name: 'code', alias: 'Second view code', visible: true }] + } + } + }; + + ReactDOM.render(, container); + setTimeout(() => { + expect(container.textContent).toContain('External label'); + ReactDOM.unmountComponentAtNode(container); + ReactDOM.render(, container); + setTimeout(() => { + expect(requestCount).toBe(1); + expect(container.textContent).toContain('Second view code'); + expect(container.textContent).toContain('Code value'); + expect(container.textContent).toNotContain('External label'); + done(); + }); + }); + }); + + it('reports a missing source property without making a request', (done) => { + ReactDOM.render( + , + document.getElementById('container') + ); + + setTimeout(() => { + expect(mockAxios.history.get.length).toBe(0); + const alert = document.querySelector('.alert-danger'); + expect(alert).toExist(); + expect(alert.textContent).toContain('layerProperties.externalData.missingProperty'); + expect(alert.querySelector('details')).toNotExist(); + done(); + }); + }); + + + it('retries a failed request and renders the successful response', (done) => { + let requestCount = 0; + mockAxios.onGet().reply(() => { + requestCount += 1; + return requestCount === 1 + ? [500, { message: 'Temporary failure' }] + : [200, { + type: 'FeatureCollection', + features: [{ id: 'external.retry', properties: { label: 'Recovered' } }] + }]; + }); + + ReactDOM.render( + , + document.getElementById('container') + ); + + setTimeout(() => { + expect(document.querySelector('.alert-danger')).toExist(); + TestUtils.Simulate.click(document.querySelector('.alert-danger button')); + setTimeout(() => { + expect(requestCount).toBe(2); + expect(document.getElementById('container').textContent).toContain('Recovered'); + done(); + }); + }); + }); +}); diff --git a/web/client/epics/identify.js b/web/client/epics/identify.js index 186688dd469..926c8190054 100644 --- a/web/client/epics/identify.js +++ b/web/client/epics/identify.js @@ -65,6 +65,8 @@ const gridGeometryQuickFilter = state => get(find(getAttributeFilters(state), f const stopFeatureInfo = state => stopGetFeatureInfoSelector(state) || isFeatureGridOpen(state) && (gridEditingSelector(state) || gridGeometryQuickFilter(state)); import {getFeatureInfoForViews} from '../api/identify'; +import { LOGOUT } from '../actions/security'; +import { clearExternalDataCache } from '../utils/mapinfo/ExternalDataCache'; import { VISUALIZATION_MODE_CHANGED } from '../actions/maptype'; import {updatePointWithGeometricFilter} from "../utils/IdentifyUtils"; import { getDerivedLayersVisibility } from '../utils/LayersUtils'; @@ -481,6 +483,15 @@ export const handleGetFeatureInfoForTimeParamsChange = (action$, {getState}) => ...lastAction })); +/** + * Drops the external data responses cached outside the store when the session ends. + */ +export const clearExternalDataCacheOnLogout = (action$) => + action$ + .ofType(LOGOUT) + .do(() => clearExternalDataCache()) + .ignoreElements(); + export default { getFeatureInfoOnFeatureInfoClick, handleMapInfoMarker, @@ -504,5 +515,6 @@ export default { removePopupOnLocationChangeEpic, removeMapInfoMarkerOnRemoveMapPopupEpic, setMapTriggerEpic, - handleGetFeatureInfoForTimeParamsChange + handleGetFeatureInfoForTimeParamsChange, + clearExternalDataCacheOnLogout }; diff --git a/web/client/plugins/tocitemssettings/tabs/FeatureInfo/index.jsx b/web/client/plugins/tocitemssettings/tabs/FeatureInfo/index.jsx index 9a2acfc6ab0..8d67916501b 100644 --- a/web/client/plugins/tocitemssettings/tabs/FeatureInfo/index.jsx +++ b/web/client/plugins/tocitemssettings/tabs/FeatureInfo/index.jsx @@ -21,6 +21,12 @@ const formatCards = { TEMPLATE: { titleId: titleIds.TEMPLATE, glyph: 'ext-empty' + }, + // Metadata shown for the External Data option in the view-type selector. + EXTERNAL_DATA: { + titleId: 'layerProperties.externalData.title', + descId: 'layerProperties.externalData.description', + glyph: 'ext-json' } }; const FeatureInfo = defaultProps({ diff --git a/web/client/themes/default/less/external-data-editor.less b/web/client/themes/default/less/external-data-editor.less new file mode 100644 index 00000000000..3ce79d2bbcd --- /dev/null +++ b/web/client/themes/default/less/external-data-editor.less @@ -0,0 +1,76 @@ +// Attribute configuration shared by Feature Info view editors. +.ms-external-data-editor, +.ms-properties-view-editor { + border: 1px solid @ms2-color-shade-lighter; + padding: 12px; +} + +// External Data configuration shown below a Feature Info view. +.ms-external-data-editor { + .ms-external-data-url-row { + display: flex; + gap: 8px; + + .form-control { + flex: 1; + } + } + + .ms-external-data-validation { + align-items: flex-start; + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; + + .alert { + margin: 0; + width: 100%; + } + } + + .ms-external-data-generated-cql { + width: 100%; + + pre { + margin: 4px 0 0; + white-space: pre-wrap; + word-break: break-word; + } + } +} + +// Related WFS results shown in the Identify viewer. +.ms-external-data-viewer { + .ms-external-data-result { + border-bottom: 1px solid @ms2-color-shade-lighter; + padding: 0 0 12px; + + .ms-external-data-result-title { + padding: 0 12px; + font-weight: bold; + } + + + .ms-external-data-result { + padding-top: 12px; + } + } + + .ms-external-data-runtime-loading { + align-items: center; + display: flex; + gap: 8px; + min-height: 48px; + } + + .ms-external-data-runtime-error-header { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + } + + .alert { + margin-bottom: 0; + } +} diff --git a/web/client/themes/default/less/mapstore.less b/web/client/themes/default/less/mapstore.less index 324f8edfb3f..a46c62ee050 100644 --- a/web/client/themes/default/less/mapstore.less +++ b/web/client/themes/default/less/mapstore.less @@ -59,6 +59,7 @@ @import "resources-catalog/index.less"; @import "rulesmanager.less"; @import "scrollable-tabs.less"; +@import "external-data-editor.less"; @import "searchbar.less"; @import "select.less"; @import "services-config-editor.less"; diff --git a/web/client/themes/default/less/toc-settings.less b/web/client/themes/default/less/toc-settings.less index 4b6d208f10a..9e391bdb2f0 100644 --- a/web/client/themes/default/less/toc-settings.less +++ b/web/client/themes/default/less/toc-settings.less @@ -20,6 +20,9 @@ .legend-preview { .background-color-var(@theme-vars[main-bg]); } + .ms-feature-info-view.has-error { + .border-color-var(@theme-vars[danger]); + } } // ************** @@ -166,6 +169,19 @@ .layer-fields-toolbar { text-align: center; margin: 15px; + + .layer-fields-title { + font-weight: bold; + } + + &:has(.layer-fields-title) { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + margin: 5px; + text-align: left; + } } .layer-fields-row-header { height: 25px; @@ -181,6 +197,17 @@ .layer-field-alias { flex: 1; } + .layer-field-visibility { + align-items: center; + display: flex; + justify-content: center; + margin-bottom: 0; + width: 28px; + + .checkbox { + margin: 0; + } + } .layer-field-name { width: 150px; } @@ -226,6 +253,9 @@ .ms-feature-info-views { padding: 0 15px; + display: flex; + flex-direction: column; + gap: 8px; } .ms-feature-info-views-empty { @@ -238,7 +268,6 @@ border: 1px solid var(--ms-main-border-color, @ms-main-border-color); display: flex; gap: 8px; - margin-bottom: 8px; padding: 6px 10px; &.disabled { diff --git a/web/client/translations/data.ca-ES.json b/web/client/translations/data.ca-ES.json index f71cd8734b3..f069ba2c594 100644 --- a/web/client/translations/data.ca-ES.json +++ b/web/client/translations/data.ca-ES.json @@ -117,7 +117,6 @@ "templateFormatInfoAlert2": "Utilitzeu $ { attribute } per embolicar les propietats que heu de mostrar", "templateFormatInfoAlertExample": "L\u2019identificador de la funci\u00f3 \u00e9s $ { properties.id } o $ { properties['data-value'] } ", "templateError": "Hi va haver un error en aplicar la plantilla a la informaci\u00f3 retornada pel servidor,comproveu la plantilla", - "templatePreview": "Vista pr\u00e8via de la plantilla", "heightOffset": "Despla\u00e7ament d'al\u00e7ada (M)", "wmsLayerTileSize": "Mida de rajoles (WMS)", "serverType": "Tipus de servidor", diff --git a/web/client/translations/data.da-DK.json b/web/client/translations/data.da-DK.json index 4922a5483da..f9abaef4a9e 100644 --- a/web/client/translations/data.da-DK.json +++ b/web/client/translations/data.da-DK.json @@ -113,7 +113,6 @@ "templateFormatInfoAlert2": "Brug ${ attribut } til at omslutte de egenskaber, du vil vise", "templateFormatInfoAlertExample": "Objektets id er ${ properties.id } eller ${ properties['data-value'] }", "templateError": "Der opstod en fejl ved brug af skabelonen på den information, der blev returneret af serveren. Kontroller venligst skabelonen", - "templatePreview": "Skabelon forhåndsvisning", "heightOffset": "Højdeoffset (m)", "wmsLayerTileSize": "Tile størrelse (WMS)", "maxFeaturesInView": "Maksimale antal features i udsnit", diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index b5deadcb084..37ee4658dba 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -138,6 +138,43 @@ "styleWarning": "Ebenenstil-Editor für Geometrietyp '{geometryType}' wird nicht unterstützt.", "templateFormatInfoAlert2": "Verwenden Sie ${ attribute }, um Attribut-Werte anzuzeigen", "templateError": "Beim Anwenden der Vorlage auf die vom Server zurückgegebenen Informationen ist ein Fehler aufgetreten. Bitte überprüfen Sie die Vorlage", + "propertiesView": { + "attributes": "Attribute", + "noAttributes": "Das Layerschema enthält keine darstellbaren Attribute." + }, + "externalData": { + "title": "EXTERNE DATEN", + "description": "Attribute aus einer externen WFS-Quelle abfragen", + "wfsUrl": "WFS-URL", + "invalidWfsUrl": "Die URL stellt keinen gültigen WFS-Dienst bereit", + "layerName": "Layername", + "cqlFilter": "CQL-Filtervorlage", + "cqlFilterHelp": "Setzen Sie Platzhalter für Zeichenfolgen in einfache Anführungszeichen; lassen Sie Platzhalter für Zahlen und boolesche Werte ohne Anführungszeichen.", + "attributes": "Attribute", + "validate": "Validieren", + "validation": { + "missingFields": "WFS-URL, Layername und CQL-Filtervorlage sind erforderlich", + "invalidPlaceholder": "Die CQL-Filtervorlage muss mindestens einen gültigen Feldverweis enthalten", + "invalidDoubleQuotedPlaceholder": "Doppelte Anführungszeichen kennzeichnen Feldnamen in CQL. Verwenden Sie einfache Anführungszeichen für Zeichenfolgen-Platzhalter.", + "invalidCql": "Die CQL-Filtervorlage ist ungültig", + "unquotedPlaceholder": "Im Filter ist der Verweis auf das Feld „{property}“ nicht in einfache Anführungszeichen gesetzt und akzeptiert daher nur Zahlen. Fügen Sie die Anführungszeichen hinzu, um Textwerte zu verwenden.", + "valid": "Die Konfiguration der externen Daten ist gültig", + "sourceUnavailable": "Zum Ausführen der Beispielanfrage sind eine WFS-Quell-URL und ein Layername erforderlich", + "sampleNotFound": "Der Quelllayer hat kein Beispielfeature zurückgegeben", + "testRequestFailed": "Die Testanfrage für externe Daten ist fehlgeschlagen", + "generatedCql": "Generiertes CQL" + }, + "noSourceFeatures": "Für diese externe Datenansicht sind keine identifizierten Features verfügbar", + "invalidConfiguration": "Ungültige Konfiguration", + "notConfigured": "Diese Identify-Ansicht ist nicht korrekt konfiguriert.", + "noResults": "Keine externen Daten gefunden", + "requestError": "Externe Daten konnten nicht geladen werden", + "missingProperty": "Das Quellfeature enthält die Eigenschaft „{property}“ nicht", + "unsafeSourceValue": "Die externen Daten können nicht geladen werden, da „{property}“ Text enthält, wo eine Zahl erwartet wird.", + "invalidInterpolatedCql": "Der aus dem Quellfeature erstellte CQL-Filter ist nicht gültig", + "invalidResponse": "Der externe WFS hat kein gültiges GeoJSON zurückgegeben", + "retry": "Erneut versuchen" + }, "heightOffset": "Höhenversatz (m)", "wmsLayerTileSize": "Kachelgröße (WMS)", "maxFeaturesInView": "Maximale Anzahl von Features in der Ansicht", @@ -264,6 +301,7 @@ "forceProxy": "Proxy erzwingen", "fields": { "refresh": "Felder aus der Datenquelle neu laden", + "showAll": "Alle Attribute ein- oder ausblenden", "title": "Felder", "tooltip": "Felder", "name": "Name", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 557d45d1d98..6b9fe617bb9 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -138,6 +138,43 @@ "styleWarning": "Layer style editor for geometry type '{geometryType}' is not supported.", "templateFormatInfoAlert2": "Use ${ attribute } to wrap the properties you need to display", "templateError": "There was an error applying the template to the information returned by the server, please check the template", + "propertiesView": { + "attributes": "Attributes", + "noAttributes": "The layer schema contains no displayable attributes." + }, + "externalData": { + "title": "EXTERNAL DATA", + "description": "Query attributes from an external WFS source", + "wfsUrl": "WFS URL", + "invalidWfsUrl": "The URL does not expose a valid WFS service", + "layerName": "Layer name", + "cqlFilter": "CQL filter template", + "cqlFilterHelp": "Use single quotes around string placeholders; leave numeric and boolean placeholders unquoted.", + "attributes": "Attributes", + "validate": "Validate", + "validation": { + "missingFields": "WFS URL, layer name and CQL filter template are required", + "invalidPlaceholder": "The CQL filter template must contain at least one valid field reference", + "invalidDoubleQuotedPlaceholder": "Double quotes identify field names in CQL. Use single quotes around string placeholders.", + "invalidCql": "The CQL filter template is not valid", + "unquotedPlaceholder": "In the filter, the reference to “{property}” is not wrapped in single quotes, so it only accepts numbers. Add the quotes to use text values.", + "valid": "The external data configuration is valid", + "sourceUnavailable": "A WFS source URL and layer name are required to run the sample request", + "sampleNotFound": "The source layer did not return a sample feature", + "testRequestFailed": "The external data test request failed", + "generatedCql": "Generated CQL" + }, + "noSourceFeatures": "No identified features are available for this external data view", + "invalidConfiguration": "Invalid configuration", + "notConfigured": "This Identify view is not configured correctly.", + "noResults": "No external data found", + "requestError": "Unable to load external data", + "missingProperty": "The source feature does not contain the property “{property}”", + "unsafeSourceValue": "The external data cannot be loaded because “{property}” contains text where a number is expected.", + "invalidInterpolatedCql": "The CQL filter built from the source feature is not valid", + "invalidResponse": "The external WFS did not return valid GeoJSON", + "retry": "Retry" + }, "heightOffset": "Height offset (m)", "wmsLayerTileSize": "Tile size (WMS)", "maxFeaturesInView": "Max features in view", @@ -264,6 +301,7 @@ "forceProxy": "Force proxy", "fields": { "refresh": "Reload fields from the data source", + "showAll": "Show or hide all attributes", "title": "Fields", "tooltip": "Fields", "name": "Name", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index ea7472698f5..e8a6bacb98e 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -137,6 +137,43 @@ "templateFormatTitle": "PLANTILLA", "styleWarning": "El editor de estilo de capa para el tipo de geometría '{geometryType}' no es compatible.", "templateFormatInfoAlert2": "Use ${ attribute } para ajustar las propiedades que necesita visualizar", + "propertiesView": { + "attributes": "Atributos", + "noAttributes": "El esquema de la capa no contiene atributos que se puedan mostrar." + }, + "externalData": { + "title": "DATOS EXTERNOS", + "description": "Consultar atributos de una fuente WFS externa", + "wfsUrl": "URL WFS", + "invalidWfsUrl": "La URL no expone un servicio WFS válido", + "layerName": "Nombre de capa", + "cqlFilter": "Plantilla de filtro CQL", + "cqlFilterHelp": "Use comillas simples alrededor de los marcadores de texto; deje sin comillas los marcadores numéricos y booleanos.", + "attributes": "Atributos", + "validate": "Validar", + "validation": { + "missingFields": "La URL WFS, el nombre de capa y la plantilla de filtro CQL son obligatorios", + "invalidPlaceholder": "La plantilla de filtro CQL debe contener al menos una referencia de campo válida", + "invalidDoubleQuotedPlaceholder": "Las comillas dobles identifican nombres de campos en CQL. Use comillas simples alrededor de los marcadores de texto.", + "invalidCql": "La plantilla de filtro CQL no es válida", + "unquotedPlaceholder": "En el filtro, la referencia al campo «{property}» no está entre comillas simples, por lo que solo acepta números. Añada las comillas para usar valores de texto.", + "valid": "La configuración de datos externos es válida", + "sourceUnavailable": "Se necesitan una URL WFS de origen y un nombre de capa para ejecutar la solicitud de ejemplo", + "sampleNotFound": "La capa de origen no devolvió una entidad de ejemplo", + "testRequestFailed": "La solicitud de prueba de datos externos falló", + "generatedCql": "CQL generado" + }, + "noSourceFeatures": "No hay entidades identificadas disponibles para esta vista de datos externos", + "invalidConfiguration": "Configuración no válida", + "notConfigured": "Esta vista de Identify no está configurada correctamente.", + "noResults": "No se encontraron datos externos", + "requestError": "No se pudieron cargar los datos externos", + "missingProperty": "La entidad de origen no contiene la propiedad «{property}»", + "unsafeSourceValue": "No se pueden cargar los datos externos porque «{property}» contiene texto donde se espera un número.", + "invalidInterpolatedCql": "El filtro CQL construido a partir de la entidad de origen no es válido", + "invalidResponse": "El WFS externo no devolvió un GeoJSON válido", + "retry": "Reintentar" + }, "heightOffset": "Desplazamiento de altura (m)", "wmsLayerTileSize": "Tamaño del mosaico (WMS)", "maxFeaturesInView": "Número máximo de entidades en la vista", @@ -264,6 +301,7 @@ "forceProxy": "Forzar proxy", "fields": { "refresh": "Obtener campos", + "showAll": "Mostrar u ocultar todos los atributos", "title": "Campos", "tooltip": "Campos", "name": "Nombre", diff --git a/web/client/translations/data.fi-FI.json b/web/client/translations/data.fi-FI.json index 5c5011228fc..6136284a972 100644 --- a/web/client/translations/data.fi-FI.json +++ b/web/client/translations/data.fi-FI.json @@ -93,7 +93,6 @@ "templateFormatInfoAlert1": "Napsauta Muokkaa-painiketta lisätäksesi uuden mallin.", "templateFormatInfoAlert2": "Käytä ${ attribute } kääriäksesi näytettävät ominaisuudet", "templateFormatInfoAlertExample": "Kohteen ID on ${ properties }", - "templatePreview": "Mallin esikatselu", "heightOffset": "Height offset (m)", "tooltip": { "label": "Työkaluvinkkejä", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 964c8df4117..1e71fa1b4ae 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -138,6 +138,43 @@ "styleWarning": "L'éditeur de style de calque pour le type de géométrie '{geometryType}' n'est pas pris en charge.", "templateFormatInfoAlert2": "Utilisez ${ attribut } pour envelopper les propriétés que vous devez afficher", "templateError": "Une erreur s'est produite lors de l'application du modèle aux informations renvoyées par le serveur, veuillez vérifier le modèle", + "propertiesView": { + "attributes": "Attributs", + "noAttributes": "Le schéma de la couche ne contient aucun attribut affichable." + }, + "externalData": { + "title": "DONNÉES EXTERNES", + "description": "Interroger les attributs d'une source WFS externe", + "wfsUrl": "URL WFS", + "invalidWfsUrl": "L'URL n'expose pas un service WFS valide", + "layerName": "Nom de la couche", + "cqlFilter": "Modèle de filtre CQL", + "cqlFilterHelp": "Utilisez des apostrophes autour des espaces réservés de texte ; laissez les espaces réservés numériques et booléens sans guillemets.", + "attributes": "Attributs", + "validate": "Valider", + "validation": { + "missingFields": "L'URL WFS, le nom de la couche et le modèle de filtre CQL sont obligatoires", + "invalidPlaceholder": "Le modèle de filtre CQL doit contenir au moins une référence de champ valide", + "invalidDoubleQuotedPlaceholder": "Les guillemets doubles identifient les noms de champs en CQL. Utilisez des apostrophes autour des espaces réservés de texte.", + "invalidCql": "Le modèle de filtre CQL n'est pas valide", + "unquotedPlaceholder": "Dans le filtre, la référence au champ « {property} » n’est pas entourée de guillemets simples, elle n’accepte donc que des nombres. Ajoutez les guillemets pour utiliser des valeurs textuelles.", + "valid": "La configuration des données externes est valide", + "sourceUnavailable": "Une URL WFS source et un nom de couche sont nécessaires pour exécuter la requête d'exemple", + "sampleNotFound": "La couche source n'a renvoyé aucune entité d'exemple", + "testRequestFailed": "La requête de test des données externes a échoué", + "generatedCql": "CQL généré" + }, + "noSourceFeatures": "Aucune entité identifiée n'est disponible pour cette vue de données externes", + "invalidConfiguration": "Configuration non valide", + "notConfigured": "Cette vue Identify n’est pas configurée correctement.", + "noResults": "Aucune donnée externe trouvée", + "requestError": "Impossible de charger les données externes", + "missingProperty": "L'entité source ne contient pas la propriété « {property} »", + "unsafeSourceValue": "Les données externes ne peuvent pas être chargées car « {property} » contient du texte alors qu’un nombre est attendu.", + "invalidInterpolatedCql": "Le filtre CQL construit à partir de l’entité source n’est pas valide", + "invalidResponse": "Le WFS externe n'a pas renvoyé de GeoJSON valide", + "retry": "Réessayer" + }, "heightOffset": "Décalage en hauteur (m)", "wmsLayerTileSize": "Taille de la tuile (WMS)", "maxFeaturesInView": "Nombre maximal d'entités dans la vue", @@ -264,6 +301,7 @@ "forceProxy": "Forcer le proxy", "fields": { "refresh": "Récupérer les formats pris en charge", + "showAll": "Afficher ou masquer tous les attributs", "title": "Champs", "tooltip": "Champs", "name": "Nom", diff --git a/web/client/translations/data.hr-HR.json b/web/client/translations/data.hr-HR.json index 76a00a34650..a17b3aefad6 100644 --- a/web/client/translations/data.hr-HR.json +++ b/web/client/translations/data.hr-HR.json @@ -92,7 +92,6 @@ "templateFormatInfoAlert1": "Klikni na dugme za editiranje za dodavanje novog predloška.", "templateFormatInfoAlert2": "Koristi ${ attribute } za označavanje atributa za prikaz", "templateFormatInfoAlertExample": "Id objekta je ${ properties }", - "templatePreview": "Pregled predloška", "heightOffset": "Height offset (m)", "tooltip": { "label": "Info oblačić", diff --git a/web/client/translations/data.is-IS.json b/web/client/translations/data.is-IS.json index 612acee89ea..83e3fbd3198 100644 --- a/web/client/translations/data.is-IS.json +++ b/web/client/translations/data.is-IS.json @@ -113,7 +113,6 @@ "templateFormatInfoAlert2": "Use ${ attribute } to wrap the properties you need to display", "templateFormatInfoAlertExample": "The id of the feature is ${ properties.id } or ${ properties['data-value'] }", "templateError": "There was an error applying the template to the information returned by the server, please check the template", - "templatePreview": "Template preview", "heightOffset": "Height offset (m)", "wmsLayerTileSize": "Tile size (WMS)", "serverType": "Server Type", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 806ad6f4f01..89745bc6ec1 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -138,6 +138,43 @@ "styleWarning": "L'editore dello stile di livello per il tipo di geometria '{geometryType}' non è supportato.", "templateFormatInfoAlert2": "Usa ${ attribute } per aggiungere gli attributi da visualizzare", "templateError": "Si è verificato un errore durante l'applicazione del template alle informazioni restituite dal server, controllare il template.", + "propertiesView": { + "attributes": "Attributi", + "noAttributes": "Lo schema del layer non contiene attributi visualizzabili." + }, + "externalData": { + "title": "DATI ESTERNI", + "description": "Interroga gli attributi da una sorgente WFS esterna", + "wfsUrl": "URL WFS", + "invalidWfsUrl": "L'URL non espone un servizio WFS valido", + "layerName": "Nome layer", + "cqlFilter": "Template filtro CQL", + "cqlFilterHelp": "Usa gli apici singoli per i segnaposto di testo; lascia senza apici i segnaposto numerici e booleani.", + "attributes": "Attributi", + "validate": "Valida", + "validation": { + "missingFields": "URL WFS, nome layer e template del filtro CQL sono obbligatori", + "invalidPlaceholder": "Il template del filtro CQL deve contenere almeno un riferimento valido ad un campo", + "invalidDoubleQuotedPlaceholder": "Le virgolette doppie identificano i nomi dei campi in CQL. Usa gli apici singoli per i segnaposto di testo.", + "invalidCql": "Il template del filtro CQL non è valido", + "unquotedPlaceholder": "Nel filtro, il riferimento al campo “{property}” non è racchiuso tra virgolette singole, quindi accetta solo numeri. Aggiungi le virgolette per usare valori di testo.", + "valid": "La configurazione dei dati esterni è valida", + "sourceUnavailable": "Per eseguire la richiesta di esempio sono necessari un URL WFS sorgente e un nome layer", + "sampleNotFound": "Il layer sorgente non ha restituito una feature di esempio", + "testRequestFailed": "La richiesta di test dei dati esterni non è riuscita", + "generatedCql": "CQL generato" + }, + "noSourceFeatures": "Nessuna feature identificata è disponibile per questa vista dati esterni", + "invalidConfiguration": "Configurazione non valida", + "notConfigured": "Questa vista di Identify non è configurata correttamente.", + "noResults": "Nessun dato esterno trovato", + "requestError": "Impossibile caricare i dati esterni", + "missingProperty": "La feature sorgente non contiene la proprietà “{property}”", + "unsafeSourceValue": "Impossibile caricare i dati esterni perché “{property}” contiene testo dove è previsto un numero.", + "invalidInterpolatedCql": "Il filtro CQL costruito dalla feature sorgente non è valido", + "invalidResponse": "Il WFS esterno non ha restituito un GeoJSON valido", + "retry": "Riprova" + }, "heightOffset": "Spostamento in altezza (m)", "wmsLayerTileSize": "Dimensione tile (WMS)", "maxFeaturesInView": "Numero massimo di feature in vista", @@ -264,6 +301,7 @@ "forceProxy": "Forza proxy", "fields": { "refresh": "Recupera i campi dalla sorgente dati", + "showAll": "Mostra o nascondi tutti gli attributi", "title": "Campi", "tooltip": "Campi", "name": "Nome", diff --git a/web/client/translations/data.nl-NL.json b/web/client/translations/data.nl-NL.json index 1427c3e085e..4f957cd730d 100644 --- a/web/client/translations/data.nl-NL.json +++ b/web/client/translations/data.nl-NL.json @@ -132,7 +132,6 @@ "templateFormatInfoAlert2": "Gebruik ${ attribute } om de eigenschappen die je wenst te tonen aan te duiden.", "templateFormatInfoAlertExample": "Het id van het object is ${ properties.id } of ${ properties['data-value'] }", "templateError": "De server geeft een foutmelding bij het toepassen van het sjabloon; gelieve het sjabloon te controleren", - "templatePreview": "Sjabloon voorbeeld", "heightOffset": "Hoogteverschuiving (m)", "wmsLayerTileSize": "Tegelgrootte (WMS)", "serverType": "Type server", diff --git a/web/client/translations/data.pt-BR.json b/web/client/translations/data.pt-BR.json index f6a34e4e90d..784aa779242 100644 --- a/web/client/translations/data.pt-BR.json +++ b/web/client/translations/data.pt-BR.json @@ -127,7 +127,6 @@ "templateFormatInfoAlert2": "Use ${ attribute } para envolver as propriedades que você precisa exibir", "templateFormatInfoAlertExample": "O id da feição é ${ properties.id } ou ${ properties['data-value'] }", "templateError": "Houve um erro ao aplicar o modelo às informações retornadas pelo servidor, por favor verifique o modelo", - "templatePreview": "Pré-visualização do modelo", "heightOffset": "Deslocamento de altura (m)", "wmsLayerTileSize": "Tamanho do tile (WMS)", "serverType": "Tipo de Servidor", diff --git a/web/client/translations/data.pt-PT.json b/web/client/translations/data.pt-PT.json index dfa87613cb6..e01401ea56b 100644 --- a/web/client/translations/data.pt-PT.json +++ b/web/client/translations/data.pt-PT.json @@ -112,7 +112,6 @@ "templateFormatInfoAlert1": "Clicar no botão editar para adicionar um novo template.", "templateFormatInfoAlert2": "Use ${ attribute } para identificar as propriedades que necessita visualizar", "templateFormatInfoAlertExample": "O id do tema é ${ properties }", - "templatePreview": "Preview do Template", "heightOffset": "Desvio de altura (m)", "tooltip": { "label": "Dica", diff --git a/web/client/translations/data.sk-SK.json b/web/client/translations/data.sk-SK.json index e7a14f2d391..21d935a017c 100644 --- a/web/client/translations/data.sk-SK.json +++ b/web/client/translations/data.sk-SK.json @@ -104,7 +104,6 @@ "templateFormatInfoAlert1": "Kliknutím na tlačidlo Upraviť pridáš novú šablónu.", "templateFormatInfoAlert2": "Použi ${ attribute } pre zoskupenie vlastností, ktoré potrebuješ na zobrazenie", "templateFormatInfoAlertExample": "ID tejto funkcie je ${ properties }", - "templatePreview": "Náhľad šablóny", "heightOffset": "Height offset (m)", "visibilityLimits": { "title": "Limity viditeľnosti", diff --git a/web/client/translations/data.sv-SE.json b/web/client/translations/data.sv-SE.json index 2f920a958c9..38434ad4ff6 100644 --- a/web/client/translations/data.sv-SE.json +++ b/web/client/translations/data.sv-SE.json @@ -127,7 +127,6 @@ "templateFormatInfoAlert2": "Använd $ {attribute} för att omsluta de egenskaper du behöver visa", "templateFormatInfoAlertExample": "Objektets id är $ {properties.id} eller $ {properties [ 'data-value']} ", "templateError": "Det uppstod ett fel när mallen skulle tillämpas på den information som servern returnerade. Kontrollera mallen", - "templatePreview": "Förhandsvisning av mall", "heightOffset": "Höjdoffset (m)", "wmsLayerTileSize": "Rutstorlek (WMS)", "serverType": "Servertyp", diff --git a/web/client/translations/data.vi-VN.json b/web/client/translations/data.vi-VN.json index 3396120b062..5714ab1c2de 100644 --- a/web/client/translations/data.vi-VN.json +++ b/web/client/translations/data.vi-VN.json @@ -724,7 +724,6 @@ "templateFormatInfoAlert2": "Sử dụng ${ attribute } để bọc các thuộc tính bạn cần hiển thị", "templateFormatInfoAlertExample": "Id của tính năng là ${ properties }", "templateFormatTitle": "Bản mẫu", - "templatePreview": "Xem trước mẫu", "textFormatDescription": "Hiển thị kết quả thông tin tính năng dưới dạng văn bản thuần túy", "textFormatTitle": "VĂN BẢN", "title": "Tiêu đề", diff --git a/web/client/translations/data.zh-ZH.json b/web/client/translations/data.zh-ZH.json index d9642dfb937..7aafe740e1b 100644 --- a/web/client/translations/data.zh-ZH.json +++ b/web/client/translations/data.zh-ZH.json @@ -94,7 +94,6 @@ "templateFormatInfoAlert1": "Click on edit button to add a new template.", "templateFormatInfoAlert2": "Use ${ attribute } to wrap the properties you need to display", "templateFormatInfoAlertExample": "The id of the feature is ${ properties }", - "templatePreview": "Template preview", "heightOffset": "Height offset (m)", "tooltip": { "label": "Tooltip", diff --git a/web/client/utils/IdentifyUtils.js b/web/client/utils/IdentifyUtils.js index 632d5ad6184..3a20ec951c6 100644 --- a/web/client/utils/IdentifyUtils.js +++ b/web/client/utils/IdentifyUtils.js @@ -23,6 +23,31 @@ export const getFormatForResponse = (res, props) => { export const responseValidForEdit = (res) => !!get(res, 'layer.search.url'); +/** + * Prepares a feature and its field metadata for Identify row rendering. + * When visibility is configured, the returned feature is a copy containing + * only visible properties + */ +export const getVisibleFeatureRow = (feature = {}, fields = []) => { + const hasVisibilityConfiguration = fields.some((field) => + Object.prototype.hasOwnProperty.call(field, 'visible')); + if (!hasVisibilityConfiguration) { + return { feature, fields }; + } + const visibleFields = fields.filter(({ visible = true }) => visible); + const visibleNames = new Set(visibleFields.map(({ name }) => name)); + return { + feature: { + ...feature, + properties: Object.fromEntries( + Object.entries(feature.properties || {}) + .filter(([name]) => visibleNames.has(name)) + ) + }, + fields: visibleFields + }; +}; + /** * Recalculates pixel and geometric filter to allow also GFI emulation for WFS. diff --git a/web/client/utils/MapInfoUtils.js b/web/client/utils/MapInfoUtils.js index bf666b4d009..0cdc1897587 100644 --- a/web/client/utils/MapInfoUtils.js +++ b/web/client/utils/MapInfoUtils.js @@ -24,6 +24,7 @@ import model from './mapinfo/model'; import arcgis from './mapinfo/arcgis'; import cog from './mapinfo/cog'; import flatgeobuf from './mapinfo/flatgeobuf'; +import { EXTERNAL_DATA } from './mapinfo/ExternalDataUtils'; // TODO import only index in ./mapinfo let MapInfoUtils; @@ -37,14 +38,16 @@ const INFO_VIEW_MODES = { TEXT: "TEXT", PROPERTIES: "PROPERTIES", HTML: "HTML", - TEMPLATE: "TEMPLATE" + TEMPLATE: "TEMPLATE", + EXTERNAL_DATA }; const INFO_VIEW_MODE_TITLE_IDS = { [INFO_VIEW_MODES.TEXT]: 'layerProperties.textFormatTitle', [INFO_VIEW_MODES.PROPERTIES]: 'layerProperties.propertiesFormatTitle', [INFO_VIEW_MODES.HTML]: 'layerProperties.htmlFormatTitle', - [INFO_VIEW_MODES.TEMPLATE]: 'layerProperties.templateFormatTitle' + [INFO_VIEW_MODES.TEMPLATE]: 'layerProperties.templateFormatTitle', + [INFO_VIEW_MODES.EXTERNAL_DATA]: 'layerProperties.externalData.title' }; /** @@ -105,6 +108,7 @@ export const getInfoFormatByInfoView = (infoView, layerInfoFormatCfg) => { break; case INFO_VIEW_MODES.PROPERTIES: case INFO_VIEW_MODES.TEMPLATE: + case INFO_VIEW_MODES.EXTERNAL_DATA: infoFormat = layerInfoFormatCfg?.includes(GEOJSON_MIME_TYPE) ? INFO_FORMATS.GEOJSON : INFO_FORMATS.JSON; break; default: @@ -204,11 +208,12 @@ export const getLayerFeatureInfoViews = (layer, { defaultType, includeDisabled = return []; } if (Array.isArray(featureInfo.views) && featureInfo.views.length) { - return featureInfo.views.map((view, idx) => ({ - ...view, - id: view.id || `view-${idx}`, - type: view.type || view.format || INFO_VIEW_MODES.PROPERTIES - })); + return featureInfo.views + .map((view, idx) => ({ + ...view, + id: view.id || `view-${idx}`, + type: view.type || view.format || INFO_VIEW_MODES.PROPERTIES + })); } if (featureInfo.format && featureInfo.format !== 'HIDDEN') { const { format, ...config } = featureInfo; diff --git a/web/client/utils/__tests__/IdentifyUtils-test.js b/web/client/utils/__tests__/IdentifyUtils-test.js index f534e07b628..77bc79d35c2 100644 --- a/web/client/utils/__tests__/IdentifyUtils-test.js +++ b/web/client/utils/__tests__/IdentifyUtils-test.js @@ -7,7 +7,7 @@ */ import expect from 'expect'; -import { getFormatForResponse } from '../IdentifyUtils'; +import { getFormatForResponse, getVisibleFeatureRow } from '../IdentifyUtils'; import { INFO_FORMATS } from '../FeatureInfoUtils'; describe('IdentifyUtils', () => { @@ -17,4 +17,47 @@ describe('IdentifyUtils', () => { it('getFormatForResponse WFS response', () => { expect(getFormatForResponse({ queryParams: { outputFormat: INFO_FORMATS.JSON } })).toBe(INFO_FORMATS.JSON); }); + it('getVisibleFeatureRow passes through when no field declares a visibility', () => { + const feature = { properties: { a: 1, b: 2 } }; + const objectFields = [{ name: 'a' }, { name: 'b', alias: 'B' }]; + const objectRow = getVisibleFeatureRow(feature, objectFields); + expect(objectRow.feature).toBe(feature); + expect(objectRow.fields).toBe(objectFields); + + // vector, model, cog and flatgeobuf layers expose fields as plain names + const nameFields = ['a', 'b']; + const nameRow = getVisibleFeatureRow(feature, nameFields); + expect(nameRow.feature).toBe(feature); + expect(nameRow.fields).toBe(nameFields); + }); + it('getVisibleFeatureRow keeps only the visible properties when visibility is configured', () => { + const row = getVisibleFeatureRow( + { properties: { a: 1, b: 2, unknown: 3 } }, + [ + { name: 'a', visible: true }, + { name: 'b', visible: false }, + { name: 'c' } + ] + ); + // a property without a matching field is dropped, a field without the flag stays visible + expect(row.feature.properties).toEqual({ a: 1 }); + expect(row.fields.map(({ name }) => name)).toEqual(['a', 'c']); + }); + it('getVisibleFeatureRow does not alter the source feature', () => { + const feature = { + id: 'feature.1', + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: { a: 1, b: 2 } + }; + const row = getVisibleFeatureRow(feature, [ + { name: 'a', visible: true }, + { name: 'b', visible: false } + ]); + expect(feature.properties).toEqual({ a: 1, b: 2 }); + expect(row.feature).toNotBe(feature); + expect(row.feature.id).toBe('feature.1'); + expect(row.feature.type).toBe('Feature'); + expect(row.feature.geometry).toBe(feature.geometry); + }); }); diff --git a/web/client/utils/__tests__/MapInfoUtils-test.js b/web/client/utils/__tests__/MapInfoUtils-test.js index d8104dba643..0b859e81665 100644 --- a/web/client/utils/__tests__/MapInfoUtils-test.js +++ b/web/client/utils/__tests__/MapInfoUtils-test.js @@ -48,7 +48,8 @@ describe('MapInfoUtils', () => { "TEXT": "TEXT", "PROPERTIES": "PROPERTIES", "HTML": "HTML", - "TEMPLATE": "TEMPLATE" + "TEMPLATE": "TEMPLATE", + "EXTERNAL_DATA": "EXTERNAL_DATA" }; let results = getInfoViewModes(); expect(results).toExist(); @@ -838,6 +839,28 @@ describe('MapInfoUtils', () => { } })).toEqual(views); }); + it('getLayerFeatureInfoViews should report external data views regardless of their configuration', () => { + const layer = { + featureInfo: { + views: [{ + id: 'invalid-external', + type: 'EXTERNAL_DATA', + featuresService: {} + }, { + id: 'valid-external', + type: 'EXTERNAL_DATA', + featuresService: { + url: '/geoserver/wfs', + typeName: 'workspace:external', + cqlFilter: "source_id = '${properties.id}'" + } + }] + } + }; + + expect(getLayerFeatureInfoViews(layer).map(({ id }) => id)) + .toEqual(['invalid-external', 'valid-external']); + }); it('isLayerFeatureInfoDisabled should support legacy HIDDEN and disabled flag', () => { expect(isLayerFeatureInfoDisabled()).toBe(false); diff --git a/web/client/utils/mapinfo/ExternalDataCache.js b/web/client/utils/mapinfo/ExternalDataCache.js new file mode 100644 index 00000000000..fe48a9c42e0 --- /dev/null +++ b/web/client/utils/mapinfo/ExternalDataCache.js @@ -0,0 +1,70 @@ +/* + * Copyright 2026, 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. + */ + +const MAX_CACHE_ENTRIES = 500; +// Store promises so concurrent renders can share the same in-flight request. +const requestCache = new Map(); + +/** + * Builds a key that isolates requests by identify run, feature and query. + */ +export const createExternalDataCacheKey = ({ + identifyRequestId, + sourceFeatureId, + sourceFeatureIndex, + url, + typeName, + cqlFilter +}) => JSON.stringify([ + identifyRequestId, + sourceFeatureId, + sourceFeatureIndex, + url, + typeName, + cqlFilter +]); + +export const getExternalDataCacheEntry = (key) => + requestCache.get(key)?.request; + +/** + * Caches an external WFS request and associates it with its identify run. + */ +export const setExternalDataCacheEntry = (key, request, identifyRequestId) => { + if (requestCache.size >= MAX_CACHE_ENTRIES) { + requestCache.delete(requestCache.keys().next().value); + } + requestCache.set(key, { identifyRequestId, request }); + return request; +}; + +export const deleteExternalDataCacheEntry = (key) => { + requestCache.delete(key); +}; + +/** + * Drops every cached request, for example when the session ends. + */ +export const clearExternalDataCache = () => { + requestCache.clear(); +}; + +/** + * Removes cached requests when their identify results are discarded. + */ +export const clearExternalDataCacheForIdentifyRequests = (identifyRequestIds = []) => { + const requestIds = new Set(identifyRequestIds.filter(Boolean)); + if (!requestIds.size) { + return; + } + requestCache.forEach((entry, key) => { + if (requestIds.has(entry.identifyRequestId)) { + requestCache.delete(key); + } + }); +}; diff --git a/web/client/utils/mapinfo/ExternalDataUtils.js b/web/client/utils/mapinfo/ExternalDataUtils.js new file mode 100644 index 00000000000..6f4bfe53687 --- /dev/null +++ b/web/client/utils/mapinfo/ExternalDataUtils.js @@ -0,0 +1,81 @@ +/* + * Copyright 2026, 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 { read as readCQL } from '../ogc/Filter/CQL/parser'; + +export const EXTERNAL_DATA = 'EXTERNAL_DATA'; + +const PLACEHOLDER = /\$\{\s*properties(?:\.([A-Za-z_$][\w$]*)|\[['"]([^'"]+)['"]\])\s*\}/g; +const DOUBLE_QUOTED_PLACEHOLDER = /"[^"]*\$\{\s*properties(?:\.[A-Za-z_$][\w$]*|\[['"][^'"]+['"]\])\s*\}[^"]*"/; +// the only literals CQL accepts outside a quoted string +const UNQUOTED_LITERAL = /^(-?\d+(\.\d+)?|true|false)$/i; +const escapeCQLStrings = (value) => value.replace(/'/g, "''"); + +// quote parity is reliable because CQL escapes a quote by doubling it +const isInsideQuotedLiteral = (cqlFilter, offset) => + (cqlFilter.slice(0, offset).match(/'/g) || []).length % 2 === 1; + +const createError = (message, code, properties = {}) => + Object.assign(new Error(message), { code, ...properties }); + +/** + * Validates the required fields, property placeholders and CQL syntax. + * @return {string|null} translation key for the error, or null when valid + */ +export const validateExternalDataConfiguration = ({ url, typeName, cqlFilter } = {}) => { + if (!url?.trim() || !typeName?.trim() || !cqlFilter?.trim()) { + return 'layerProperties.externalData.validation.missingFields'; + } + const placeholders = cqlFilter.match(PLACEHOLDER) || []; + const withoutValidPlaceholders = cqlFilter.replace(PLACEHOLDER, ''); + if (!placeholders.length || /\$\{/.test(withoutValidPlaceholders)) { + return 'layerProperties.externalData.validation.invalidPlaceholder'; + } + if (DOUBLE_QUOTED_PLACEHOLDER.test(cqlFilter)) { + return 'layerProperties.externalData.validation.invalidDoubleQuotedPlaceholder'; + } + try { + readCQL(cqlFilter.replace(PLACEHOLDER, '1')); + } catch (e) { + return 'layerProperties.externalData.validation.invalidCql'; + } + return null; +}; + +/** + * Interpolates an External Data CQL template with the properties of a source + * feature. Quoted values are escaped, unquoted values must be literals. + * @return {string} CQL filter ready for the external WFS request + */ +export const interpolateExternalDataCQL = (cqlFilter = '', feature = {}) => { + const interpolatedCQL = cqlFilter.replace(PLACEHOLDER, (placeholder, dotProperty, bracketProperty, offset) => { + const propertyName = dotProperty || bracketProperty; + const properties = feature?.properties || {}; + if (!Object.prototype.hasOwnProperty.call(properties, propertyName)) { + throw createError(`Missing source feature property: ${propertyName}`, 'MISSING_SOURCE_PROPERTY', { propertyName }); + } + const value = properties[propertyName]; + if (value === null || value === undefined) { + throw createError(`Null source feature property: ${propertyName}`, 'MISSING_SOURCE_PROPERTY', { propertyName }); + } + const stringValue = `${value}`; + if (isInsideQuotedLiteral(cqlFilter, offset)) { + return escapeCQLStrings(stringValue); + } + if (!UNQUOTED_LITERAL.test(stringValue)) { + throw createError(`Unsafe value for unquoted placeholder: ${propertyName}`, 'UNSAFE_SOURCE_VALUE', { propertyName }); + } + return stringValue; + }); + try { + readCQL(interpolatedCQL); + } catch (e) { + throw createError('The interpolated CQL filter is not valid', 'INVALID_INTERPOLATED_CQL', { cqlFilter: interpolatedCQL }); + } + return interpolatedCQL; +}; diff --git a/web/client/utils/mapinfo/__tests__/ExternalDataCache-test.js b/web/client/utils/mapinfo/__tests__/ExternalDataCache-test.js new file mode 100644 index 00000000000..b1e3379d3ea --- /dev/null +++ b/web/client/utils/mapinfo/__tests__/ExternalDataCache-test.js @@ -0,0 +1,79 @@ +/* + * Copyright 2026, 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 { + clearExternalDataCacheForIdentifyRequests, + createExternalDataCacheKey, + deleteExternalDataCacheEntry, + getExternalDataCacheEntry, + setExternalDataCacheEntry +} from '../ExternalDataCache'; + +describe('ExternalDataCache', () => { + const createKey = (identifyRequestId = 'request-1') => createExternalDataCacheKey({ + identifyRequestId, + sourceFeatureId: 'feature-1', + sourceFeatureIndex: 0, + url: '/external/wfs', + typeName: 'workspace:external', + cqlFilter: "source_id = '1'" + }); + + afterEach(() => { + clearExternalDataCacheForIdentifyRequests(['request-1', 'request-2']); + }); + + it('reuses a cached request and deletes it explicitly', () => { + const key = createKey(); + const request = Promise.resolve({ features: [] }); + + expect(setExternalDataCacheEntry(key, request, 'request-1')).toBe(request); + expect(getExternalDataCacheEntry(key)).toBe(request); + + deleteExternalDataCacheEntry(key); + expect(getExternalDataCacheEntry(key)).toNotExist(); + }); + + it('clears only entries belonging to discarded identify requests', () => { + const firstKey = createKey('request-1'); + const secondKey = createKey('request-2'); + setExternalDataCacheEntry(firstKey, Promise.resolve({}), 'request-1'); + setExternalDataCacheEntry(secondKey, Promise.resolve({}), 'request-2'); + + clearExternalDataCacheForIdentifyRequests(['request-1']); + + expect(getExternalDataCacheEntry(firstKey)).toNotExist(); + expect(getExternalDataCacheEntry(secondKey)).toExist(); + }); + + it('creates a different key when a request dependency changes', () => { + const request = { + identifyRequestId: 'request-1', + sourceFeatureId: 'feature-1', + sourceFeatureIndex: 0, + url: '/external/wfs', + typeName: 'workspace:external', + cqlFilter: "source_id = '1'" + }; + const key = createExternalDataCacheKey(request); + + expect(createExternalDataCacheKey({ ...request })).toBe(key); + [ + { identifyRequestId: 'request-2' }, + { sourceFeatureId: 'feature-2' }, + { sourceFeatureIndex: 1 }, + { url: '/other/wfs' }, + { typeName: 'workspace:other' }, + { cqlFilter: "source_id = '2'" } + ].forEach((change) => { + expect(createExternalDataCacheKey({ ...request, ...change })).toNotBe(key); + }); + }); +}); diff --git a/web/client/utils/mapinfo/__tests__/ExternalDataUtils-test.js b/web/client/utils/mapinfo/__tests__/ExternalDataUtils-test.js new file mode 100644 index 00000000000..3c8c31f2289 --- /dev/null +++ b/web/client/utils/mapinfo/__tests__/ExternalDataUtils-test.js @@ -0,0 +1,126 @@ +/* + * Copyright 2026, 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 { + interpolateExternalDataCQL, + validateExternalDataConfiguration +} from '../ExternalDataUtils'; + +describe('ExternalDataUtils', () => { + it('interpolates dot and bracket properties and escapes CQL string values', () => { + const filter = interpolateExternalDataCQL( + "code = '${properties.code}' AND label = '${properties['display-name']}'", + { + properties: { + code: "O'Brien", + 'display-name': 'Main' + } + } + ); + + expect(filter).toBe("code = 'O''Brien' AND label = 'Main'"); + }); + + it('interpolates numeric values without adding quotes', () => { + expect(interpolateExternalDataCQL( + 'external_id = ${properties.id}', + { properties: { id: 10 } } + )).toBe('external_id = 10'); + }); + + it('rejects placeholders surrounded by CQL double quotes', () => { + expect(validateExternalDataConfiguration({ + url: '/geoserver/wfs', + typeName: 'workspace:external', + cqlFilter: 'code = "${properties.code}"' + })).toBe('layerProperties.externalData.validation.invalidDoubleQuotedPlaceholder'); + + expect(validateExternalDataConfiguration({ + url: '/geoserver/wfs', + typeName: 'workspace:external', + cqlFilter: "code = '${properties.code}'" + })).toBe(null); + }); + + it('interpolates a quoted placeholder that is not adjacent to its quotes', () => { + expect(interpolateExternalDataCQL( + "label LIKE '${properties.prefix}%'", + { properties: { prefix: "O'B" } } + )).toBe("label LIKE 'O''B%'"); + }); + + it('rejects a value that would alter the filter through an unquoted placeholder', () => { + try { + interpolateExternalDataCQL( + 'external_id = ${properties.id}', + { properties: { id: '1 OR external_id > 0' } } + ); + throw new Error('Expected interpolation to fail'); + } catch (error) { + expect(error.code).toBe('UNSAFE_SOURCE_VALUE'); + expect(error.propertyName).toBe('id'); + } + }); + + it('accepts numeric and boolean values for unquoted placeholders', () => { + expect(interpolateExternalDataCQL( + 'external_id = ${properties.id}', + { properties: { id: -1.5 } } + )).toBe('external_id = -1.5'); + + expect(interpolateExternalDataCQL( + 'active = ${properties.active}', + { properties: { active: true } } + )).toBe('active = true'); + }); + + it('throws a typed error when the interpolated filter is not valid CQL', () => { + try { + interpolateExternalDataCQL( + "code = '${properties.code}' AND", + { properties: { code: 'a' } } + ); + throw new Error('Expected interpolation to fail'); + } catch (error) { + expect(error.code).toBe('INVALID_INTERPOLATED_CQL'); + expect(error.cqlFilter).toBe("code = 'a' AND"); + } + }); + + it('throws a typed error when the source property is missing', () => { + expect(() => interpolateExternalDataCQL( + "code = '${properties.code}'", + { properties: {} } + )).toThrow(/Missing source feature property: code/); + + try { + interpolateExternalDataCQL("code = '${properties.code}'", { properties: {} }); + } catch (error) { + expect(error.code).toBe('MISSING_SOURCE_PROPERTY'); + expect(error.propertyName).toBe('code'); + } + }); + + it('throws a typed error when the source property is nullish', () => { + [null, undefined].forEach((value) => { + try { + interpolateExternalDataCQL( + "code = '${properties.code}'", + { properties: { code: value } } + ); + throw new Error('Expected interpolation to fail'); + } catch (error) { + expect(error.message).toMatch(/Null source feature property: code/); + expect(error.code).toBe('MISSING_SOURCE_PROPERTY'); + expect(error.propertyName).toBe('code'); + } + }); + }); +}); From 78b2ecbbf725021a5883a7a0d47d89ea31453044 Mon Sep 17 00:00:00 2001 From: RowHeat <40065760+rowheat02@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:16:55 +0545 Subject: [PATCH 4/5] Identify: media rendering for EXTERNAL DATA attributes #12597 (#12786) --------- Co-authored-by: allyoucanmap --- package.json | 5 +- .../TOC/fragments/LayerFields/Fields.jsx | 100 ++++++++++--- .../LayerFields/__tests__/Fields-test.jsx | 45 ++++++ .../fragments/settings/ExternalDataEditor.jsx | 2 + .../fragments/settings/PropertiesEditor.jsx | 1 + .../__tests__/ExternalDataEditor-test.jsx | 28 ++-- .../identify/viewers/ExternalDataViewer.jsx | 1 + .../data/identify/viewers/PropertiesViewer.js | 1 + .../identify/viewers/row/AttributeValue.jsx | 73 +++++++++ .../identify/viewers/row/PanoramaViewer.jsx | 53 +++++++ .../data/identify/viewers/row/PdfViewer.jsx | 82 +++++++++++ .../identify/viewers/row/PropertiesViewer.jsx | 13 +- .../data/identify/viewers/row/RowViewer.jsx | 7 +- .../row/__tests__/AttributeValue-test.jsx | 138 ++++++++++++++++++ .../row/__tests__/PanoramaViewer-test.jsx | 84 +++++++++++ .../viewers/row/__tests__/PdfViewer-test.jsx | 84 +++++++++++ .../components/geostory/media/Image.jsx | 5 + .../geostory/media/__tests__/Image-test.jsx | 2 + .../default/less/external-data-editor.less | 53 +++++++ .../themes/default/less/toc-settings.less | 33 +++++ web/client/translations/data.de-DE.json | 17 ++- web/client/translations/data.en-US.json | 17 ++- web/client/translations/data.es-ES.json | 17 ++- web/client/translations/data.fr-FR.json | 17 ++- web/client/translations/data.it-IT.json | 17 ++- web/client/utils/FeatureInfoAttributeUtils.js | 64 ++++++++ web/client/utils/HtmlSanitizer.js | 18 ++- web/client/utils/IdentifyUtils.js | 14 +- .../FeatureInfoAttributeUtils-test.js | 28 ++++ .../utils/__tests__/IdentifyUtils-test.js | 21 ++- 30 files changed, 977 insertions(+), 63 deletions(-) create mode 100644 web/client/components/data/identify/viewers/row/AttributeValue.jsx create mode 100644 web/client/components/data/identify/viewers/row/PanoramaViewer.jsx create mode 100644 web/client/components/data/identify/viewers/row/PdfViewer.jsx create mode 100644 web/client/components/data/identify/viewers/row/__tests__/AttributeValue-test.jsx create mode 100644 web/client/components/data/identify/viewers/row/__tests__/PanoramaViewer-test.jsx create mode 100644 web/client/components/data/identify/viewers/row/__tests__/PdfViewer-test.jsx create mode 100644 web/client/utils/FeatureInfoAttributeUtils.js create mode 100644 web/client/utils/__tests__/FeatureInfoAttributeUtils-test.js diff --git a/package.json b/package.json index cc7c4a2e31d..10b6a337126 100644 --- a/package.json +++ b/package.json @@ -142,18 +142,18 @@ "bootstrap": "3.4.1", "buffer": "6.0.3", "canvas-to-blob": "0.0.0", - "cesium": "1.134", + "cesium": "1.134.0", "chroma-js": "1.3.7", "classnames": "2.2.5", "codemirror": "5.65.16", "concurrently": "6.4.0", "connected-react-router": "6.3.2", "d3-format": "3.1.0", + "dompurify": "^3.1.0", "draft-js": "0.11.0", "draft-js-inline-toolbar-plugin": "3.0.1", "draft-js-plugins-editor": "2.1.1", "draft-js-side-toolbar-plugin": "3.0.1", - "dompurify": "^3.1.0", "draftjs-to-html": "0.8.4", "dxf-parser": "1.1.2", "dxf-writer": "1.18.4", @@ -192,6 +192,7 @@ "moment": "2.29.4", "node-geo-distance": "1.2.0", "ol": "7.4.0", + "pannellum": "2.5.7", "pdfmake": "0.2.7", "plotly.js-cartesian-dist": "2.35.2", "proj4": "2.19.10", diff --git a/web/client/components/TOC/fragments/LayerFields/Fields.jsx b/web/client/components/TOC/fragments/LayerFields/Fields.jsx index c21270bd299..31002b4557f 100644 --- a/web/client/components/TOC/fragments/LayerFields/Fields.jsx +++ b/web/client/components/TOC/fragments/LayerFields/Fields.jsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { ControlLabel, FormControl, FormGroup, Alert, Glyphicon, Button, Checkbox } from 'react-bootstrap'; +import Select from 'react-select'; import Message from '../../../I18N/Message'; import LoadingSpinner from '../../../misc/LoadingSpinner'; import LocalizedInput from '../../../misc/LocalizedInput'; @@ -9,9 +10,13 @@ import BorderLayout from '../../../layout/BorderLayout'; import withConfirm from '../../../misc/withConfirm'; import withTooltip from '../../../data/featuregrid/enhancers/withTooltip'; import localizedProps from '../../../misc/enhancers/localizedProps'; +import tooltip from '../../../misc/enhancers/tooltip'; +import { extractLocalizedString } from '../../../I18N/LocalizedString'; +import { DISPLAY_TYPES } from '../../../../utils/FeatureInfoAttributeUtils'; const ConfirmButton = localizedProps("tooltip")(withTooltip(withConfirm(Button))); const CheckboxWithTooltip = localizedProps("tooltip")(withTooltip(Checkbox)); +const SettingsButton = tooltip(Button); const isGeometryType = (type) => ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'Geometry'].includes(type); @@ -47,12 +52,17 @@ const Fields = ({ error, currentLocale, title, - showVisibility = false + showVisibility = false, + showFieldSettings = false }) => { const displayedFields = fields.filter(({type}) => !isGeometryType(type)); const visibleCount = displayedFields.filter(({visible = true}) => visible).length; + const [configuredFields, setConfiguredFields] = useState([]); + const toggleFieldSettings = (name) => setConfiguredFields(configuredFields.includes(name) + ? configuredFields.filter((fieldName) => fieldName !== name) + : [...configuredFields, name]); return (
{title ?
{title}
: null} @@ -96,12 +106,13 @@ const Fields = ({ - + {showFieldSettings ? null : - + } + {showFieldSettings ? : null}
} footer={
@@ -110,23 +121,65 @@ const Fields = ({
} > {displayedFields - .map(({name, alias, type, visible}) => { - return (
- {showVisibility ? - onChange(name, "visible", event.target.checked)}/> - : null} - - - - - onChange(name, "alias", value)} value={alias} currentLocale={currentLocale} /> - - - - + .map((field) => { + const { name, alias, type, visible, displayType, mediaTypeAttribute } = field; + const isConfigured = configuredFields.includes(name); + return (
+
+ {showVisibility ? + onChange(name, "visible", event.target.checked)}/> + : null} + + + + {showFieldSettings ? null : + onChange(name, "alias", value)} value={alias} currentLocale={currentLocale} /> + } + + + + {showFieldSettings ? + toggleFieldSettings(name)}> + + + : null} +
+ {showFieldSettings && isConfigured ?
+
+ + onChange(name, "alias", value)} value={alias} currentLocale={currentLocale} /> +
+
+ + optionName !== name).map(({ name: optionName, alias: optionAlias }) => ({ + value: optionName, + label: extractLocalizedString(optionAlias, currentLocale) || optionName + }))} + onChange={(selected) => onChange(name, 'mediaTypeAttribute', selected?.value)}/> +
: null} +
: null}
); })} @@ -143,7 +196,8 @@ Fields.propTypes = { error: PropTypes.bool, currentLocale: PropTypes.string, title: PropTypes.node, - showVisibility: PropTypes.bool + showVisibility: PropTypes.bool, + showFieldSettings: PropTypes.bool }; export default Fields; diff --git a/web/client/components/TOC/fragments/LayerFields/__tests__/Fields-test.jsx b/web/client/components/TOC/fragments/LayerFields/__tests__/Fields-test.jsx index a621a5a125a..2ea7143da25 100644 --- a/web/client/components/TOC/fragments/LayerFields/__tests__/Fields-test.jsx +++ b/web/client/components/TOC/fragments/LayerFields/__tests__/Fields-test.jsx @@ -19,6 +19,7 @@ describe('TOC Settings - Fields component', () => { const container = document.getElementById('container'); const el = container.querySelector('.layer-fields'); expect(el).toBeTruthy(); + expect(el.classList.contains('layer-fields-with-settings')).toBe(false); }); it('rendering fields', () => { const actions = { @@ -188,4 +189,48 @@ describe('TOC Settings - Fields component', () => { Simulate.change(headerCheckbox(), {target: {checked: true}}); expect(spy).toHaveBeenCalledWith('visible', true); }); + it('shows display settings only after clicking the field settings button', () => { + ReactDOM.render( + , + document.getElementById('container') + ); + const container = document.getElementById('container'); + expect(container.querySelector('.layer-field-settings-panel')).toNotExist(); + const settingsButtons = container.querySelectorAll('.layer-field-settings-button'); + expect(settingsButtons.length).toBe(2); + Simulate.click(settingsButtons[0]); + expect(container.querySelector('.layer-field-settings-panel')).toExist(); + Simulate.click(settingsButtons[0]); + expect(container.querySelector('.layer-field-settings-panel')).toNotExist(); + }); + it('allows selecting the feature attribute containing the media type', () => { + const actions = { onChange: () => {} }; + const spy = expect.spyOn(actions, 'onChange'); + ReactDOM.render( + , + document.getElementById('container') + ); + const settingsButton = document.querySelector('.layer-field-settings-button'); + expect(document.querySelector('.layer-fields').classList.contains('layer-fields-with-settings')).toBe(true); + Simulate.click(settingsButton); + const mediaTypeSelect = document.querySelectorAll('.layer-field-settings-panel .Select-control')[1]; + expect(mediaTypeSelect).toBeTruthy(); + Simulate.keyDown(mediaTypeSelect, { key: 'ArrowDown', keyCode: 40 }); + const options = document.querySelectorAll('.Select-option'); + expect(options.length).toBe(2); + expect(options[0].textContent).toBe('Tipo MIME'); + Simulate.mouseDown(options[0]); + expect(spy.calls[0].arguments).toEqual(['media', 'mediaTypeAttribute', 'mimeType']); + spy.restore(); + }); }); diff --git a/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx b/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx index 441dfe8a9db..051e914f507 100644 --- a/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx +++ b/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx @@ -65,6 +65,7 @@ export const getExternalAttributes = (description = {}, previousAttributes = []) .map((attribute) => { const previous = previousAttributes.find(({ name }) => name === attribute.name) || {}; return { + ...previous, name: attribute.name, type: attribute.localType || attribute.type, alias: previous.alias || '', @@ -414,6 +415,7 @@ const ExternalDataEditor = ({ value = {}, onChange = () => {}, sourceLayer, curr loading={attributesStatus === 'loading'} error={attributesStatus === 'error'} showVisibility + showFieldSettings onChange={(name, property, nextValue) => updateAttribute(name, { [property]: nextValue })} diff --git a/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx b/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx index d5f0e710644..c5ac20299f1 100644 --- a/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx +++ b/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx @@ -114,6 +114,7 @@ const PropertiesEditor = ({ sourceLayer = {}, value = [], onChange = () => {}, c loading={loading} error={error} showVisibility + showFieldSettings onChange={updateAttribute} onChangeAll={updateAllAttributes} onLoadFields={() => loadAttributes(true)} diff --git a/web/client/components/TOC/fragments/settings/__tests__/ExternalDataEditor-test.jsx b/web/client/components/TOC/fragments/settings/__tests__/ExternalDataEditor-test.jsx index c42626f7e5a..210af1d2230 100644 --- a/web/client/components/TOC/fragments/settings/__tests__/ExternalDataEditor-test.jsx +++ b/web/client/components/TOC/fragments/settings/__tests__/ExternalDataEditor-test.jsx @@ -11,6 +11,7 @@ import MockAdapter from 'axios-mock-adapter'; import React from 'react'; import ReactDOM from 'react-dom'; import TestUtils from 'react-dom/test-utils'; +import { waitFor } from '@testing-library/react'; import axios from '../../../../../libs/ajax'; import ExternalDataEditor, { @@ -113,8 +114,10 @@ describe('ExternalDataEditor', () => { , document.getElementById('container') ); - const attributeRow = document.querySelector('.ms-external-data-attributes .layer-fields-row'); - TestUtils.Simulate.change(attributeRow.querySelector('.layer-field-alias input'), { target: { value: 'Display name' } }); + const attributeContainer = document.querySelector('.ms-external-data-attributes .layer-fields-field-container'); + const attributeRow = attributeContainer.querySelector('.layer-fields-row'); + TestUtils.Simulate.click(attributeRow.querySelector('.layer-field-settings-button')); + TestUtils.Simulate.change(attributeContainer.querySelector('.layer-field-settings-panel .layer-field-alias input'), { target: { value: 'Display name' } }); expect(value.attributes[0].alias).toBe('Display name'); TestUtils.Simulate.change(attributeRow.querySelector('.layer-field-visibility input'), { @@ -123,7 +126,7 @@ describe('ExternalDataEditor', () => { expect(value.attributes[0].visible).toBe(false); }); - it('loads feature types for an initial WFS URL', (done) => { + it('loads feature types for an initial WFS URL', () => { mockAxios.onGet().reply(({ url }) => { expect(url).toContain('/external-data-test/wfs'); expect(url).toContain('request=GetCapabilities'); @@ -140,17 +143,14 @@ describe('ExternalDataEditor', () => { document.getElementById('container') ); - setTimeout(() => { - try { - const select = document.querySelector('.ms-external-data-layer-select'); - expect(select.classList.contains('is-disabled')).toBe(false); - TestUtils.Simulate.mouseDown(select.querySelector('.Select-arrow'), { button: 0 }); - expect(document.body.textContent).toContain('Table (workspace:table)'); - done(); - } catch (error) { - done(error); - } - }, 50); + return waitFor(() => { + const select = document.querySelector('.ms-external-data-layer-select'); + expect(select.classList.contains('is-disabled')).toBe(false); + }).then(() => { + const select = document.querySelector('.ms-external-data-layer-select'); + TestUtils.Simulate.mouseDown(select.querySelector('.Select-arrow'), { button: 0 }); + expect(document.body.textContent).toContain('Table (workspace:table)'); + }); }); it('ignores a capabilities response superseded by a newer URL', (done) => { diff --git a/web/client/components/data/identify/viewers/ExternalDataViewer.jsx b/web/client/components/data/identify/viewers/ExternalDataViewer.jsx index 7e818f5da10..759afc8312b 100644 --- a/web/client/components/data/identify/viewers/ExternalDataViewer.jsx +++ b/web/client/components/data/identify/viewers/ExternalDataViewer.jsx @@ -224,6 +224,7 @@ const ExternalDataViewer = ({ response, layer }) => { ); }) diff --git a/web/client/components/data/identify/viewers/PropertiesViewer.js b/web/client/components/data/identify/viewers/PropertiesViewer.js index e86afcbc743..c74dda9ab06 100644 --- a/web/client/components/data/identify/viewers/PropertiesViewer.js +++ b/web/client/components/data/identify/viewers/PropertiesViewer.js @@ -23,6 +23,7 @@ export default ({response, layer, rowViewer}) => { ); diff --git a/web/client/components/data/identify/viewers/row/AttributeValue.jsx b/web/client/components/data/identify/viewers/row/AttributeValue.jsx new file mode 100644 index 00000000000..92b8ca6be8e --- /dev/null +++ b/web/client/components/data/identify/viewers/row/AttributeValue.jsx @@ -0,0 +1,73 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { lazy, Suspense } from 'react'; +import PropTypes from 'prop-types'; +import Image from '../../../../geostory/media/Image'; +import Video from '../../../../geostory/media/Video'; +import { Modes } from '../../../../../utils/GeoStoryUtils'; +import PdfViewer from './PdfViewer'; +import { resolveAttributeDisplayType } from '../../../../../utils/FeatureInfoAttributeUtils'; +import { isValidURL } from '../../../../../utils/URLUtils'; +import { IFRAME_SANDBOX, IFRAME_REFERRER_POLICY } from '../../../../../utils/HtmlSanitizer'; + +const PanoramaViewer = lazy(() => import('./PanoramaViewer')); + +export const formatAttributeValue = (value) => { + if (typeof value === 'string') { + return value; + } + if (value === undefined || value === null) { + return ''; + } + return JSON.stringify(value); +}; + +const AttributeValue = ({ value, attribute = {}, mediaTypeValue }) => { + const displayType = resolveAttributeDisplayType({ value, attribute, mediaTypeValue }); + if (typeof value !== 'string' || displayType === 'string' || !isValidURL(value)) { + return formatAttributeValue(value); + } + const className = 'ms-feature-info-attribute-media'; + if (displayType === 'image') { + return
; + } + if (displayType === 'video') { + return
; + } + if (displayType === 'audio') { + return ; + } + if (displayType === 'panorama') { + return ; + } + if (displayType === 'pdf') { + return ; + } + if (displayType === 'iframe') { + return (