+ {showVisibility ?
+ onChange(name, "visible", event.target.checked)}/>
+ : null}
@@ -103,9 +137,13 @@ Fields.propTypes = {
fields: PropTypes.array,
onLoadFields: PropTypes.func,
onChange: PropTypes.func,
+ onChangeAll: PropTypes.func,
onClear: PropTypes.func,
loading: PropTypes.bool,
- error: PropTypes.bool
+ error: PropTypes.bool,
+ currentLocale: PropTypes.string,
+ title: PropTypes.node,
+ showVisibility: 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 1d66eff1997..a621a5a125a 100644
--- a/web/client/components/TOC/fragments/LayerFields/__tests__/Fields-test.jsx
+++ b/web/client/components/TOC/fragments/LayerFields/__tests__/Fields-test.jsx
@@ -139,4 +139,53 @@ describe('TOC Settings - Fields component', () => {
const rows = container.querySelectorAll('.ms2-border-layout-body .layer-fields-row');
expect(rows.length).toBe(2);
});
+ it('optionally renders visibility controls', () => {
+ const actions = {
+ onChange: () => {}
+ };
+ const spy = expect.spyOn(actions, 'onChange');
+ ReactDOM.render(
+
,
+ document.getElementById('container')
+ );
+ const container = document.getElementById('container');
+ expect(container.querySelector('.layer-fields-toolbar')).toExist();
+ const visibilityInputs = container.querySelectorAll('.layer-fields-row .layer-field-visibility input');
+ expect(visibilityInputs.length).toBe(3);
+ expect(visibilityInputs[0].checked).toBe(true);
+ expect(visibilityInputs[2].checked).toBe(false);
+
+ Simulate.change(visibilityInputs[2], {target: {checked: true}});
+ expect(spy).toHaveBeenCalledWith('hidden', 'visible', true);
+ });
+ it('toggles every attribute with the header checkbox', () => {
+ const actions = {
+ onChangeAll: () => {}
+ };
+ const spy = expect.spyOn(actions, 'onChangeAll');
+ const container = document.getElementById('container');
+ const render = (fields) => ReactDOM.render(
+
,
+ container
+ );
+ const headerCheckbox = () => container.querySelector('.layer-fields-row-header .layer-field-visibility input');
+
+ render([{name: 'a', type: 'string', visible: true}, {name: 'b', type: 'string', visible: true}]);
+ expect(headerCheckbox().checked).toBe(true);
+ expect(headerCheckbox().indeterminate).toBe(false);
+
+ render([{name: 'a', type: 'string', visible: true}, {name: 'b', type: 'string', visible: false}]);
+ expect(headerCheckbox().checked).toBe(false);
+ expect(headerCheckbox().indeterminate).toBe(true);
+
+ render([{name: 'a', type: 'string', visible: false}, {name: 'b', type: 'string', visible: false}]);
+ expect(headerCheckbox().checked).toBe(false);
+ expect(headerCheckbox().indeterminate).toBe(false);
+
+ Simulate.change(headerCheckbox(), {target: {checked: true}});
+ expect(spy).toHaveBeenCalledWith('visible', true);
+ });
});
diff --git a/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx b/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx
new file mode 100644
index 00000000000..441dfe8a9db
--- /dev/null
+++ b/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx
@@ -0,0 +1,436 @@
+/*
+ * 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, { useEffect, useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import { Alert, Button, ControlLabel, FormControl, FormGroup, Glyphicon } from 'react-bootstrap';
+import Select from 'react-select';
+import { castArray, get } from 'lodash';
+
+import Message from '../../../I18N/Message';
+import localizedProps from '../../../misc/enhancers/localizedProps';
+import Spinner from '../../../layout/Spinner';
+import Fields from '../LayerFields/Fields';
+import { getCapabilities, getFeature } from '../../../../api/WFS';
+import { describeFeatureType } from '../../../../observables/wfs';
+import { isGeometryType } from '../../../../utils/ogc/WFS/base';
+import {
+ interpolateExternalDataCQL,
+ validateExternalDataConfiguration
+} from '../../../../utils/mapinfo/ExternalDataUtils';
+
+const LocalizedFormControl = localizedProps('placeholder')(FormControl);
+
+const URL_VALIDATION_DELAY = 500;
+
+const IDLE_VALIDATION = {
+ status: 'idle',
+ messageId: null,
+ messageParams: null,
+ cqlFilter: null
+};
+
+const INTERPOLATION_MESSAGE_IDS = {
+ MISSING_SOURCE_PROPERTY: 'layerProperties.externalData.missingProperty',
+ UNSAFE_SOURCE_VALUE: 'layerProperties.externalData.validation.unquotedPlaceholder',
+ INVALID_INTERPOLATED_CQL: 'layerProperties.externalData.invalidInterpolatedCql'
+};
+
+/**
+ * Normalizes the feature-type list returned by different WFS versions.
+ */
+export const getWFSFeatureTypes = (capabilities = {}) => {
+ const root = capabilities['wfs:WFS_Capabilities']
+ || capabilities.WFS_Capabilities
+ || capabilities;
+ return castArray(get(root, 'FeatureTypeList.FeatureType', []))
+ .map((featureType) => ({
+ name: featureType?.Name?._ || featureType?.Name,
+ title: featureType?.Title?._ || featureType?.Title || featureType?.Name?._ || featureType?.Name
+ }))
+ .filter(({ name }) => !!name);
+};
+
+/**
+ * Creates display settings for non-geometry attributes from DescribeFeatureType.
+ */
+export const getExternalAttributes = (description = {}, previousAttributes = []) =>
+ (description?.featureTypes?.[0]?.properties || [])
+ .filter((attribute) => !isGeometryType(attribute))
+ .map((attribute) => {
+ const previous = previousAttributes.find(({ name }) => name === attribute.name) || {};
+ return {
+ name: attribute.name,
+ type: attribute.localType || attribute.type,
+ alias: previous.alias || '',
+ visible: previous.visible !== false
+ };
+ });
+
+const parseResponse = (data) => {
+ if (typeof data === 'string') {
+ try {
+ return JSON.parse(data);
+ } catch (e) {
+ return data;
+ }
+ }
+ return data;
+};
+
+/**
+ * Configures and validates the WFS query used by an External Data view.
+ */
+const ExternalDataEditor = ({ value = {}, onChange = () => {}, sourceLayer, currentLocale }) => {
+ const [state, setState] = useState({
+ featureTypes: [],
+ capabilitiesStatus: value.url ? 'idle' : 'empty',
+ attributesStatus: value.typeName ? 'idle' : 'empty',
+ validation: IDLE_VALIDATION
+ });
+ const [capabilitiesRequest, setCapabilitiesRequest] = useState({
+ url: value.url,
+ delay: value.url ? 0 : URL_VALIDATION_DELAY,
+ key: 0
+ });
+ const describeRequestId = useRef(0);
+ const validationRequestId = useRef(0);
+ const valueRef = useRef(value);
+ // Async callbacks read the latest committed value instead of stale closures.
+ useEffect(() => {
+ valueRef.current = value;
+ });
+
+ const updateState = (changes) => {
+ setState((previousState) => ({ ...previousState, ...changes }));
+ };
+
+ const updateValue = (changes) => {
+ validationRequestId.current += 1;
+ updateState({ validation: IDLE_VALIDATION });
+ onChange({
+ type: 'wfs',
+ ...valueRef.current,
+ ...changes
+ });
+ };
+
+ const requestCapabilities = (url, delay = URL_VALIDATION_DELAY) => {
+ setCapabilitiesRequest((previousRequest) => ({
+ url,
+ delay,
+ key: previousRequest.key + 1
+ }));
+ };
+
+ useEffect(() => {
+ const { url, delay } = capabilitiesRequest;
+ if (!url?.trim()) {
+ updateState({ featureTypes: [], capabilitiesStatus: 'empty' });
+ return () => {};
+ }
+ let cancelled = false;
+ const timer = setTimeout(() => {
+ updateState({ capabilitiesStatus: 'loading', featureTypes: [] });
+ getCapabilities(url)
+ .then((capabilities) => {
+ if (!cancelled) {
+ const featureTypes = getWFSFeatureTypes(capabilities);
+ updateState({
+ featureTypes,
+ capabilitiesStatus: featureTypes.length ? 'valid' : 'error'
+ });
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ updateState({ featureTypes: [], capabilitiesStatus: 'error' });
+ }
+ });
+ }, delay);
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [capabilitiesRequest]);
+
+ useEffect(() => {
+ return () => {
+ describeRequestId.current += 1;
+ validationRequestId.current += 1;
+ };
+ }, []);
+
+ const onUrlChange = (event) => {
+ const url = event.target.value;
+ describeRequestId.current += 1;
+ updateValue({ url, typeName: '', attributes: [] });
+ updateState({
+ attributesStatus: 'empty',
+ featureTypes: [],
+ capabilitiesStatus: url?.trim() ? 'loading' : 'empty'
+ });
+ requestCapabilities(url);
+ };
+
+ const loadAttributes = (typeName, previousAttributes) => {
+ const currentRequestId = describeRequestId.current + 1;
+ describeRequestId.current = currentRequestId;
+ updateState({ attributesStatus: 'loading' });
+ describeFeatureType({ layer: { url: valueRef.current.url, name: typeName } })
+ .toPromise()
+ .then(({ data }) => {
+ if (currentRequestId === describeRequestId.current) {
+ updateValue({
+ typeName,
+ attributes: getExternalAttributes(data, previousAttributes)
+ });
+ updateState({ attributesStatus: 'valid' });
+ }
+ })
+ .catch(() => {
+ if (currentRequestId === describeRequestId.current) {
+ updateState({ attributesStatus: 'error' });
+ }
+ });
+ };
+
+ const onLayerChange = (selected) => {
+ const typeName = selected?.value || '';
+ const previousAttributes = valueRef.current.typeName === typeName
+ ? valueRef.current.attributes || []
+ : [];
+ updateValue({ typeName, attributes: [] });
+ if (!typeName) {
+ updateState({ attributesStatus: 'empty' });
+ return;
+ }
+ loadAttributes(typeName, previousAttributes);
+ };
+
+ const updateAttribute = (name, changes) => {
+ updateValue({
+ attributes: (valueRef.current.attributes || []).map((attribute) =>
+ attribute.name === name ? { ...attribute, ...changes } : attribute)
+ });
+ };
+
+ const updateAllAttributes = (property, nextValue) => {
+ updateValue({
+ attributes: (valueRef.current.attributes || []).map((attribute) =>
+ ({ ...attribute, [property]: nextValue }))
+ });
+ };
+
+ const getSourceRequest = () => {
+ const currentSourceLayer = sourceLayer || {};
+ const sourceUrl = currentSourceLayer.search?.url
+ || currentSourceLayer.describeFeatureTypeURL
+ || currentSourceLayer.url;
+ const url = Array.isArray(sourceUrl) ? sourceUrl[0] : sourceUrl;
+ const layerName = currentSourceLayer.search?.name || currentSourceLayer.name;
+ return { url, layerName };
+ };
+
+ const validate = () => {
+ const configuration = valueRef.current;
+ const configurationMessage = validateExternalDataConfiguration(configuration);
+ if (configurationMessage) {
+ updateState({
+ validation: { ...IDLE_VALIDATION, status: 'error', messageId: configurationMessage }
+ });
+ return;
+ }
+ const sourceRequest = getSourceRequest();
+ if (!sourceRequest.url || !sourceRequest.layerName) {
+ updateState({
+ validation: {
+ ...IDLE_VALIDATION,
+ status: 'error',
+ messageId: 'layerProperties.externalData.validation.sourceUnavailable'
+ }
+ });
+ return;
+ }
+ const currentValidationRequestId = validationRequestId.current + 1;
+ validationRequestId.current = currentValidationRequestId;
+ updateState({ validation: { ...IDLE_VALIDATION, status: 'loading' } });
+ // First get a source feature, then use it to test the external WFS query.
+ getFeature(sourceRequest.url, sourceRequest.layerName, {
+ maxFeatures: 1,
+ outputFormat: 'application/json'
+ }, {
+ _msAuthSourceId: sourceLayer?.security?.sourceId
+ })
+ .then(({ data }) => {
+ const sourceResponse = parseResponse(data);
+ const sampleFeature = sourceResponse?.features?.[0];
+ if (!sampleFeature) {
+ const error = new Error('No sample feature was returned by the source layer');
+ error.messageId = 'layerProperties.externalData.validation.sampleNotFound';
+ throw error;
+ }
+ const cqlFilter = interpolateExternalDataCQL(
+ configuration.cqlFilter,
+ sampleFeature
+ );
+ return getFeature(configuration.url, configuration.typeName, {
+ CQL_FILTER: cqlFilter,
+ maxFeatures: 1,
+ outputFormat: 'application/json'
+ }).then(({ data: externalResponse }) => ({
+ cqlFilter,
+ response: parseResponse(externalResponse)
+ }));
+ })
+ .then(({ cqlFilter, response }) => {
+ if (!Array.isArray(response?.features)) {
+ const error = new Error('The external WFS did not return a GeoJSON FeatureCollection');
+ error.cqlFilter = cqlFilter;
+ throw error;
+ }
+ if (currentValidationRequestId === validationRequestId.current) {
+ updateState({
+ validation: { ...IDLE_VALIDATION, status: 'success', cqlFilter }
+ });
+ }
+ })
+ .catch((error) => {
+ if (currentValidationRequestId === validationRequestId.current) {
+ updateState({
+ validation: {
+ ...IDLE_VALIDATION,
+ status: 'error',
+ messageId: INTERPOLATION_MESSAGE_IDS[error.code]
+ || error.messageId
+ || 'layerProperties.externalData.validation.testRequestFailed',
+ messageParams: error.propertyName ? { property: error.propertyName } : null,
+ cqlFilter: error.cqlFilter || null
+ }
+ });
+ }
+ });
+ };
+
+ const { url = '', typeName = '', cqlFilter = '', attributes = [] } = value;
+ const { capabilitiesStatus, attributesStatus, validation } = state;
+ return (
+
+
+ *
+
+ requestCapabilities(url, 0)}/>
+
+
+ {capabilitiesStatus === 'error' ? (
+
+ ) : null}
+
+
+
+ *
+
+
+
+ *
+ 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} />
+
+
+
+ }
+ value={displayType}
+ options={DISPLAY_TYPES.map((value) => ({
+ value,
+ label:
+ }))}
+ onChange={(selected) => onChange(name, 'displayType', selected?.value)}/>
+
+ {displayType === 'media' ?
+
+
: 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 (