diff --git a/docs/developer-guide/local-config.md b/docs/developer-guide/local-config.md index 1c21c9facf4..47efa52a7bc 100644 --- a/docs/developer-guide/local-config.md +++ b/docs/developer-guide/local-config.md @@ -157,6 +157,17 @@ For configuring plugins, see the [Configuring Plugins Section](plugins-documenta - `initialState`: is an object that will initialize the state with some default values and this WILL OVERRIDE the initialState imposed by plugins & reducers. - `projectionDefs`: is an array of objects that contain definitions for Coordinate Reference Systems - `gridFiles`: is an object that contains definitions for grid files used in coordinate transformations +- `featureInfoMediaTypeAliases`: is an object that maps display types to custom alias values used by media type attributes. Values are normalized trimming whitespace and converting to lowercase, so also numeric codes are supported. Only display types listed in `DISPLAY_TYPES` are accepted. For example: + + ```json + "featureInfoMediaTypeAliases": { + "panorama": ["PAN", "PANO", "360"], + "image": ["IMG", "FOTO"], + "video": ["VID"] + } + ``` + + When a field uses `"displayType": "media"` and references a media type attribute, these aliases are resolved before the value's file extension is used for detection. - `useAuthenticationRules` (deprecated): if this flag is set to true, legacy `authenticationRules` will be used. The new `requestsConfigurationRules` system does not require this flag and is always active when rules are present. - `requestsConfigurationRules`: is an array of objects that contain rules to match for request configuration. Each rule has a `urlPattern` regex to match and either `headers`, `params`, or `withCredentials` configuration. If the URL of a request matches the `urlPattern` of a rule, the configuration will be applied to the request. diff --git a/docs/developer-guide/mapstore-migration-guide.md b/docs/developer-guide/mapstore-migration-guide.md index 87cdab98883..26202347369 100644 --- a/docs/developer-guide/mapstore-migration-guide.md +++ b/docs/developer-guide/mapstore-migration-guide.md @@ -20,6 +20,34 @@ This is a list of things to check if you want to update from a previous version - Optionally check also accessory files like `.eslinrc`, if you want to keep aligned with lint standards. - Follow the instructions below, in order, from your version to the one you want to update to. +## Migration from 2026.02.02 to 2026.03.00 + +### Identify supports multiple views per layer + +The `featureInfo` of a layer describes a list of views instead of a single format. The identify panel renders one tab per view. + +```json +{ + "featureInfo": { + "disabled": false, + "views": [ + { "id": "properties", "type": "PROPERTIES" }, + { "id": "report", "title": "Report", "type": "TEMPLATE", "template": "

${properties.NAME}

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

${properties.NAME}

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

Feature 1

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

Feature 1

'); + }); + it('creates an error feature-info action with its request ID and error', () => { + const error = new Error('GetFeatureInfo failed'); + const action = errorFeatureInfo('123', error); + + expect(action).toEqual({ + type: ERROR_FEATURE_INFO, + error, + reqId: '123' + }); + }); it('reset reverse geocode data', () => { const e = hideMapinfoRevGeocode(); expect(e).toExist(); diff --git a/web/client/actions/mapInfo.js b/web/client/actions/mapInfo.js index b1373d78551..7f152429183 100644 --- a/web/client/actions/mapInfo.js +++ b/web/client/actions/mapInfo.js @@ -43,15 +43,14 @@ export const toggleEmptyMessageGFI = () => ({type: TOGGLE_EMPTY_MESSAGE_GFI}); /** * Private - * @return a LOAD_FEATURE_INFO action with the response data to a wms GetFeatureInfo + * @return a LOAD_FEATURE_INFO action containing the responses for all configured views */ -export function loadFeatureInfo(reqId, data, rParams, lMetaData, layer, queryParamZoomOption = null) { +export function loadFeatureInfo(reqId, layerMetadata, viewResponses, layer, queryParamZoomOption = null) { return { type: LOAD_FEATURE_INFO, - data: data, - reqId: reqId, - requestParams: rParams, - layerMetadata: lMetaData, + reqId, + layerMetadata, + viewResponses, layer, queryParamZoomOption }; @@ -61,13 +60,11 @@ export function loadFeatureInfo(reqId, data, rParams, lMetaData, layer, queryPar * Private * @return a ERROR_FEATURE_INFO action with the error occurred */ -export function errorFeatureInfo(reqId, e, rParams, lMetaData) { +export function errorFeatureInfo(reqId, e) { return { type: ERROR_FEATURE_INFO, error: e, - reqId: reqId, - requestParams: rParams, - layerMetadata: lMetaData + reqId }; } @@ -98,11 +95,10 @@ export function clearWarning() { }; } -export function newMapInfoRequest(reqId, reqConfig) { +export function newMapInfoRequest(reqId) { return { type: NEW_MAPINFO_REQUEST, - reqId: reqId, - request: reqConfig + reqId }; } diff --git a/web/client/api/__tests__/identify-test.js b/web/client/api/__tests__/identify-test.js index 9e30bac7a9d..172f7c58f68 100644 --- a/web/client/api/__tests__/identify-test.js +++ b/web/client/api/__tests__/identify-test.js @@ -10,7 +10,7 @@ import expect from 'expect'; import MockAdapter from "axios-mock-adapter"; import axios from "../../libs/ajax"; -import { getFeatureInfo } from '../identify'; +import { getFeatureInfo, getFeatureInfoForViews } from '../identify'; let mockAxios; @@ -260,4 +260,143 @@ describe('identify API', () => { }); }); + describe('getFeatureInfoForViews', () => { + const VIEWS_LAYER = { + type: "wms", + name: "test_layer", + url: "TEST_URL", + featureInfo: { + views: [ + { id: 'properties', type: 'PROPERTIES' }, + { id: 'template', type: 'TEMPLATE' }, + { id: 'html', type: 'HTML' } + ] + } + }; + const IDENTIFY_OPTIONS = { + map: { zoom: 0, projection: 'EPSG:4326' }, + point: { latlng: { lat: 0, lng: 0 } } + }; + const mockInfoFormats = (failing = []) => { + mockAxios = new MockAdapter(axios); + mockAxios.onGet().reply((req) => { + if (failing.includes(req.params.info_format)) { + return [500, "ERROR"]; + } + switch (req.params.info_format) { + case INFO_FORMATS.HTML: + return [200, WMS_HTML]; + case INFO_FORMATS.JSON: + return [200, WMS_JSON]; + default: + return [404, "NOT FOUND"]; + } + }); + }; + afterEach((done) => { + if (mockAxios) { + mockAxios.restore(); + } + mockAxios = null; + setTimeout(done); + }); + it('groups the responses by view and shares the deduplicated request', (done) => { + mockInfoFormats(); + getFeatureInfoForViews(VIEWS_LAYER, IDENTIFY_OPTIONS).subscribe( + ({ views, viewResponses, features, primaryResponse, error }) => { + try { + expect(error).toNotExist(); + expect(views.length).toBe(3); + expect(Object.keys(viewResponses).sort()).toEqual(['html', 'properties', 'template']); + expect(viewResponses.properties.queryParams.info_format).toBe(INFO_FORMATS.JSON); + expect(viewResponses.template.queryParams.info_format).toBe(INFO_FORMATS.JSON); + expect(viewResponses.html.queryParams.info_format).toBe(INFO_FORMATS.HTML); + expect(viewResponses.properties.response).toBe(viewResponses.template.response); + expect(viewResponses.html.response.indexOf('= 0).toBeTruthy(); + expect(features).toBeTruthy(); + expect(primaryResponse.response.features).toBeTruthy(); + done(); + } catch (ex) { + done(ex); + } + }, + error => done(error) + ); + }); + it('keeps the views that succeeded when one request fails', (done) => { + mockInfoFormats([INFO_FORMATS.HTML]); + getFeatureInfoForViews(VIEWS_LAYER, IDENTIFY_OPTIONS).subscribe( + ({ views, viewResponses, error }) => { + try { + expect(error).toNotExist(); + expect(views.length).toBe(3); + expect(Object.keys(viewResponses).sort()).toEqual(['properties', 'template']); + done(); + } catch (ex) { + done(ex); + } + }, + error => done(error) + ); + }); + it('returns the error when every request fails', (done) => { + mockInfoFormats([INFO_FORMATS.HTML, INFO_FORMATS.JSON]); + getFeatureInfoForViews(VIEWS_LAYER, IDENTIFY_OPTIONS).subscribe( + ({ views, viewResponses, error }) => { + try { + expect(views.length).toBe(3); + expect(viewResponses).toNotExist(); + expect(error).toExist(); + done(); + } catch (ex) { + done(ex); + } + }, + error => done(error) + ); + }); + it('returns null when the layer has no request to perform', () => { + expect(getFeatureInfoForViews({ + ...VIEWS_LAYER, + featureInfo: { ...VIEWS_LAYER.featureInfo, disabled: true } + }, IDENTIFY_OPTIONS)).toBe(null); + }); + // these fields are the ones withCarouselMarkerInteraction reads + it('keeps the flat vector response used to resolve a clicked carousel marker', (done) => { + const feature = { + type: 'Feature', + geometry: null, + properties: { sectionId: 'section-1', contentId: 'content-1', title: 'Marker' } + }; + getFeatureInfoForViews({ + id: 'vector-layer', + type: 'vector', + name: 'vector_layer', + features: [feature] + }, { + format: INFO_FORMATS.JSON, + map: { zoom: 0, projection: 'EPSG:4326' }, + point: { + latlng: { lat: 0, lng: 0 }, + intersectedFeatures: [{ id: 'vector-layer', features: [feature] }] + } + }).subscribe( + ({ layerMetadata, viewResponses, primaryResponse, error }) => { + try { + expect(error).toNotExist(); + expect(layerMetadata.layerId).toBe('vector-layer'); + expect(primaryResponse.response.features.length).toBe(1); + expect(primaryResponse.response.features[0].properties.sectionId).toBe('section-1'); + expect(primaryResponse.queryParams.request).toNotExist(); + expect(Object.keys(viewResponses).length).toBe(1); + done(); + } catch (ex) { + done(ex); + } + }, + error => done(error) + ); + }); + }); + }); diff --git a/web/client/api/catalog/WMS.js b/web/client/api/catalog/WMS.js index f95a84bf014..0e67dba3716 100644 --- a/web/client/api/catalog/WMS.js +++ b/web/client/api/catalog/WMS.js @@ -87,9 +87,16 @@ const recordToLayer = (record, { const format = supportedGetMapFormats?.find((value) => value === defaultFormat) || supportedGetMapFormats[0] || defaultFormat; - const featureInfo = infoFormat && INFO_FORMATS_BY_MIME_TYPE[infoFormat] + const { featureInfo: serviceFeatureInfo, ...serviceLayerOptions } = layerOptions || {}; + const { featureInfo: recordFeatureInfo, ...recordLayerOptions } = record.layerOptions || {}; + const computedFeatureInfo = infoFormat && INFO_FORMATS_BY_MIME_TYPE[infoFormat] ? { format: INFO_FORMATS_BY_MIME_TYPE[infoFormat] } - : null; + : {}; + const featureInfo = { + ...computedFeatureInfo, + ...(serviceFeatureInfo || {}), + ...(recordFeatureInfo || {}) + }; let security; if (service?.protectedId) { security = {sourceId: service?.protectedId, type: "basic"}; @@ -99,7 +106,7 @@ const recordToLayer = (record, { requestEncoding: record.requestEncoding, // WMTS KVP vs REST, KVP by default style: record.style, format, - featureInfo: featureInfo, + featureInfo: isEmpty(featureInfo) ? null : featureInfo, url: layerURL, capabilitiesURL: record.capabilitiesURL, queryable: record.queryable, @@ -124,8 +131,8 @@ const recordToLayer = (record, { allowedSRS: allowedSRS, catalogURL, ...layerBaseConfig, - ...layerOptions, - ...record.layerOptions, + ...serviceLayerOptions, + ...recordLayerOptions, localizedLayerStyles: !isNil(localizedLayerStyles) ? localizedLayerStyles : undefined, imageFormats: supportedGetMapFormats, infoFormats: supportedGetFeatureInfoFormats, diff --git a/web/client/api/catalog/__tests__/WMS-test.js b/web/client/api/catalog/__tests__/WMS-test.js index 16e4e3fcc10..2bcd997a3e5 100644 --- a/web/client/api/catalog/__tests__/WMS-test.js +++ b/web/client/api/catalog/__tests__/WMS-test.js @@ -100,6 +100,33 @@ describe('Test correctness of the WMS APIs', () => { expect(layer.serverType).toBe("no-vendor"); }); + it('wms feature info layer options preserve catalog info format', () => { + const records = getCatalogRecords({ + records: [{}] + }, { + url: 'http://sample' + }); + expect(records.length).toBe(1); + const layer = getLayerFromRecord(records[0], { + service: { + infoFormat: 'text/html', + layerOptions: { + serverType: "geoserver", + featureInfo: { + maxItems: 20, + buffer: 4 + } + } + } + }); + expect(layer.serverType).toBe("geoserver"); + expect(layer.featureInfo).toEqual({ + format: "HTML", + maxItems: 20, + buffer: 4 + }); + }); + it('wms layer with visibility limits', () => { const records = getCatalogRecords({ records: [{ @@ -222,4 +249,3 @@ describe('Test correctness of the WMS APIs', () => { expect(layer.forceProxy).toBeTruthy(); }); }); - diff --git a/web/client/api/identify.jsx b/web/client/api/identify.jsx index 7fd522f45ed..e29affa8f71 100644 --- a/web/client/api/identify.jsx +++ b/web/client/api/identify.jsx @@ -8,7 +8,7 @@ import { isString, isNil } from 'lodash'; import { Observable } from 'rxjs'; -import {getIdentifyFlow, isDataFormat} from '../utils/MapInfoUtils'; +import {buildIdentifyRequest, buildIdentifyRequestPlan, getIdentifyFlow, isDataFormat} from '../utils/MapInfoUtils'; import axios from '../libs/ajax'; import {parseURN} from '../utils/CoordinatesUtils'; import { GEOJSON_MIME_TYPE, JSON_MIME_TYPE } from '../utils/FeatureInfoUtils'; @@ -59,3 +59,64 @@ export const getFeatureInfo = (basePath, param, layer, {attachJSON, itemId = nul })) ); }; + +const associateResponsesToViews = (responses) => responses.reduce((viewResponses, { response, requestParams, viewIds = [] }) => { + viewIds.forEach((viewId) => { + viewResponses[viewId] = { + response: response.data, + queryParams: requestParams + }; + }); + return viewResponses; +}, {}); + +/** + * Runs the identify requests needed by the views configured on a layer, grouping the responses by view id. + * @param {object} layer the layer object + * @param {object} identifyOptions options used to build the requests (map, point, format, env) + * @param {object} options + * @param {object} options.params params applied to every request + * @param {object} options.requestOptions options forwarded to `getFeatureInfo` + * @param {function} options.mapRequestParams overrides the params of every request + * @return {Observable|null} null when the layer has no request to perform + */ +export const getFeatureInfoForViews = (layer, identifyOptions, { + params = {}, + requestOptions = {}, + mapRequestParams = (requestParams) => requestParams +} = {}) => { + const { views, requests } = buildIdentifyRequestPlan(layer, identifyOptions); + if (!requests.length) { + return null; + } + // metadata belongs to the layer, not to one of its view requests + const { metadata: layerMetadata = {} } = buildIdentifyRequest(layer, identifyOptions); + return Observable.forkJoin(requests.map(({ url, request, viewIds }) => { + const requestParams = mapRequestParams(request); + return getFeatureInfo(url, { ...params, ...requestParams }, layer, requestOptions) + // vector/3dtiles responses are synchronous, the delay lets the panel render its spinner + // and avoids freezing the app when many layers are queried at once + .delay(0) + .map((response) => ({ response, requestParams, viewIds })) + .catch((error) => Observable.of({ error, requestParams, viewIds })); + })).map((results) => { + const responses = results.filter(({ error }) => !error); + if (!responses.length) { + return { views, layerMetadata, error: results.find(({ error }) => error)?.error }; + } + const featureResponse = responses.find(({ response }) => response.features?.length) + || responses.find(({ response }) => response.features) + || responses[0]; + return { + views, + layerMetadata, + viewResponses: associateResponsesToViews(responses), + features: featureResponse.response.features, + featuresCrs: featureResponse.response.featuresCrs, + primaryResponse: { + response: featureResponse.response.data, + queryParams: featureResponse.requestParams + } + }; + }); +}; diff --git a/web/client/components/I18N/Message.jsx b/web/client/components/I18N/Message.jsx index 1a4c684e52a..473aa68ffe6 100644 --- a/web/client/components/I18N/Message.jsx +++ b/web/client/components/I18N/Message.jsx @@ -23,12 +23,13 @@ class Message extends React.Component { }; renderFormattedMsg = ({msgId, msgParams, children}) => { + const values = msgParams || undefined; if (children && typeof children === 'function') { - return ({msg => { + return ({msg => { return children(msg); }}); } - return (); + return (); } renderMsg = ({msgId, children}) => { diff --git a/web/client/components/TOC/enhancers/tocItemsSettings.js b/web/client/components/TOC/enhancers/tocItemsSettings.js index c00642303dc..802233a8d13 100644 --- a/web/client/components/TOC/enhancers/tocItemsSettings.js +++ b/web/client/components/TOC/enhancers/tocItemsSettings.js @@ -7,18 +7,7 @@ */ import { isArray, isFunction, isNil } from 'lodash'; -import { compose, lifecycle, withHandlers, withState } from 'recompose'; - -/** - * Enhancer for settings state needed in TOCItemsSettings plugin - * - onShowAlertModal, alert modal appears on close in case of changes in TOCItemsSettings - * - onShowEditor, edit modal appears on format info in TOCItemsSettings - * @memberof enhancers.settingsState - * @class - */ -export const settingsState = compose( - withState('showEditor', 'onShowEditor', false) -); +import { compose, lifecycle, withHandlers } from 'recompose'; /** * Basic toc settings lificycle used in TOCItemsSettings plugin with TOCItemsSettings component @@ -76,12 +65,10 @@ export const settingsLifecycle = compose( ); export const updateSettingsLifecycle = compose( - settingsState, settingsLifecycle ); export default { - settingsState, settingsLifecycle, /** * Enhancer for compose together settings lifecycle and state diff --git a/web/client/components/TOC/fragments/LayerFields/Fields.jsx b/web/client/components/TOC/fragments/LayerFields/Fields.jsx index b466dc07837..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 } from 'react-bootstrap'; +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,8 +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); @@ -29,17 +35,37 @@ const isGeometryType = (type) => * - type: the type of the field * @prop {function} onLoadFields callback to reload the fields of the layer (for instance in case of WFS it will perform a new DescribeFeatureType request to reload the fields) * @prop {function} onChange callback to be called when the alias of a field is changed. The arguments are the `name` of the field, the property changed and the new value. For instance `onChange("NAME", "alias", "new alias")` + * @prop {function} onChangeAll callback to be called when the same property is set on every field. For instance `onChangeAll("visible", false)` * @prop {function} onClear callback to be called when the customization of the fields is cleared * @prop {boolean} loading true if the fields are loading * @prop {boolean} error true if there is an error loading the fields * @prop {string} currentLocale the current locale (for instance "en-US") used to show the localized alias * @name Fields */ -const Fields = ({fields = [], onLoadFields = () => {}, onChange = () => {}, onClear = () => {}, loading, error, currentLocale }) => { +const Fields = ({ + fields = [], + onLoadFields = () => {}, + onChange = () => {}, + onChangeAll = () => {}, + onClear = () => {}, + loading, + error, + currentLocale, + title, + 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} {}, onChange = () => {}, onCl />
+ {showVisibility ? + { + if (input) { + input.indeterminate = visibleCount > 0 && visibleCount < displayedFields.length; + } + }} + onChange={(event) => onChangeAll("visible", event.target.checked)}/> + : null} - + {showFieldSettings ? null : - + } + {showFieldSettings ? : null}
} footer={
@@ -80,19 +120,66 @@ const Fields = ({fields = [], onLoadFields = () => {}, onChange = () => {}, onCl {error && }
} > - {fields - .filter(({type}) => !isGeometryType(type)) // exclude geometry fields - .map(({name, alias, type}) => { - return (
- - - - - onChange(name, "alias", value)} value={alias} currentLocale={currentLocale} /> - - - - + {displayedFields + .map((field) => { + const { name, alias, type, visible, displayType, mediaTypeAttribute } = field; + const isConfigured = configuredFields.includes(name); + return (
+
+ {showVisibility ? + onChange(name, "visible", event.target.checked)}/> + : null} + + + + {showFieldSettings ? null : + onChange(name, "alias", value)} value={alias} currentLocale={currentLocale} /> + } + + + + {showFieldSettings ? + toggleFieldSettings(name)}> + + + : null} +
+ {showFieldSettings && isConfigured ?
+
+ + onChange(name, "alias", value)} value={alias} currentLocale={currentLocale} /> +
+
+ + optionName !== name).map(({ name: optionName, alias: optionAlias }) => ({ + value: optionName, + label: extractLocalizedString(optionAlias, currentLocale) || optionName + }))} + onChange={(selected) => onChange(name, 'mediaTypeAttribute', selected?.value)}/> +
: null} +
: null}
); })} @@ -103,9 +190,14 @@ 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, + 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 1d66eff1997..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 = { @@ -139,4 +140,97 @@ 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); + }); + 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 new file mode 100644 index 00000000000..051e914f507 --- /dev/null +++ b/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx @@ -0,0 +1,438 @@ +/* + * 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 { + ...previous, + 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} +
+ + + * + { + this.updateView(view.id, { type: selected?.value }); + this.setState({ + editingViewId: [EXTERNAL_DATA, 'PROPERTIES'].includes(selected?.value) + ? view.id + : null + }); + }}/> + ); + } + + renderTypeOption = (option) => { + return ( + +  {option.label} + ); + } + + renderView = (view, views, index, isDisabled) => { + const canEdit = ['TEMPLATE', 'PROPERTIES', EXTERNAL_DATA].includes(view.type); + return ( +
+ 1} + isEditing={[EXTERNAL_DATA, 'PROPERTIES'].includes(view.type) + && this.state.editingViewId === view.id} + isInvalid={view.type === EXTERNAL_DATA && !!validateExternalDataConfiguration(view.featuresService)} + view={view} + views={views} + canEdit={canEdit} + onEdit={(viewId) => this.setState(({ editingViewId }) => ({ + editingViewId: editingViewId === viewId ? null : viewId + }))} + onRemove={this.removeView} + onUpdateView={this.updateView} + onMove={this.reorderView} + renderTypeSelect={(featureInfoView) => this.renderTypeSelect(featureInfoView, isDisabled)}/> + {/* Structured views use inline editors; templates keep their existing editor below the list. */} + {!isDisabled && this.state.editingViewId === view.id && view.type === EXTERNAL_DATA ? ( + this.updateView(view.id, { featuresService })}/> + ) : null} + {!isDisabled && this.state.editingViewId === view.id && view.type === 'PROPERTIES' ? ( + this.updateView(view.id, { attributes })}/> + ) : null} +
+ ); + } + + render() { + const disabled = isLayerFeatureInfoDisabled(this.props.element); + const views = this.getViews(); + const editingView = views.find((view) => view.id === this.state.editingViewId); return this.state.loading ? (
) : ( - - { - const isEqualFormat = this.props.element.featureInfo && this.props.element.featureInfo.format && value === this.props.element.featureInfo.format; - this.props.onChange("featureInfo", { - ...(this.props.element && this.props.element.featureInfo || {}), - format: !isEqualFormat ? value : '', - viewer: this.props.element.featureInfo ? this.props.element.featureInfo.viewer : undefined - }); - }}/> + + {this.props.element.type === 'wms' ? ( +
+ this.props.onChange("featureInfo", featureInfo)} /> +
+ ) : null} +
+ this.updateFeatureInfo(event.target.checked, views)}> + + + +
+
+ {views.length === 0 ? ( +
+ +
+ ) : null} + {views.map((view, index) => this.renderView(view, views, index, disabled))} + {!disabled && editingView?.type === 'TEMPLATE' ? ( + this.setState({ editingViewId: null })} + onSaveTemplate={(template) => { + this.updateView(editingView.id, { template }); + this.setState({ editingViewId: null }); + }}/> + ) : null} +
); } diff --git a/web/client/components/TOC/fragments/settings/FeatureInfoEditor.jsx b/web/client/components/TOC/fragments/settings/FeatureInfoEditor.jsx index 2a1b20eb9f2..429e9fb07b8 100644 --- a/web/client/components/TOC/fragments/settings/FeatureInfoEditor.jsx +++ b/web/client/components/TOC/fragments/settings/FeatureInfoEditor.jsx @@ -24,18 +24,18 @@ const DescriptionEditor = withDebounceOnCallback('onEditorStateChange', 'editorS * @memberof components.TOC.fragments.settings * @name FeatureInfoEditor * @class - * @prop {object} element data of the current selected node + * @prop {string} template the template to edit * @prop {boolean} showEditor show/hide modal * @prop {function} onShowEditor called when click on close buttons - * @prop {function} onChange called when text in editor has been changed + * @prop {function} onSaveTemplate called with the edited template on close * @prop {boolean} enableIFrameModule enable iframe in editor, default true */ const FeatureInfoEditor = ({ - element, + template: templateProp, showEditor, onShowEditor, - onChange, + onSaveTemplate, enableIFrameModule }, {messages}) => { const [, setCounter] = useState(0); @@ -57,14 +57,12 @@ const FeatureInfoEditor = ({ }, [showEditor]); - const [template, setTemplate] = useState(element?.featureInfo?.template || ''); + const [template, setTemplate] = useState(templateProp ?? ''); const [editorState, setEditorState] = useState(htmlToDraftJSEditorState(template)); const onClose = () => { onShowEditor(!showEditor); - onChange('featureInfo', { - ...(element && element.featureInfo || {}), - template: draftJSEditorStateToHtml(editorState) - }); + const html = draftJSEditorStateToHtml(editorState); + onSaveTemplate(html); }; const imageField = document.querySelector(".rdw-image-modal-url-section"); return ( @@ -117,8 +115,8 @@ const FeatureInfoEditor = ({ FeatureInfoEditor.propTypes = { showEditor: PropTypes.bool, - element: PropTypes.object, - onChange: PropTypes.func, + template: PropTypes.string, + onSaveTemplate: PropTypes.func.isRequired, onShowEditor: PropTypes.func, enableIFrameModule: PropTypes.bool }; @@ -128,9 +126,7 @@ FeatureInfoEditor.contextTypes = { FeatureInfoEditor.defaultProps = { showEditor: false, - element: {}, enableIFrameModule: false, - onChange: () => {}, onShowEditor: () => {} }; diff --git a/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx b/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx new file mode 100644 index 00000000000..c5ac20299f1 --- /dev/null +++ b/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx @@ -0,0 +1,138 @@ +/* + * 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, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Alert } from 'react-bootstrap'; + +import Message from '../../../I18N/Message'; +import useIsMounted from '../../../../hooks/useIsMounted'; +import Fields from '../LayerFields/Fields'; +import { describeFeatureType } from '../../../../observables/wfs'; +import { isGeometryType } from '../../../../utils/ogc/WFS/base'; +import { notPrimaryGeometryFields } from '../../../../utils/FeatureTypeUtils'; + +const EMPTY_FIELDS = []; +const GEOMETRY_FIELD_TYPES = new Set(['Geometry', ...Object.values(notPrimaryGeometryFields)]); + +const isGeometryField = (field = {}) => { + 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 + showFieldSettings + 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..210af1d2230 --- /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 { waitFor } from '@testing-library/react'; + +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 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'), { + target: { checked: false } + }); + expect(value.attributes[0].visible).toBe(false); + }); + + 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'); + return [200, getCapabilitiesResponse('workspace:table', 'Table')]; + }); + ReactDOM.render( + , + document.getElementById('container') + ); + + 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) => { + 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 aa3ddf35c1b..2e79884a164 100644 --- a/web/client/components/TOC/fragments/settings/__tests__/FeatureInfo-test.jsx +++ b/web/client/components/TOC/fragments/settings/__tests__/FeatureInfo-test.jsx @@ -10,6 +10,8 @@ import expect from 'expect'; import React from 'react'; import ReactDOM from 'react-dom'; import TestUtils from 'react-dom/test-utils'; +import { DragDropContext as dragDropContext } from 'react-dnd'; +import testBackend from 'react-dnd-test-backend'; import {getAvailableInfoFormat} from '../../../../../utils/MapInfoUtils'; import FeatureInfo from '../FeatureInfo'; @@ -19,38 +21,33 @@ import MockAdapter from 'axios-mock-adapter'; let mockAxios; const defaultInfoFormat = getAvailableInfoFormat(); +const DndFeatureInfo = dragDropContext(testBackend)(FeatureInfo); +const getFeatureInfoInstance = (props = {}) => TestUtils.findRenderedComponentWithType( + ReactDOM.render(, document.getElementById("container")), + FeatureInfo +); const formatCards = { - HIDDEN: { - titleId: 'layerProperties.hideFormatTitle', - descId: 'layerProperties.hideFormatDescription', - glyph: 'hide-marker', - body: () =>
- }, TEXT: { titleId: 'layerProperties.textFormatTitle', - descId: 'layerProperties.textFormatDescription', - glyph: 'ext-txt', - body: () =>
+ glyph: 'ext-txt' }, HTML: { titleId: 'layerProperties.htmlFormatTitle', - descId: 'layerProperties.htmlFormatDescription', - glyph: 'ext-html', - body: () =>
+ glyph: 'ext-html' }, PROPERTIES: { titleId: 'layerProperties.propertiesFormatTitle', - descId: 'layerProperties.propertiesFormatDescription', - glyph: 'ext-json', - body: () =>
+ glyph: 'ext-json' }, TEMPLATE: { titleId: 'layerProperties.templateFormatTitle', - descId: 'layerProperties.templateFormatDescription', - glyph: 'ext-empty', - body: () =>
+ glyph: 'ext-empty' + }, + EXTERNAL_DATA: { + titleId: 'layerProperties.externalData.title', + glyph: 'ext-json' } }; @@ -69,84 +66,220 @@ describe("test FeatureInfo", () => { }); it('test rendering', () => { - ReactDOM.render(, document.getElementById("container")); - const testComponent = document.getElementsByClassName('test-preview'); - expect(testComponent.length).toBe(5); + getFeatureInfoInstance({ + element: { + featureInfo: { + views: [{ id: 'text-view', title: 'Text identify', type: 'TEXT' }] + } + }, + formatCards, + defaultInfoFormat + }); + const views = document.querySelectorAll('[data-id^="feature-info-view-"]'); + expect(views.length).toBe(1); + expect(views[0].querySelector('.ms-feature-info-view-title')).toExist(); const modalEditor = document.getElementsByClassName('ms-resizable-modal'); expect(modalEditor.length).toBe(0); }); - it('test changes on click', done => { - ReactDOM.render( { - expect(key).toBe('featureInfo'); - expect(value.format).toBe('HIDDEN'); - done(); - }} formatCards={formatCards} defaultInfoFormat={defaultInfoFormat}/>, document.getElementById("container")); - const testComponent = document.getElementsByClassName('test-preview'); - expect(testComponent.length).toBe(5); - const sideCards = document.getElementsByClassName('mapstore-side-card'); - expect(sideCards.length).toBe(5); - TestUtils.Simulate.click(sideCards[0]); + it('should not configure any view when the layer has no featureInfo', () => { + getFeatureInfoInstance({formatCards, defaultInfoFormat}); + expect(document.querySelectorAll('[data-id^="feature-info-view-"]').length).toBe(0); + expect(document.querySelector('.ms-feature-info-views-empty')).toExist(); + }); + + it('should allow to remove the last view to fall back on the map settings format', done => { + const component = getFeatureInfoInstance({ + element: { + featureInfo: { + views: [{ id: 'text-view', title: 'Text identify', type: 'TEXT' }] + } + }, + onChange: (key, value) => { + expect(key).toBe('featureInfo'); + expect(value.views).toEqual([]); + done(); + }, + formatCards, + defaultInfoFormat + }); + const removeButton = document.querySelector('.ms-feature-info-view-remove'); + expect(removeButton.disabled).toBe(false); + component.removeView('text-view'); + }); + + it('should display configured views as disabled when identify is disabled', () => { + getFeatureInfoInstance({ + element: { + featureInfo: { + disabled: true, + views: [ + { id: 'text-view', title: 'Text identify', type: 'TEXT' }, + { id: 'properties-view', title: 'Properties identify', type: 'PROPERTIES' } + ] + } + }, + formatCards, + defaultInfoFormat + }); + + const views = document.querySelectorAll('[data-id^="feature-info-view-"]'); + expect(views.length).toBe(2); + expect(views[0].classList.contains('disabled')).toBe(true); + expect(views[0].querySelector('.ms-feature-info-view-title').disabled).toBe(true); + expect(views[0].querySelector('.Select').classList.contains('is-disabled')).toBe(true); + expect([...views[0].querySelectorAll('button')].every((button) => button.disabled)).toBe(true); + expect(document.querySelector('.btn-primary').disabled).toBe(true); + }); + + it('updates the featureInfo view configuration', done => { + const component = getFeatureInfoInstance({ + element: { + featureInfo: { + views: [{ id: 'text-view', title: 'Text identify', type: 'TEXT' }] + } + }, + onChange: (key, value) => { + expect(key).toBe('featureInfo'); + expect(value.disabled).toBe(true); + expect(value.views.length).toBe(1); + done(); + }, + formatCards, + defaultInfoFormat + }); + component.updateFeatureInfo(true, component.getViews()); + }); + it('drops the legacy format, template and viewer when saving views', done => { + const component = getFeatureInfoInstance({ + element: { + featureInfo: { + format: 'TEMPLATE', + template: '

${properties.NAME}

', + viewer: { type: 'customViewer' }, + featureInfoRegex: ']*>' + } + }, + onChange: (key, value) => { + expect(key).toBe('featureInfo'); + expect(value.format).toNotExist(); + expect(value.template).toNotExist(); + expect(value.viewer).toNotExist(); + expect(value.featureInfoRegex).toBe(']*>'); + expect(value.views.length).toBe(1); + done(); + }, + formatCards, + defaultInfoFormat + }); + component.updateFeatureInfo(false, component.getViews()); }); it('test rendering with supported infoFormats from layer props', () => { - ReactDOM.render(, document.getElementById("container")); - const testComponent = document.getElementsByClassName('test-preview'); - expect(testComponent.length).toBe(3); + const component = getFeatureInfoInstance({element: {infoFormats: ["text/html", "text/plain"]}, formatCards, defaultInfoFormat}); + expect(component.getTypeOptions()).toEqual(['TEXT', 'HTML']); }); it('test rendering supported infoFormats for wfs layer', () => { - ReactDOM.render(, document.getElementById("container")); - const testComponents = document.getElementsByClassName('test-preview'); - expect(testComponents.length).toBe(3); - const sideCards = document.querySelectorAll('.mapstore-side-card-title span span'); - expect(sideCards.length).toBe(3); - expect(sideCards[0].textContent).toBe('layerProperties.hideFormatTitle'); - expect(sideCards[1].textContent).toBe('layerProperties.propertiesFormatTitle'); - expect(sideCards[2].textContent).toBe('layerProperties.templateFormatTitle'); + const component = getFeatureInfoInstance({element: {type: "wfs"}, formatCards, defaultInfoFormat}); + expect(component.getTypeOptions()).toEqual(['PROPERTIES', 'TEMPLATE', 'EXTERNAL_DATA']); }); it('test rendering supported infoFormats for wfs layer with only application/json', () => { - ReactDOM.render( { + const component = getFeatureInfoInstance({element: {type: "wfs", infoFormats: ["application/json", "text/html"]}, formatCards, defaultInfoFormat}); + 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', '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 => { + ReactDOM.render(${properties.name}

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

${properties.name}

", + maxItems: 25 + }); + done(); + } catch (e) { + done(e); + } + }} formatCards={formatCards} defaultInfoFormat={defaultInfoFormat} />, document.getElementById("container")); - const testComponents = document.getElementsByClassName('test-preview'); - expect(testComponents.length).toBe(3); - const sideCards = document.querySelectorAll('.mapstore-side-card-title span span'); - expect(sideCards.length).toBe(3); - expect(sideCards[0].textContent).toBe('layerProperties.hideFormatTitle'); - expect(sideCards[1].textContent).toBe('layerProperties.propertiesFormatTitle'); - expect(sideCards[2].textContent).toBe('layerProperties.templateFormatTitle'); + expect(document.querySelector('[data-qa="feature-info-buffer"]')).toBeTruthy(); + expect(document.querySelector('[data-qa="feature-info-buffer"]').max).toBe('1000'); + expect(document.querySelector('[data-qa="feature-info-max-items"]').value).toBe('10'); + TestUtils.Simulate.change(document.querySelector('[data-qa="feature-info-max-items"]'), {target: {value: "25"}}); }); - it('test rendering supported infoFormats for wfs layer with application/json and text/html', () => { - ReactDOM.render(