Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/developer-guide/local-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
28 changes: 28 additions & 0 deletions docs/developer-guide/mapstore-migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<p>${properties.NAME}</p>" }
]
}
}
```

The previous configuration is still read, so existing maps keep working. `format: "HIDDEN"` is equivalent to `disabled: true`.

```json
{ "featureInfo": { "format": "TEMPLATE", "template": "<p>${properties.NAME}</p>" } }
```

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
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
53 changes: 38 additions & 15 deletions web/client/actions/__tests__/mapInfo-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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: '<p>Feature 1</p>',
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('<p>Feature 1</p>');
});
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();
Expand Down
22 changes: 9 additions & 13 deletions web/client/actions/mapInfo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand All @@ -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
};
}

Expand Down Expand Up @@ -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
};
}

Expand Down
141 changes: 140 additions & 1 deletion web/client/api/__tests__/identify-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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('<html') >= 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)
);
});
});

});
Loading
Loading