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 ({validation.cqlFilter}
+