) : (
- {
- 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.updateFeatureInfo(event.target.checked, views)}>
+
+
+
+
+
+ {views.map((view, index) => this.renderView(view, views, index, disabled))}
+ {!disabled && editingView ? (
+ 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..ec78a09f342 100644
--- a/web/client/components/TOC/fragments/settings/FeatureInfoEditor.jsx
+++ b/web/client/components/TOC/fragments/settings/FeatureInfoEditor.jsx
@@ -33,9 +33,10 @@ const DescriptionEditor = withDebounceOnCallback('onEditorStateChange', 'editorS
const FeatureInfoEditor = ({
element,
+ template: templateProp,
showEditor,
onShowEditor,
- onChange,
+ onSaveTemplate,
enableIFrameModule
}, {messages}) => {
const [, setCounter] = useState(0);
@@ -57,14 +58,12 @@ const FeatureInfoEditor = ({
}, [showEditor]);
- const [template, setTemplate] = useState(element?.featureInfo?.template || '');
+ const [template, setTemplate] = useState(templateProp !== undefined ? templateProp : element?.featureInfo?.template || '');
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 (
@@ -118,7 +117,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
};
@@ -130,7 +130,6 @@ FeatureInfoEditor.defaultProps = {
showEditor: false,
element: {},
enableIFrameModule: false,
- onChange: () => {},
onShowEditor: () => {}
};
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..10eea23fa9c 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,6 +21,11 @@ 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 = {
@@ -69,84 +76,69 @@ describe("test FeatureInfo", () => {
});
it('test rendering', () => {
- ReactDOM.render(
, document.getElementById("container"));
- const testComponent = document.getElementsByClassName('test-preview');
- expect(testComponent.length).toBe(5);
+ getFeatureInfoInstance({formatCards, defaultInfoFormat});
+ const views = document.querySelectorAll('[data-id^="feature-info-view-"]');
+ expect(views.length).toBe(1);
+ expect(views[0].querySelector('input[placeholder="Title"]')).toExist();
const modalEditor = document.getElementsByClassName('ms-resizable-modal');
expect(modalEditor.length).toBe(0);
});
- it('test changes on click', done => {
- ReactDOM.render(
{
+ it('should display configured views as disabled when identify is disabled', () => {
+ ReactDOM.render(, document.getElementById("container"));
+
+ const views = document.querySelectorAll('[data-id^="feature-info-view-"]');
+ expect(views.length).toBe(2);
+ expect(views[0].style.opacity).toBe('0.5');
+ expect(views[0].querySelector('input[placeholder="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({onChange: (key, value) => {
expect(key).toBe('featureInfo');
- expect(value.format).toBe('HIDDEN');
+ expect(value.disabled).toBe(true);
+ expect(value.views.length).toBe(1);
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]);
+ }, formatCards, defaultInfoFormat});
+ component.updateFeatureInfo(true, 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']);
});
it('test rendering supported infoFormats for wfs layer with only application/json', () => {
- 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", infoFormats: ["application/json"]}, formatCards, defaultInfoFormat});
+ expect(component.getTypeOptions()).toEqual(['PROPERTIES', 'TEMPLATE']);
});
it('test rendering supported infoFormats for wfs layer with application/json and text/html', () => {
- ReactDOM.render(, document.getElementById("container"));
- const testComponents = document.getElementsByClassName('test-preview');
- expect(testComponents.length).toBe(4);
- const sideCards = document.querySelectorAll('.mapstore-side-card-title span span');
- expect(sideCards.length).toBe(4);
- expect(sideCards[0].textContent).toBe('layerProperties.hideFormatTitle');
- expect(sideCards[1].textContent).toBe('layerProperties.htmlFormatTitle');
- expect(sideCards[2].textContent).toBe('layerProperties.propertiesFormatTitle');
- expect(sideCards[3].textContent).toBe('layerProperties.templateFormatTitle');
+ const component = getFeatureInfoInstance({element: {type: "wfs", infoFormats: ["application/json", "text/html"]}, formatCards, defaultInfoFormat});
+ expect(component.getTypeOptions()).toEqual(['HTML', 'PROPERTIES', 'TEMPLATE']);
});
it('test rendering supported infoFormats for wms layer', () => {
- ReactDOM.render(, document.getElementById("container"));
- const testComponent = document.getElementsByClassName('test-preview');
- expect(testComponent.length).toBe(5);
- const sideCards = document.querySelectorAll('.mapstore-side-card-title span span');
- expect(sideCards.length).toBe(5);
- expect(sideCards[0].textContent).toBe('layerProperties.hideFormatTitle');
- expect(sideCards[1].textContent).toBe('layerProperties.textFormatTitle');
- expect(sideCards[2].textContent).toBe('layerProperties.htmlFormatTitle');
- expect(sideCards[3].textContent).toBe('layerProperties.propertiesFormatTitle');
- expect(sideCards[4].textContent).toBe('layerProperties.templateFormatTitle');
+ const component = getFeatureInfoInstance({element: {type: "wms"}, formatCards, defaultInfoFormat});
+ expect(component.getTypeOptions()).toEqual(['TEXT', 'HTML', 'PROPERTIES', 'TEMPLATE']);
});
it('should request WMS GetCapabilities for wms layers', (done) => {
@@ -160,7 +152,7 @@ describe("test FeatureInfo", () => {
done();
return [200, {}];
});
- ReactDOM.render(, document.getElementById("container"));
const testComponents = document.getElementsByClassName('test-preview');
@@ -178,7 +170,7 @@ describe("test FeatureInfo", () => {
done();
return [200, {}];
});
- ReactDOM.render(, document.getElementById("container"));
const testComponents = document.getElementsByClassName('test-preview');
diff --git a/web/client/components/TOC/fragments/settings/__tests__/FeatureInfoEditor-test.jsx b/web/client/components/TOC/fragments/settings/__tests__/FeatureInfoEditor-test.jsx
index 5f4f2ecf177..afa0b756f16 100644
--- a/web/client/components/TOC/fragments/settings/__tests__/FeatureInfoEditor-test.jsx
+++ b/web/client/components/TOC/fragments/settings/__tests__/FeatureInfoEditor-test.jsx
@@ -9,6 +9,7 @@
import expect from 'expect';
import React from 'react';
import ReactDOM from 'react-dom';
+import TestUtils from 'react-dom/test-utils';
import FeatureInfoEditor from '../FeatureInfoEditor';
@@ -25,9 +26,24 @@ describe("test FeatureInfoEditor", () => {
});
it('test rendering edit modal', () => {
- ReactDOM.render(, document.getElementById("container"));
+ ReactDOM.render( {}}/>, document.getElementById("container"));
const modalEditor = document.getElementsByClassName('ms-resizable-modal');
expect(modalEditor.length).toBe(1);
});
+ it('saves the template when closing the editor', () => {
+ const onSaveTemplate = expect.createSpy();
+ ReactDOM.render(
+ ,
+ document.getElementById("container")
+ );
+
+ TestUtils.Simulate.click(document.querySelector('.ms-feature-info-editor .btn-primary'));
+ expect(onSaveTemplate).toHaveBeenCalled();
+ expect(onSaveTemplate.calls[0].arguments[0]).toContain('Saved template');
+ });
+
});
diff --git a/web/client/components/dashboard/hooks/useCheckScroll.js b/web/client/components/dashboard/hooks/useCheckScroll.js
index 01cc0d5c4a4..d022b610bf4 100644
--- a/web/client/components/dashboard/hooks/useCheckScroll.js
+++ b/web/client/components/dashboard/hooks/useCheckScroll.js
@@ -1,49 +1 @@
-import { useEffect, useRef, useState } from "react";
-
-export default function useCheckScroll({ data }) {
- const scrollRef = useRef(null);
- const [isLeftDisabled, setIsLeftDisabled] = useState(true);
- const [isRightDisabled, setIsRightDisabled] = useState(true);
- const [showButtons, setShowButtons] = useState(false);
-
- useEffect(() => {
- const handleScroll = () => {
- const el = scrollRef.current;
- if (!el) return;
-
- const { scrollLeft, scrollWidth, clientWidth } = el;
-
- // Show buttons only if scrolling is possible
- const canScroll = scrollWidth > clientWidth + 1;
- setShowButtons(canScroll);
-
- // Disable states for buttons
- setIsLeftDisabled(scrollLeft <= 0);
- setIsRightDisabled(scrollLeft + clientWidth >= scrollWidth - 1);
- };
-
- const el = scrollRef.current;
- if (el) {
- handleScroll(); // initial
- el.addEventListener("scroll", handleScroll);
- window.addEventListener("resize", handleScroll);
- }
-
- return () => {
- el?.removeEventListener("scroll", handleScroll);
- window.removeEventListener("resize", handleScroll);
- };
- }, [data]);
-
- const scroll = (direction) => {
- const el = scrollRef.current;
- if (!el) return;
- const scrollAmount = el.clientWidth * 0.8;
- el.scrollBy({
- left: direction === "left" ? -scrollAmount : scrollAmount,
- behavior: "smooth"
- });
- };
-
- return [scrollRef, showButtons, isLeftDisabled, isRightDisabled, scroll];
-}
+export { default } from '../../../hooks/useCheckScroll';
diff --git a/web/client/components/data/identify/DefaultViewer.jsx b/web/client/components/data/identify/DefaultViewer.jsx
index ff02bfbc6dc..916dffe7bc1 100644
--- a/web/client/components/data/identify/DefaultViewer.jsx
+++ b/web/client/components/data/identify/DefaultViewer.jsx
@@ -9,10 +9,11 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { getDefaultInfoFormatValue, getValidator, getViewers, getViewer, getDefaultViewer } from '../../../utils/MapInfoUtils';
+import { getDefaultInfoFormatValue, getValidator, getViewers, getViewer, getDefaultViewer, getDefaultInfoViewMode, getLayerFeatureInfoViews } from '../../../utils/MapInfoUtils';
import HTML from '../../../components/I18N/HTML';
import Message from '../../../components/I18N/Message';
import { Alert, Panel, Accordion } from 'react-bootstrap';
+import ScrollableTabs from '../../misc/ScrollableTabs';
import ViewerPage from './viewers/ViewerPage';
import { isEmpty, reverse, startsWith } from 'lodash';
import { getFormatForResponse } from '../../../utils/IdentifyUtils';
@@ -69,8 +70,15 @@ class DefaultViewer extends React.Component {
hidePopupIfNoResults: false
};
- shouldComponentUpdate(nextProps) {
- return nextProps.responses !== this.props.responses || nextProps.missingResponses !== this.props.missingResponses || nextProps.index !== this.props.index;
+ state = {
+ activeViewIds: {}
+ };
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return nextProps.responses !== this.props.responses
+ || nextProps.missingResponses !== this.props.missingResponses
+ || nextProps.index !== this.props.index
+ || nextState.activeViewIds !== this.state.activeViewIds;
}
/**
@@ -161,17 +169,94 @@ class DefaultViewer extends React.Component {
return null;
}
+ getActiveView = (views, reqId) => {
+ return views.find(({ id }) => id === this.state.activeViewIds[reqId]) || views[0];
+ }
+
+ getLayerMetadataForView = (layerMetadata, view) => {
+ if (!view) {
+ return layerMetadata;
+ }
+ const featureInfo = layerMetadata?.featureInfo || {};
+ return {
+ ...layerMetadata,
+ viewer: view.viewer ?? layerMetadata.viewer,
+ featureInfo: {
+ ...featureInfo,
+ ...view,
+ format: view.type,
+ template: view.template ?? featureInfo.template,
+ viewer: view.viewer ?? featureInfo.viewer
+ }
+ };
+ }
+
+ getResponseForView = (res, view) => {
+ const viewResponse = view?.id && res?.viewResponses?.[view.id];
+ return {
+ response: viewResponse?.response ?? res.response,
+ queryParams: viewResponse?.queryParams ?? res.queryParams
+ };
+ }
+
+ renderViewTabs = (views, activeView, reqId) => {
+ if (views.length <= 1) {
+ return null;
+ }
+ return (
+
+ {
+ this.setState(({activeViewIds}) => ({
+ activeViewIds: {...activeViewIds, [reqId]: activeViewId}
+ }));
+ }}
+ tabs={views.map((view) => ({
+ title: view.title,
+ eventKey: view.id
+ }))}/>
+
+ );
+ }
+
renderPages = () => {
const {validResponses: responses} = this.getResponseProperties(this.props.isMobile || this.props.renderValidOnly);
return responses.map((res, i) => {
- const {response, layerMetadata} = res;
- const format = getFormatForResponse(res, this.props);
+ const {layerMetadata, layer} = res;
+ const layerWithMetadata = {
+ ...layer,
+ ...layerMetadata,
+ featureInfo: layerMetadata?.featureInfo || layer?.featureInfo
+ };
+ const views = getLayerFeatureInfoViews(layerWithMetadata, {
+ defaultType: getDefaultInfoViewMode(this.props.format) || 'PROPERTIES'
+ });
+ const activeView = this.getActiveView(views, res.reqId);
+ const layerMetadataForView = this.getLayerMetadataForView(layerWithMetadata, activeView);
+ const viewResponse = this.getResponseForView(res, activeView);
+ const format = getFormatForResponse({
+ ...res,
+ queryParams: viewResponse.queryParams
+ }, this.props);
const PageHeader = this.props.header;
let customViewer;
- if (layerMetadata?.viewer?.type) {
- customViewer = getViewer(layerMetadata.viewer.type);
+ if (layerMetadataForView?.viewer?.type) {
+ customViewer = getViewer(layerMetadataForView.viewer.type);
}
- const size = responses.filter(resp => !startsWith(resp.response, "no features were found")).length;
+ const size = responses.filter((resp) => {
+ const responseLayer = {
+ ...resp.layer,
+ ...resp.layerMetadata,
+ featureInfo: resp.layerMetadata?.featureInfo || resp.layer?.featureInfo
+ };
+ const responseViews = getLayerFeatureInfoViews(responseLayer, {
+ defaultType: getDefaultInfoViewMode(this.props.format) || 'PROPERTIES'
+ });
+ const response = this.getResponseForView(resp, this.getActiveView(responseViews, resp.reqId));
+ return !startsWith(response.response, "no features were found");
+ }).length;
return ( this.props.onPrevious()}/> : null
}
style={this.props.style}>
+ {this.renderViewTabs(views, activeView, res.reqId)}
+ layer={layerMetadataForView}/>
);
});
};
diff --git a/web/client/components/data/identify/__tests__/DefaultViewer-test.jsx b/web/client/components/data/identify/__tests__/DefaultViewer-test.jsx
index 3c8f19a47e7..0c16f021a07 100644
--- a/web/client/components/data/identify/__tests__/DefaultViewer-test.jsx
+++ b/web/client/components/data/identify/__tests__/DefaultViewer-test.jsx
@@ -184,6 +184,40 @@ describe('DefaultViewer', () => {
expect(dom.getElementsByClassName("alert").length).toBe(0);
});
+ it('renders compact shared tabs for multiple identify views', () => {
+ ReactDOM.render(
+ ,
+ document.getElementById("container")
+ );
+
+ const tabs = document.querySelectorAll('.ms-scrollable-tabs .nav > li > a');
+ expect(tabs.length).toBe(2);
+ expect(tabs[0].textContent).toBe('Text');
+ expect(tabs[1].textContent).toBe('Properties');
+ });
+
it('creates the DefaultViewer component with missing results', () => {
const viewer = ReactDOM.render(
,
diff --git a/web/client/components/data/identify/viewers/JSONViewer.jsx b/web/client/components/data/identify/viewers/JSONViewer.jsx
index cd75759f2c6..74f742a4e79 100644
--- a/web/client/components/data/identify/viewers/JSONViewer.jsx
+++ b/web/client/components/data/identify/viewers/JSONViewer.jsx
@@ -17,7 +17,7 @@ const Viewers = {
PROPERTIES
};
-export default shouldUpdate((props, nextProps) => nextProps.response !== props.response)(
+export default shouldUpdate((props, nextProps) => nextProps.response !== props.response || nextProps.layer !== props.layer)(
props => {
const type = props.layer && props.layer.featureInfo && props.layer.featureInfo.format && (props.layer.featureInfo.template && props.layer.featureInfo.template !== '
') && props.layer.featureInfo.format || 'PROPERTIES';
const Viewer = Viewers[type] || Viewers.PROPERTIES;
diff --git a/web/client/components/misc/ScrollableTabs.jsx b/web/client/components/misc/ScrollableTabs.jsx
new file mode 100644
index 00000000000..5fb3e192ced
--- /dev/null
+++ b/web/client/components/misc/ScrollableTabs.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button, Glyphicon } from 'react-bootstrap';
+import useCheckScroll from '../../hooks/useCheckScroll';
+import Tabs from '../catalog/resources/Tabs';
+
+/**
+ * Renders underline tabs in a horizontally scrollable strip with optional controls.
+ */
+const ScrollableTabs = ({ className, tabs, ...props }) => {
+ const [scrollRef, showButtons, isLeftDisabled, isRightDisabled, scroll] = useCheckScroll({ data: tabs });
+
+ return (
+
+ {showButtons ? (
+
+ ) : null}
+
+
+
+ {showButtons ? (
+
+ ) : null}
+
+ );
+};
+
+ScrollableTabs.propTypes = {
+ className: PropTypes.string,
+ tabs: PropTypes.array
+};
+
+ScrollableTabs.defaultProps = {
+ className: 'ms-tabs tabs-underline',
+ tabs: []
+};
+
+export default ScrollableTabs;
diff --git a/web/client/epics/__tests__/identify-test.js b/web/client/epics/__tests__/identify-test.js
index 4e620c20c6c..4c8eeff5d26 100644
--- a/web/client/epics/__tests__/identify-test.js
+++ b/web/client/epics/__tests__/identify-test.js
@@ -104,6 +104,8 @@ const EPSG_32636_DEF = {
worldExtent: [30.0, 0.0, 36.0, 84.0]
};
+const getFirstViewResponse = (action) => Object.values(action.viewResponses)[0];
+
describe('identify Epics', () => {
it('getFeatureInfoOnFeatureInfoClick, no queriable layers', (done) => {
const state = {
@@ -192,7 +194,7 @@ describe('identify Epics', () => {
});
- it('getFeatureInfoOnFeatureInfoClick WMS', (done) => {
+ it('requests each queryable layer views and maps shared responses to those views', (done) => {
// remove previous hook
registerHook('RESOLUTION_HOOK', undefined);
const state = {
@@ -206,7 +208,14 @@ describe('identify Epics', () => {
title: "TITLE",
type: "wms",
visibility: true,
- url: 'base/web/client/test-resources/featureInfo-response.json'
+ url: 'base/web/client/test-resources/featureInfo-response.json',
+ featureInfo: {
+ views: [
+ {id: 'properties', type: 'PROPERTIES'},
+ {id: 'template', type: 'TEMPLATE'},
+ {id: 'html', type: 'HTML'}
+ ]
+ }
},
{
id: "TEST2",
@@ -214,7 +223,14 @@ describe('identify Epics', () => {
title: "TITLE2",
type: "wms",
visibility: true,
- url: 'base/web/client/test-resources/featureInfo-response.json'
+ url: 'base/web/client/test-resources/featureInfo-response.json',
+ featureInfo: {
+ views: [
+ {id: 'properties', type: 'PROPERTIES'},
+ {id: 'template', type: 'TEMPLATE'},
+ {id: 'html', type: 'HTML'}
+ ]
+ }
}]
}
};
@@ -229,23 +245,30 @@ describe('identify Epics', () => {
expect(a1).toExist();
expect(a1.type).toBe(NEW_MAPINFO_REQUEST);
expect(a1.reqId).toExist();
- expect(a1.request).toExist();
+ expect(a1.request).toNotExist();
expect(a2).toExist();
expect(a2.type).toBe(NEW_MAPINFO_REQUEST);
expect(a2.reqId).toExist();
- expect(a2.request).toExist();
+ expect(a2.request).toNotExist();
expect(a3).toExist();
expect(a3.type).toBe(LOAD_FEATURE_INFO);
- expect(a3.data).toExist();
- expect(a3.requestParams).toExist();
expect(a3.reqId).toExist();
- expect(a3.layerMetadata.title).toBe(state.layers.flat[a3.requestParams.id === "TEST" ? 0 : 1].title);
+ expect(a3.layerMetadata.title).toBe(state.layers.flat.find(({id}) => id === a3.layer.id).title);
+ expect(Object.keys(a3.viewResponses)).toEqual(['properties', 'template', 'html']);
+ expect(a3.viewResponses.properties.response).toBe(a3.viewResponses.template.response);
+ expect(a3.viewResponses.properties.queryParams.info_format).toBe('application/json');
+ expect(a3.viewResponses.html.queryParams.info_format).toBe('text/html');
expect(a4).toExist();
expect(a4.type).toBe(FORCE_UPDATE_MAP_LAYOUT);
expect(a5).toExist();
- expect(a5.layerMetadata.title).toBe(state.layers.flat[a5.requestParams.id === "TEST" ? 0 : 1].title);
+ expect(a5.type).toBe(LOAD_FEATURE_INFO);
+ expect(a5.layerMetadata.title).toBe(state.layers.flat.find(({id}) => id === a5.layer.id).title);
+ expect(Object.keys(a5.viewResponses)).toEqual(['properties', 'template', 'html']);
+ expect(a5.viewResponses.properties.response).toBe(a5.viewResponses.template.response);
+ expect(a5.viewResponses.properties.queryParams.info_format).toBe('application/json');
+ expect(a5.viewResponses.html.queryParams.info_format).toBe('text/html');
done();
} catch (ex) {
done(ex);
@@ -292,7 +315,7 @@ describe('identify Epics', () => {
expect(a0).toBeTruthy();
expect(a1).toBeTruthy();
expect(a2).toBeTruthy();
- expect(a3.requestParams.feature_count).toBe(50);
+ expect(getFirstViewResponse(a3).queryParams.feature_count).toBe(50);
expect(a4).toBeTruthy();
done();
} catch (ex) {
@@ -335,15 +358,12 @@ describe('identify Epics', () => {
expect(a1).toExist();
expect(a1.type).toBe(NEW_MAPINFO_REQUEST);
expect(a1.reqId).toExist();
- expect(a1.request).toExist();
- expect(a1.request.cql_filter).toExist();
- expect(a1.request.cql_filter).toBe("id>1");
+ expect(a1.request).toNotExist();
expect(a2).toExist();
expect(a2.type).toBe(LOAD_FEATURE_INFO);
- expect(a2.data).toExist();
- expect(a2.data.features).toExist();
- expect(a2.data.features.length).toBe(1);
- expect(a2.requestParams).toExist();
+ expect(getFirstViewResponse(a2).response.features).toExist();
+ expect(getFirstViewResponse(a2).response.features.length).toBe(1);
+ expect(getFirstViewResponse(a2).queryParams.cql_filter).toBe("id>1");
expect(a2.reqId).toExist();
expect(a2.layerMetadata.title).toBe(state.layers.flat[0].title);
done();
@@ -391,12 +411,10 @@ describe('identify Epics', () => {
expect(a1).toExist();
expect(a1.type).toBe(NEW_MAPINFO_REQUEST);
expect(a1.reqId).toExist();
- expect(a1.request).toExist();
- expect(a1.request.cql_filter).toNotExist();
+ expect(a1.request).toNotExist();
expect(a2).toExist();
expect(a2.type).toBe(LOAD_FEATURE_INFO);
- expect(a2.data).toExist();
- expect(a2.requestParams).toExist();
+ expect(a2.viewResponses).toExist();
expect(a2.reqId).toExist();
expect(a2.layerMetadata.title).toBe(state.layers.flat[0].title);
done();
@@ -431,15 +449,15 @@ describe('identify Epics', () => {
expect(a1).toExist();
expect(a1.type).toBe(NEW_MAPINFO_REQUEST);
expect(a1.reqId).toExist();
- expect(a1.request).toExist();
+ expect(a1.request).toNotExist();
expect(a2).toExist();
expect(a2.type).toBe(ERROR_FEATURE_INFO);
expect(a2).toExist();
expect(a2.type).toBe(ERROR_FEATURE_INFO);
expect(a2.error).toExist();
expect(a2.reqId).toExist();
- expect(a2.requestParams).toExist();
- expect(a2.layerMetadata.title).toBe(state.layers.flat[0].title);
+ expect(a2.requestParams).toNotExist();
+ expect(a2.layerMetadata).toNotExist();
done();
} catch (ex) {
done(ex);
@@ -485,15 +503,12 @@ describe('identify Epics', () => {
expect(a1).toExist();
expect(a1.type).toEqual(NEW_MAPINFO_REQUEST);
expect(a1.reqId).toExist();
- expect(a1.request).toExist();
- expect(a1.request.cql_filter).toExist();
- expect(a1.request.cql_filter).toEqual("id>1");
+ expect(a1.request).toNotExist();
expect(a2).toExist();
expect(a2.type).toEqual(LOAD_FEATURE_INFO);
- expect(a2.data).toExist();
- expect(a2.data.features).toExist();
- expect(a2.data.features.length).toEqual(1);
- expect(a2.requestParams).toExist();
+ expect(getFirstViewResponse(a2).response.features).toExist();
+ expect(getFirstViewResponse(a2).response.features.length).toEqual(1);
+ expect(getFirstViewResponse(a2).queryParams.cql_filter).toEqual("id>1");
expect(a2.reqId).toExist();
expect(a2.layerMetadata.title).toEqual(state.layers.flat[0].title);
expect(a2.queryParamZoomOption).toEqual(queryParamZoomOption);
@@ -587,11 +602,10 @@ describe('identify Epics', () => {
expect(a1).toExist();
expect(a1.type).toBe(NEW_MAPINFO_REQUEST);
expect(a1.reqId).toExist();
- expect(a1.request).toExist();
+ expect(a1.request).toNotExist();
expect(a2).toExist();
expect(a2.type).toBe(LOAD_FEATURE_INFO);
- expect(a2.data).toExist();
- expect(a2.requestParams).toExist();
+ expect(a2.viewResponses).toExist();
expect(a2.reqId).toExist();
expect(a2.layerMetadata.title).toBe(state.layers.flat[0].title);
done();
@@ -635,11 +649,10 @@ describe('identify Epics', () => {
expect(a1).toExist();
expect(a1.type).toBe(NEW_MAPINFO_REQUEST);
expect(a1.reqId).toExist();
- expect(a1.request).toExist();
+ expect(a1.request).toNotExist();
expect(a2).toExist();
expect(a2.type).toBe(LOAD_FEATURE_INFO);
- expect(a2.data).toExist();
- expect(a2.requestParams).toExist();
+ expect(a2.viewResponses).toExist();
expect(a2.reqId).toExist();
expect(a2.layerMetadata.title).toBe(state.layers.flat[0].title);
done();
@@ -685,12 +698,10 @@ describe('identify Epics', () => {
break;
case NEW_MAPINFO_REQUEST:
expect(action.reqId).toBeTruthy();
- expect(action.request).toBeTruthy();
break;
case LOAD_FEATURE_INFO:
- expect(action.data).toBeTruthy();
- expect(action.requestParams).toBeTruthy();
expect(action.reqId).toBeTruthy();
+ expect(action.viewResponses).toBeTruthy();
expect([state.layers.flat[0].title, state.layers.flat[1].title].includes(action.layerMetadata.title)).toBeTruthy();
break;
case FORCE_UPDATE_MAP_LAYOUT:
@@ -751,13 +762,11 @@ describe('identify Epics', () => {
expect(a1).toExist();
expect(a1.type).toBe(NEW_MAPINFO_REQUEST);
expect(a1.reqId).toExist();
- expect(a1.request).toExist();
- expect(a1.request.cql_filter).toExist();
- expect(a1.request.cql_filter).toBe("id>1");
+ expect(a1.request).toNotExist();
expect(a2).toExist();
expect(a2.type).toBe(LOAD_FEATURE_INFO);
- expect(a2.data).toExist();
- expect(a2.requestParams).toExist();
+ expect(a2.viewResponses).toExist();
+ expect(getFirstViewResponse(a2).queryParams.cql_filter).toBe("id>1");
expect(a2.reqId).toExist();
expect(a2.layerMetadata.title).toBe(state.layers.flat[1].title); // layer that has no visibility limits
done();
@@ -818,31 +827,29 @@ describe('identify Epics', () => {
};
const ignoreVisibilityLimits = true;
const sentActions = [featureInfoClick({ latlng: { lat: 36.95, lng: -79.84 } }, "TEST", ["TEST"], {"TEST": {cql_filter: "id>1"}}, "province_view.5", ignoreVisibilityLimits)];
- testEpic(getFeatureInfoOnFeatureInfoClick, 5, sentActions, ([a0, a1, a2, a3]) => {
+ testEpic(getFeatureInfoOnFeatureInfoClick, 8, sentActions, (actions) => {
try {
+ const [a0, a1, a2, a3] = actions;
expect(a0).toExist();
expect(a0.type).toBe(PURGE_MAPINFO_RESULTS);
expect(a1).toExist();
expect(a1.type).toBe(NEW_MAPINFO_REQUEST);
expect(a1.reqId).toExist();
- expect(a1.request).toExist();
- expect(a1.request.id).toEqual(state.layers.flat[2].id);
- expect(a1.request.cql_filter).toExist();
- expect(a1.request.cql_filter).toBe("id>1");
+ expect(a1.request).toNotExist();
expect(a2).toExist();
expect(a2.type).toBe(NEW_MAPINFO_REQUEST);
expect(a2.reqId).toExist();
- expect(a2.request).toExist();
- expect(a2.request.id).toEqual(state.layers.flat[1].id);
- expect(a2.request.cql_filter).toExist();
- expect(a2.request.cql_filter).toBe("id>1");
+ expect(a2.request).toNotExist();
expect(a3).toExist();
expect(a3.type).toBe(NEW_MAPINFO_REQUEST);
expect(a3.reqId).toExist();
- expect(a3.request).toExist();
- expect(a3.request.id).toEqual(state.layers.flat[0].id);
- expect(a3.request.cql_filter).toExist();
- expect(a3.request.cql_filter).toBe("id>1");
+ expect(a3.request).toNotExist();
+ const loadActions = actions.filter(({type}) => type === LOAD_FEATURE_INFO);
+ expect(loadActions.length).toBe(3);
+ expect(loadActions.map(({layer}) => layer.id).sort()).toEqual(["TEST1", "TEST NEW 2", "TEST_NEW"].sort());
+ loadActions.forEach((action) => {
+ expect(getFirstViewResponse(action).queryParams.cql_filter).toBe("id>1");
+ });
done();
} catch (ex) {
done(ex);
@@ -909,13 +916,10 @@ describe('identify Epics', () => {
expect(a1).toExist();
expect(a1.type).toBe(NEW_MAPINFO_REQUEST);
expect(a1.reqId).toExist();
- expect(a1.request).toExist();
- expect(a1.request.id).toEqual(state.layers.flat[0].id);
- expect(a1.request.cql_filter).toExist();
- expect(a1.request.cql_filter).toBe("id>1");
+ expect(a1.request).toNotExist();
expect(a2.type).toBe(LOAD_FEATURE_INFO);
- expect(a2.data).toExist();
- expect(a2.requestParams).toExist();
+ expect(a2.viewResponses).toExist();
+ expect(getFirstViewResponse(a2).queryParams.cql_filter).toBe("id>1");
expect(a2.reqId).toExist();
expect(a2.layerMetadata.title).toBe(state.layers.flat[0].title);
done();
@@ -1150,7 +1154,11 @@ describe('identify Epics', () => {
const sentActions = [
featureInfoClick({ latlng: { lat: 36.95, lng: -79.84 } }),
- loadFeatureInfo(1, "no features were found")
+ loadFeatureInfo(1, {}, {
+ "default": {
+ response: "no features were found"
+ }
+ })
];
const expectedAction = actions => {
@@ -1195,7 +1203,7 @@ describe('identify Epics', () => {
const sentActions = [
featureInfoClick({ latlng: { lat: 36.95, lng: -79.84 } }),
- loadFeatureInfo(123, {}, {}, {
+ loadFeatureInfo(123, {
isQueryJustOneLayer: true
}),
closeIdentify()
@@ -1251,7 +1259,7 @@ describe('identify Epics', () => {
const sentActions = [
featureInfoClick({ latlng: { lat: 36.95, lng: -79.84 } }),
- loadFeatureInfo(123, {}, {}, {
+ loadFeatureInfo(123, {
isQueryJustOneLayer: true,
featureBbox: [1, 2, 3, 5]
})
diff --git a/web/client/epics/identify.js b/web/client/epics/identify.js
index 9ddf6fb1adb..d3f9507ab29 100644
--- a/web/client/epics/identify.js
+++ b/web/client/epics/identify.js
@@ -56,7 +56,7 @@ import { mouseOutSelector } from '../selectors/mousePosition';
import { hideEmptyPopupSelector } from '../selectors/mapPopups';
import {getBbox, getCurrentResolution} from '../utils/MapUtils';
import { parseLayoutValue } from '../utils/LayoutUtils';
-import {buildIdentifyRequest, defaultQueryableFilter, filterRequestParams} from '../utils/MapInfoUtils';
+import {buildIdentifyRequest, buildIdentifyRequestPlan, defaultQueryableFilter, filterRequestParams} from '../utils/MapInfoUtils';
import { IDENTIFY_POPUP } from '../components/map/popups';
const gridEditingSelector = state => modeSelector(state) === 'EDIT';
@@ -64,6 +64,16 @@ const gridGeometryQuickFilter = state => get(find(getAttributeFilters(state), f
const stopFeatureInfo = state => stopGetFeatureInfoSelector(state) || isFeatureGridOpen(state) && (gridEditingSelector(state) || gridGeometryQuickFilter(state));
+const associateResponsesToViews = (responses) => responses.reduce((viewResponses, { response, requestParams, viewIds = [] }) => {
+ viewIds.forEach((viewId) => {
+ viewResponses[viewId] = {
+ response: response.data,
+ queryParams: requestParams
+ };
+ });
+ return viewResponses;
+}, {});
+
import {getFeatureInfo} from '../api/identify';
import { VISUALIZATION_MODE_CHANGED } from '../actions/maptype';
import {updatePointWithGeometricFilter} from "../utils/IdentifyUtils";
@@ -110,7 +120,7 @@ export const getFeatureInfoOnFeatureInfoClick = (action$, { getState = () => { }
"propertyName"
];
- let firstResponseReturned = false;
+ let layoutUpdated = false;
// 'isQueryJustOneLayer' if just one layer to query
const isQueryJustOneLayer = queryableLayers.filter(l => filterNameList.length ? (filterNameList.filter(name => name.indexOf(l.name) !== -1).length > 0) : true)?.length === 1;
const out$ = Rx.Observable.from((queryableLayers.filter(l => {
@@ -119,41 +129,76 @@ export const getFeatureInfoOnFeatureInfoClick = (action$, { getState = () => { }
})))
.mergeMap(layer => {
let env = localizedLayerStylesEnvSelector(getState());
- let { url, request, metadata } = buildIdentifyRequest(layer, {...identifyOptionsSelector(getState()), env});
- // request override
- if (itemIdSelector(getState()) && overrideParamsSelector(getState())) {
- request = {...request, ...overrideParamsSelector(getState())[layer.name]};
- }
- if (overrideParams[layer.name]) {
- request = {...request, ...overrideParams[layer.name]};
- }
- if (url) {
- const basePath = url;
- const requestParams = request;
- const lMetaData = metadata;
- const appParams = filterRequestParams(layer, includeOptions, excludeParams);
- const attachJSON = isHighlightEnabledSelector(getState());
- const itemId = itemIdSelector(getState());
- const reqId = uuidv1();
+ const identifyOptions = {...identifyOptionsSelector(getState()), env};
+ const { views, requests: requestConfigs } = buildIdentifyRequestPlan(layer, identifyOptions);
+ // Metadata belongs to the layer, not to one of its view requests.
+ const { metadata: layerMetadata = {} } = buildIdentifyRequest(layer, identifyOptions);
+ const appParams = filterRequestParams(layer, includeOptions, excludeParams);
+ const attachJSON = isHighlightEnabledSelector(getState());
+ const itemId = itemIdSelector(getState());
+ const reqId = uuidv1();
+ const identifyRequests = requestConfigs.map(({ url, request, metadata, viewIds }) => {
+ let requestParams = request;
+ // request override
+ if (itemIdSelector(getState()) && overrideParamsSelector(getState())) {
+ requestParams = {...requestParams, ...overrideParamsSelector(getState())[layer.name]};
+ }
+ if (overrideParams[layer.name]) {
+ requestParams = {...requestParams, ...overrideParams[layer.name]};
+ }
const param = { ...appParams, ...requestParams };
- return getFeatureInfo(basePath, param, layer, {attachJSON, itemId})
+ return {
+ basePath: url,
+ metadata,
+ param,
+ requestParams,
+ viewIds
+ };
+ });
+ if (identifyRequests.length) {
+ return Rx.Observable.forkJoin(identifyRequests.map(({basePath, param, requestParams, viewIds}) =>
+ getFeatureInfo(basePath, param, layer, {attachJSON, itemId})
// this 0 delay is needed for vector/3dtiles layer because makes the response async and give time to the GUI to render
// these type of layers don't perform requests to the server because the values are taken from the client map so the response were applied synchronously
// this delay allows the panel to open and show the spinner for the first one
// this delay mitigates the freezing of the app when there are a great amount of queried layers at the same time
- .delay(0)
- .map((response) =>loadFeatureInfo(reqId, response.data, requestParams, { ...lMetaData, features: response.features, featuresCrs: response.featuresCrs, isQueryJustOneLayer, sidebarIsOpened, featureBbox: (queryParamZoomOption?.overrideZoomLvl || queryParamZoomOption?.isCoordsProvided) ? null : bbox }, layer, queryParamZoomOption))
- .catch((e) => Rx.Observable.of(errorFeatureInfo(reqId, e, requestParams, lMetaData)))
+ .delay(0)
+ .map((response) => ({response, requestParams, viewIds}))
+ ))
+ .map((responses) => {
+ const featureResponse = responses.find(({response}) => response.features?.length)
+ || responses.find(({response}) => response.features)
+ || responses[0];
+ return loadFeatureInfo(
+ reqId,
+ {
+ ...layerMetadata,
+ featureInfo: {
+ ...(layer.featureInfo || {}),
+ views
+ },
+ features: featureResponse.response.features,
+ featuresCrs: featureResponse.response.featuresCrs,
+ isQueryJustOneLayer,
+ sidebarIsOpened,
+ featureBbox: (queryParamZoomOption?.overrideZoomLvl || queryParamZoomOption?.isCoordsProvided) ? null : bbox
+ },
+ associateResponsesToViews(responses),
+ layer,
+ queryParamZoomOption
+ );
+ })
+ .catch((e) => Rx.Observable.of(errorFeatureInfo(reqId, e)))
.concat(Rx.Observable.defer(() => {
// update the layout only after the initial response
// we don't need to trigger this for each query layer
- if (!firstResponseReturned) {
- firstResponseReturned = true;
+ if (!layoutUpdated) {
+ layoutUpdated = true;
return Rx.Observable.of(forceUpdateMapLayout());
}
return Rx.Observable.empty();
}))
- .startWith(newMapInfoRequest(reqId, param));
+ .startWith(newMapInfoRequest(reqId));
}
return Rx.Observable.of(forceUpdateMapLayout());
});
@@ -164,6 +209,7 @@ export const getFeatureInfoOnFeatureInfoClick = (action$, { getState = () => { }
}
return out$.startWith(purgeMapInfoResults());
});
+
/**
* if `clickLayer` is present, this means that `handleClickOnLayer` is true for the clicked layer, so the marker have to be hidden, because
* it's managed by the layer itself (e.g. annotations). So the marker have to be hidden.
@@ -266,7 +312,9 @@ export const zoomToVisibleAreaEpic = (action$, store) =>
const state = store.getState();
const hideIdentifyPopupIfNoResults = hideEmptyPopupSelector(state);
const hoverIdentifyActive = isMouseMoveIdentifyActiveSelector(state);
- const noResultFeatures = loadFeatInfoAction.type === LOAD_FEATURE_INFO && typeof loadFeatInfoAction?.data === "string" && loadFeatInfoAction?.data?.includes("no features were found");
+ const noResultFeatures = loadFeatInfoAction.type === LOAD_FEATURE_INFO
+ && Object.values(loadFeatInfoAction?.viewResponses || {})
+ .some(({response}) => typeof response === "string" && response.includes("no features were found"));
// remove marker in case activated identify hover mode and no fetched results plus existing hideIdentifyPopupIfNoResults = true
if (noResultFeatures && hideIdentifyPopupIfNoResults && hoverIdentifyActive) {
return Rx.Observable.from([updateCenterToMarker('disabled'), hideMapinfoMarker()]);
diff --git a/web/client/hooks/useCheckScroll.js b/web/client/hooks/useCheckScroll.js
new file mode 100644
index 00000000000..db281deb58b
--- /dev/null
+++ b/web/client/hooks/useCheckScroll.js
@@ -0,0 +1,55 @@
+import { useEffect, useRef, useState } from 'react';
+
+/**
+ * Provides native horizontal scroll controls for an overflowing element.
+ * @param {object} options hook options
+ * @param {any} options.data data that changes the scrollable content
+ * @return {array} scroll element ref, control visibility, disabled states and scroll action
+ */
+export default function useCheckScroll({ data }) {
+ const scrollRef = useRef(null);
+ const [isLeftDisabled, setIsLeftDisabled] = useState(true);
+ const [isRightDisabled, setIsRightDisabled] = useState(true);
+ const [showButtons, setShowButtons] = useState(false);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ const element = scrollRef.current;
+ if (!element) {
+ return;
+ }
+
+ const { scrollLeft, scrollWidth, clientWidth } = element;
+ const canScroll = scrollWidth > clientWidth + 1;
+ setShowButtons(canScroll);
+ setIsLeftDisabled(scrollLeft <= 0);
+ setIsRightDisabled(scrollLeft + clientWidth >= scrollWidth - 1);
+ };
+
+ const element = scrollRef.current;
+ if (element) {
+ handleScroll();
+ element.addEventListener('scroll', handleScroll);
+ window.addEventListener('resize', handleScroll);
+ }
+
+ return () => {
+ element?.removeEventListener('scroll', handleScroll);
+ window.removeEventListener('resize', handleScroll);
+ };
+ }, [data, showButtons]);
+
+ const scroll = (direction) => {
+ const element = scrollRef.current;
+ if (!element) {
+ return;
+ }
+ const scrollAmount = element.clientWidth * 0.8;
+ element.scrollBy({
+ left: direction === 'left' ? -scrollAmount : scrollAmount,
+ behavior: 'smooth'
+ });
+ };
+
+ return [scrollRef, showButtons, isLeftDisabled, isRightDisabled, scroll];
+}
diff --git a/web/client/plugins/tocitemssettings/defaultSettingsTabs.js b/web/client/plugins/tocitemssettings/defaultSettingsTabs.js
index 6eab274d125..7aecc0d91a8 100644
--- a/web/client/plugins/tocitemssettings/defaultSettingsTabs.js
+++ b/web/client/plugins/tocitemssettings/defaultSettingsTabs.js
@@ -180,15 +180,7 @@ export default ({ showFeatureInfoTab = true, loadedPlugins, items, onToggleStyle
tooltipId: 'layerProperties.featureInfo',
glyph: 'map-marker',
visible: showFeatureInfoTab && isLayerNode(props) && (isWMS(props) || isWFS(props)) && !(props.element.featureInfo && props.element.featureInfo.viewer),
- Component: FeatureInfo,
- toolbar: [
- {
- glyph: 'pencil',
- tooltipId: 'layerProperties.editCustomFormat',
- visible: !props.showEditor && props.element && props.element.featureInfo && props.element.featureInfo.format === 'TEMPLATE' || false,
- onClick: () => props.onShowEditor && props.onShowEditor(!props.showEditor)
- }
- ]
+ Component: FeatureInfo
},
{
id: 'elevation',
diff --git a/web/client/reducers/__tests__/mapInfo-test.js b/web/client/reducers/__tests__/mapInfo-test.js
index 501f9040a60..e94893ae642 100644
--- a/web/client/reducers/__tests__/mapInfo-test.js
+++ b/web/client/reducers/__tests__/mapInfo-test.js
@@ -92,8 +92,7 @@ describe('Test the mapInfo reducer', () => {
it('creates a feature info data from successful request', () => {
let testAction = {
type: 'LOAD_FEATURE_INFO',
- data: "data",
- requestParams: "params",
+ viewResponses: {"default": {response: "data", queryParams: "params"}},
layerMetadata: "meta",
reqId: 10
};
@@ -101,16 +100,16 @@ describe('Test the mapInfo reducer', () => {
let state = mapInfo(appState, testAction);
expect(state.responses).toExist();
expect(state.responses.length).toBe(1);
- expect(state.responses[0].response).toBe("data");
- expect(state.responses[0].queryParams).toBe("params");
+ expect(state.responses[0].viewResponses.default.response).toBe("data");
+ expect(state.responses[0].viewResponses.default.queryParams).toBe("params");
expect(state.responses[0].layerMetadata).toBe("meta");
expect(state.index).toBe(0);
state = mapInfo(Object.assign({}, appState, {responses: []}), testAction);
expect(state.responses).toExist();
expect(state.responses.length).toBe(1);
- expect(state.responses[0].response).toBe("data");
- expect(state.responses[0].queryParams).toBe("params");
+ expect(state.responses[0].viewResponses.default.response).toBe("data");
+ expect(state.responses[0].viewResponses.default.queryParams).toBe("params");
expect(state.responses[0].layerMetadata).toBe("meta");
expect(state.index).toBe(0);
@@ -118,16 +117,38 @@ describe('Test the mapInfo reducer', () => {
expect(state.responses).toExist();
expect(state.responses.length).toBe(2);
expect(state.responses[0]).toBeTruthy();
- expect(state.responses[1].response).toBe("data");
- expect(state.responses[1].queryParams).toBe("params");
+ expect(state.responses[1].viewResponses.default.response).toBe("data");
+ expect(state.responses[1].viewResponses.default.queryParams).toBe("params");
expect(state.responses[1].layerMetadata).toBe("meta");
expect(state.index).toBe(1);
});
+ it('stores all configured identify view responses for a layer', () => {
+ const viewResponses = {
+ properties: {
+ response: {features: [{id: 'feature-1'}]},
+ queryParams: {info_format: 'application/json'}
+ },
+ html: {
+ response: 'Feature 1
',
+ queryParams: {info_format: 'text/html'}
+ }
+ };
+ const state = mapInfo({...appState, responses: []}, {
+ type: 'LOAD_FEATURE_INFO',
+ viewResponses,
+ layerMetadata: {title: 'Layer 1'},
+ layer: {id: 'layer-1'},
+ reqId: 10
+ });
+
+ expect(state.responses[0].viewResponses).toEqual(viewResponses);
+ expect(state.responses[0].viewResponses.properties.response.features[0].id).toBe('feature-1');
+ expect(state.responses[0].viewResponses.html.queryParams.info_format).toBe('text/html');
+ });
it('creates a feature info data from successful request, with showAllResponses true', () => {
let testAction = {
type: 'LOAD_FEATURE_INFO',
- data: "data",
- requestParams: "params",
+ viewResponses: {"default": {response: "data", queryParams: "params"}},
layerMetadata: "meta",
reqId: 11
};
@@ -147,8 +168,8 @@ describe('Test the mapInfo reducer', () => {
expect(state.responses).toExist();
expect(state.responses.length).toBe(2);
expect(state.loaded).toBe(true);
- expect(state.responses[1].response).toBe("data");
- expect(state.responses[1].queryParams).toBe("params");
+ expect(state.responses[1].viewResponses.default.response).toBe("data");
+ expect(state.responses[1].viewResponses.default.queryParams).toBe("params");
expect(state.responses[1].layerMetadata).toBe("meta");
expect(state.index).toBe(0);
@@ -171,7 +192,7 @@ describe('Test the mapInfo reducer', () => {
expect(state.responses[1].response).toBe("test1");
expect(state.responses[1]).toBeTruthy();
expect(state.responses[2]).toBeTruthy();
- expect(state.responses[2].queryParams).toBe("params");
+ expect(state.responses[2].viewResponses.default.queryParams).toBe("params");
expect(state.responses[2].layerMetadata).toBe("meta3");
expect(state.loaded).toBe(true);
expect(state.index).toBe(0);
@@ -179,8 +200,7 @@ describe('Test the mapInfo reducer', () => {
it('creates a feature info data from successful request on showInMapPopup', () => {
let testAction = {
type: 'LOAD_FEATURE_INFO',
- data: "data",
- requestParams: "params",
+ viewResponses: {"default": {response: "data", queryParams: "params"}},
layerMetadata: "meta",
reqId: 10
};
@@ -188,8 +208,8 @@ describe('Test the mapInfo reducer', () => {
let state = mapInfo({...appState, showInMapPopup: true}, testAction);
expect(state.responses).toExist();
expect(state.responses.length).toBe(2);
- expect(state.responses[0].response).toBe("data");
- expect(state.responses[0].queryParams).toBe("params");
+ expect(state.responses[0].viewResponses.default.response).toBe("data");
+ expect(state.responses[0].viewResponses.default.queryParams).toBe("params");
expect(state.responses[0].layerMetadata).toBe("meta");
expect(state.index).toBe(0);
@@ -207,8 +227,7 @@ describe('Test the mapInfo reducer', () => {
it('creates a feature info with empty data from successful request', () => {
let testAction = {
type: 'LOAD_FEATURE_INFO',
- data: "",
- requestParams: "params",
+ viewResponses: {"default": {response: "", queryParams: "params"}},
layerMetadata: "meta",
reqId: 10
};
@@ -216,8 +235,8 @@ describe('Test the mapInfo reducer', () => {
let state = mapInfo(appState, testAction);
expect(state.responses).toExist();
expect(state.responses.length).toBe(1);
- expect(state.responses[0].response).toBe("");
- expect(state.responses[0].queryParams).toBe("params");
+ expect(state.responses[0].viewResponses.default.response).toBe("");
+ expect(state.responses[0].viewResponses.default.queryParams).toBe("params");
expect(state.responses[0].layerMetadata).toBe("meta");
expect(state.index).toBe(undefined);
expect(state.loaded).toBe(undefined);
@@ -226,8 +245,8 @@ describe('Test the mapInfo reducer', () => {
expect(state.responses).toExist();
expect(state.responses.length).toBe(2);
expect(state.responses[0]).toBeTruthy();
- expect(state.responses[1].response).toBe("");
- expect(state.responses[1].queryParams).toBe("params");
+ expect(state.responses[1].viewResponses.default.response).toBe("");
+ expect(state.responses[1].viewResponses.default.queryParams).toBe("params");
expect(state.responses[1].layerMetadata).toBe("meta");
state = mapInfo(Object.assign({}, appState, {responses: [{response: "test"}, {response: "test"}]}), {...testAction, layerMetadata: "meta3", reqId: 3});
@@ -236,7 +255,7 @@ describe('Test the mapInfo reducer', () => {
expect(state.responses[0]).toBeTruthy();
expect(state.responses[0].response).toBe("test");
expect(state.responses[1].response).toBe("test");
- expect(state.responses[2].queryParams).toBe("params");
+ expect(state.responses[2].viewResponses.default.queryParams).toBe("params");
expect(state.responses[2].layerMetadata).toBe("meta3");
expect(state.index).toBe(undefined);
expect(state.loaded).toBe(true);
diff --git a/web/client/reducers/mapInfo.js b/web/client/reducers/mapInfo.js
index 2bcd31f9a71..6abb87e0c10 100644
--- a/web/client/reducers/mapInfo.js
+++ b/web/client/reducers/mapInfo.js
@@ -90,10 +90,10 @@ function receiveResponse(state, action, type) {
if (!isVector) {
const updateResponse = {
- response: action[type],
- queryParams: action.requestParams,
layerMetadata: action.layerMetadata,
- layer: action.layer
+ viewResponses: action.viewResponses,
+ layer: action.layer,
+ reqId: action.reqId
};
if (isHover) {
// Add response upon it is received
diff --git a/web/client/themes/default/less/mapstore.less b/web/client/themes/default/less/mapstore.less
index ca6cb97062a..324f8edfb3f 100644
--- a/web/client/themes/default/less/mapstore.less
+++ b/web/client/themes/default/less/mapstore.less
@@ -58,6 +58,7 @@
@import "react-widgets.less";
@import "resources-catalog/index.less";
@import "rulesmanager.less";
+@import "scrollable-tabs.less";
@import "searchbar.less";
@import "select.less";
@import "services-config-editor.less";
diff --git a/web/client/themes/default/less/scrollable-tabs.less b/web/client/themes/default/less/scrollable-tabs.less
new file mode 100644
index 00000000000..cd49e2c46be
--- /dev/null
+++ b/web/client/themes/default/less/scrollable-tabs.less
@@ -0,0 +1,33 @@
+.ms-scrollable-tabs {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+
+ .ms-scrollable-tabs-content {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ > .tabs-underline > .nav {
+ display: flex;
+ flex-wrap: nowrap;
+ margin-bottom: 0;
+ width: max-content;
+
+ > li {
+ flex: none;
+ float: none;
+ }
+ }
+ }
+
+ .ms-scrollable-tabs-control {
+ flex: none;
+ }
+}
diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json
index aed4167148a..255bf19d4a6 100644
--- a/web/client/translations/data.de-DE.json
+++ b/web/client/translations/data.de-DE.json
@@ -109,6 +109,8 @@
"elevation": "Höhe",
"groupProperties": "Gruppeneigenschaften",
"featureInfo": "Feature Info",
+ "disableIdentify": "Identifizierung deaktivieren",
+ "addIdentifyView": "Neue Identifikationsansicht hinzufügen",
"featureInfoFormatLbl": "Antwortformat festlegen",
"guideText": "In der URL des Bildes können Sie Platzhalter wie $\\{properties.YOUR_ATTRIBUTE\\} verwenden, um die URL abhängig von den Feature-Attributen parametrisch zu machen. Es unterstützt sowohl URLs als auch „Data URI“, die in Base64 codiert sind",
"imageNotFound": "Für die korrekte Darstellung dieses Bildes ist möglicherweise eine Feature-Info-Anfrage erforderlich",
diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json
index 373f588dd93..7e859b66d9f 100644
--- a/web/client/translations/data.en-US.json
+++ b/web/client/translations/data.en-US.json
@@ -109,6 +109,8 @@
"elevation": "Elevation",
"groupProperties": "Group properties",
"featureInfo": "Feature Info",
+ "disableIdentify": "Disable Identify",
+ "addIdentifyView": "Add new identify view",
"featureInfoFormatLbl": "Identify response format",
"guideText": "In the URL of the image you can use placeholders like $\\{properties.YOUR_ATTRIBUTE\\} to make the URL parametric, depending by the feature attributes. It supports both URLs or \"Data URIs\" encoded in base64",
"imageNotFound": "This image may need a feature info request to be rendered correctly",
diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json
index 15e35281833..a52f323bb87 100644
--- a/web/client/translations/data.es-ES.json
+++ b/web/client/translations/data.es-ES.json
@@ -109,6 +109,8 @@
"elevation": "Elevación",
"groupProperties": "Propiedades del grupo",
"featureInfo": "Información de la capa",
+ "disableIdentify": "Desactivar identificación",
+ "addIdentifyView": "Añadir nueva vista de identificación",
"featureInfoFormatLbl": "Identificar el formato de respuesta",
"guideText": "En la URL de la imagen, puede utilizar marcadores de posición como $\\{properties.YOUR_ATTRIBUTE\\} para hacer que la URL sea paramétrica, según los atributos de la característica. Admite URL o \"Data URI\" codificados en base64",
"imageNotFound": "Es posible que esta imagen necesite una solicitud de información de función para renderizarse correctamente",
diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json
index 2d5138468b2..cbfd35ff931 100644
--- a/web/client/translations/data.fr-FR.json
+++ b/web/client/translations/data.fr-FR.json
@@ -109,6 +109,8 @@
"elevation": "Élévation",
"groupProperties": "Propriétés du groupe",
"featureInfo": "Informations attributaires de l'objet",
+ "disableIdentify": "Désactiver l'identification",
+ "addIdentifyView": "Ajouter une nouvelle vue d'identification",
"featureInfoFormatLbl": "Information, format de la réponse",
"guideText": "Dans l'URL de l'image, vous pouvez utiliser des espaces réservés comme $\\{properties.YOUR_ATTRIBUTE\\} pour rendre l'URL paramétrique, en fonction des attributs de la fonctionnalité. Il prend en charge à la fois les URL ou les « Data URI » codés en base64.",
"imageNotFound": "Cette image peut nécessiter une demande d\\'informations sur les fonctionnalités pour être rendue correctement",
diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json
index 8663c39f70d..58ba24e74e1 100644
--- a/web/client/translations/data.it-IT.json
+++ b/web/client/translations/data.it-IT.json
@@ -109,6 +109,8 @@
"elevation": "Elevazione",
"groupProperties": "Proprietà del gruppo",
"featureInfo": "Feature Info",
+ "disableIdentify": "Disabilita Identify",
+ "addIdentifyView": "Aggiungi una nuova vista Identify",
"featureInfoFormatLbl": "Formato risposta interrogazioni su mappa",
"guideText": "Nell'URL dell'immagine puoi utilizzare variabili come $\\{properties.YOUR_ATTRIBUTE\\} per rendere parametrico l'URL a seconda degli attributi della feature. Supporta sia URL che \"Data URI\" codificati in base64",
"imageNotFound": "Questa immagine può necessitare di una richiesta informazioni sulle feature per essere renderizzata correttamente",
diff --git a/web/client/utils/MapInfoUtils.js b/web/client/utils/MapInfoUtils.js
index c61a80ad189..5ceaf3c0c97 100644
--- a/web/client/utils/MapInfoUtils.js
+++ b/web/client/utils/MapInfoUtils.js
@@ -9,7 +9,7 @@
import { INFO_FORMATS, INFO_FORMATS_BY_MIME_TYPE, JSON_MIME_TYPE, GEOJSON_MIME_TYPE, validator } from './FeatureInfoUtils';
import pointOnSurface from 'turf-point-on-surface';
-import { findIndex } from 'lodash';
+import { findIndex, omit } from 'lodash';
import iconUrl from '../components/map/openlayers/img/marker-icon.png';
import JSONViewer from '../components/data/identify/viewers/JSONViewer';
import HTMLViewer from '../components/data/identify/viewers/HTMLViewer';
@@ -166,6 +166,53 @@ export const isDataFormat = (param) => {
export const getLayerFeatureInfo = (layer) => {
return layer && layer.featureInfo && {...layer.featureInfo} || {};
};
+/**
+ * returns true if the layer has identify disabled.
+ * It supports both the legacy HIDDEN format and the new disabled flag.
+ * @param layer {object} layer object
+ * @return {boolean} true if identify is disabled for the layer
+ */
+export const isLayerFeatureInfoDisabled = (layer) => {
+ const featureInfo = getLayerFeatureInfo(layer);
+ return !!featureInfo.disabled || featureInfo.format === 'HIDDEN';
+};
+/**
+ * returns the normalized feature info views configured for a layer.
+ * Legacy single featureInfo format is exposed as a single view.
+ * @param layer {object} layer object
+ * @param options {object} resolver options
+ * @param options.defaultType {string} view type used when the layer has no saved configuration
+ * @param options.includeDisabled {boolean} includes configured views while editing a disabled layer
+ * @return {object[]} feature info views
+ */
+export const getLayerFeatureInfoViews = (layer, { defaultType, includeDisabled = false } = {}) => {
+ const featureInfo = getLayerFeatureInfo(layer);
+ if (isLayerFeatureInfoDisabled(layer) && !includeDisabled) {
+ return [];
+ }
+ if (Array.isArray(featureInfo.views) && featureInfo.views.length) {
+ return featureInfo.views.map((view, idx) => ({
+ ...view,
+ id: view.id || `view-${idx}`,
+ title: view.title || 'Identify',
+ type: view.type || view.format || INFO_VIEW_MODES.PROPERTIES
+ }));
+ }
+ if (featureInfo.format && featureInfo.format !== 'HIDDEN') {
+ const { format, ...config } = featureInfo;
+ return [{
+ ...config,
+ id: 'default',
+ title: 'Identify',
+ type: format
+ }];
+ }
+ return defaultType ? [{
+ id: 'default',
+ title: 'Identify',
+ type: defaultType
+ }] : [];
+};
/**
* Extracts the proper mime time to use for the layer, given the passed props that determine the preferred type. This
* helps to convert, for instance, the mime-type set as default for the map (e.g. `application/json`) into the effective
@@ -175,7 +222,7 @@ export const getLayerFeatureInfo = (layer) => {
* @return {string} the info format value from layer, otherwise the info format in settings
*/
export const getDefaultInfoFormatValueFromLayer = (layer, props) => {
- const featInfoFormat = getLayerFeatureInfo(layer)?.format;
+ const featInfoFormat = getLayerFeatureInfoViews(layer)?.[0]?.type;
if (featInfoFormat) {
// When the user explicitly configures the format from the layer settings => feature info page, return directly from definition map.
// Check if featInfoFormat is an actual view, otherwise retrieve infoFormat directly.
@@ -206,6 +253,10 @@ export const getLayerFeatureInfoViewer = (layer) => {
&& layer.featureInfo.viewer) {
return layer.featureInfo.viewer;
}
+ const viewer = getLayerFeatureInfoViews(layer)?.[0]?.viewer;
+ if (viewer) {
+ return viewer;
+ }
return {};
};
export const clickedPointToGeoJson = (clickedPoint) => {
@@ -280,6 +331,52 @@ export const buildIdentifyRequest = (layer, options) => {
}
return {};
};
+/**
+ * Creates the minimum set of identify requests needed by the configured views.
+ * Views using the same request share its response (for example PROPERTIES and TEMPLATE).
+ * @param {object} layer the layer object
+ * @param {object} options the identify request options
+ * @return {object} effective views and deduplicated request configurations
+ */
+export const buildIdentifyRequestPlan = (layer, options) => {
+ const defaultType = getDefaultInfoViewMode(options?.format || MapInfoUtils.getDefaultInfoFormatValue()) || INFO_VIEW_MODES.PROPERTIES;
+ const views = getLayerFeatureInfoViews(layer, { defaultType });
+ if (isLayerFeatureInfoDisabled(layer)) {
+ return { views, requests: [] };
+ }
+ const requests = views.reduce((requestConfigs, view) => {
+ // View-specific settings must not override the view currently being planned.
+ const featureInfo = omit(layer.featureInfo || {}, ['format', 'template', 'viewer']);
+ const requestConfig = buildIdentifyRequest({
+ ...layer,
+ featureInfo: {
+ ...featureInfo,
+ views: [view]
+ }
+ }, options);
+ if (!requestConfig.url) {
+ return requestConfigs;
+ }
+ const requestKey = JSON.stringify({
+ url: requestConfig.url,
+ request: requestConfig.request
+ });
+ const existingRequest = requestConfigs.find(({ key }) => key === requestKey);
+ if (existingRequest) {
+ existingRequest.viewIds.push(view.id);
+ return requestConfigs;
+ }
+ return [
+ ...requestConfigs,
+ {
+ ...requestConfig,
+ key: requestKey,
+ viewIds: [view.id]
+ }
+ ];
+ }, []);
+ return { views, requests };
+};
/**
* Returns an Observable that emits the response when ready.
* @param {object} layer the layer
@@ -319,11 +416,18 @@ const determineValidator = (response, format) => {
};
export const getValidator = (format) => {
+ const isValidResponse = (current) => {
+ const viewResponses = Object.values(current?.viewResponses || {});
+ if (viewResponses.length) {
+ return viewResponses.some((response) => determineValidator(response, format).isValidResponse(response));
+ }
+ return determineValidator(current, format).isValidResponse(current);
+ };
return {
getValidResponses: (responses) => {
return responses.filter((current) => {
if (current) {
- return determineValidator(current, format).isValidResponse(current);
+ return isValidResponse(current);
}
return false;
});
@@ -331,7 +435,7 @@ export const getValidator = (format) => {
getNoValidResponses: (responses) => {
return responses.filter((current) => {
if (current) {
- return !determineValidator(current, format).isValidResponse(current);
+ return !isValidResponse(current);
}
return false;
});
@@ -367,7 +471,8 @@ export const defaultQueryableFilter = (l) => {
return l.visibility &&
MapInfoUtils.services[l.type] &&
(l.queryable === undefined || l.queryable) &&
- l.group !== "background" && l?.featureInfo?.format !== 'HIDDEN'
+ l.group !== "background" &&
+ !MapInfoUtils.isLayerFeatureInfoDisabled(l)
;
};
export const services = {
@@ -446,8 +551,10 @@ MapInfoUtils = {
getDefaultInfoFormatValueFromLayer,
getLayerFeatureInfoViewer,
getLayerFeatureInfo,
+ getLayerFeatureInfoViews,
+ isLayerFeatureInfoDisabled,
+ buildIdentifyRequestPlan,
VIEWERS: {},
registerRowViewer,
getRowViewer
};
-
diff --git a/web/client/utils/__tests__/MapInfoUtils-test.js b/web/client/utils/__tests__/MapInfoUtils-test.js
index e670fe64e57..97d5fb319a6 100644
--- a/web/client/utils/__tests__/MapInfoUtils-test.js
+++ b/web/client/utils/__tests__/MapInfoUtils-test.js
@@ -16,12 +16,15 @@ import {
getAvailableInfoFormatValues,
getDefaultInfoFormatValue,
buildIdentifyRequest,
+ buildIdentifyRequestPlan,
getValidator,
getViewer,
setViewer,
getLabelFromValue,
getDefaultInfoFormatValueFromLayer,
getLayerFeatureInfo,
+ getLayerFeatureInfoViews,
+ isLayerFeatureInfoDisabled,
getMarkerLayer,
defaultQueryableFilter,
filterRequestParams,
@@ -294,6 +297,47 @@ describe('MapInfoUtils', () => {
expect(req1.metadata.viewer.type).toBe('customViewer');
});
+ it('buildIdentifyRequestPlan supports a legacy single featureInfo format', () => {
+ const {views, requests} = buildIdentifyRequestPlan({
+ type: 'wms',
+ name: 'layer',
+ url: 'http://localhost',
+ featureInfo: {format: 'TEXT'}
+ }, {
+ map: {zoom: 0, projection: 'EPSG:4326'},
+ point: {latlng: {lat: 0, lng: 0}}
+ });
+
+ expect(views).toEqual([{id: 'default', title: 'Identify', type: 'TEXT'}]);
+ expect(requests.length).toBe(1);
+ expect(requests[0].viewIds).toEqual(['default']);
+ expect(requests[0].request.info_format).toBe('text/plain');
+ });
+
+ it('buildIdentifyRequestPlan deduplicates requests shared by configured views', () => {
+ const {requests} = buildIdentifyRequestPlan({
+ type: 'wms',
+ name: 'layer',
+ url: 'http://localhost',
+ featureInfo: {
+ views: [
+ {id: 'properties', type: 'PROPERTIES'},
+ {id: 'template', type: 'TEMPLATE'},
+ {id: 'html', type: 'HTML'}
+ ]
+ }
+ }, {
+ map: {zoom: 0, projection: 'EPSG:4326'},
+ point: {latlng: {lat: 0, lng: 0}}
+ });
+
+ expect(requests.length).toBe(2);
+ expect(requests[0].viewIds).toEqual(['properties', 'template']);
+ expect(requests[0].request.info_format).toBe('application/json');
+ expect(requests[1].viewIds).toEqual(['html']);
+ expect(requests[1].request.info_format).toBe('text/html');
+ });
+
it('buildIdentifyRequest works for wmts layer', () => {
let props = {
map: {
@@ -666,6 +710,8 @@ describe('MapInfoUtils', () => {
// getDefaultInfoFormatValueFromLayer should also resolve when featureInfo.format corresponds to an info_format key rather then an info view.
const geojsonPreConfigured = getDefaultInfoFormatValueFromLayer({featureInfo: {format: "GEOJSON"}}, {});
expect(geojsonPreConfigured).toBe('application/geo+json');
+ const jsonFormatFromViews = getDefaultInfoFormatValueFromLayer({featureInfo: {views: [{id: 'main', title: 'Main', type: 'PROPERTIES'}]}}, {});
+ expect(jsonFormatFromViews).toBe('application/json');
const htmlFormat = getDefaultInfoFormatValueFromLayer({}, {format: "text/html"});
expect(htmlFormat).toBe('text/html');
});
@@ -676,6 +722,60 @@ describe('MapInfoUtils', () => {
expect(getLayerFeatureInfo({featureInfo: {format: 'TEXT'}})).toEqual({format: 'TEXT'});
});
+ it('getLayerFeatureInfoViews should normalize legacy featureInfo format', () => {
+ expect(getLayerFeatureInfoViews()).toEqual([]);
+ expect(getLayerFeatureInfoViews({})).toEqual([]);
+ expect(getLayerFeatureInfoViews({}, {defaultType: 'HTML'})).toEqual([{
+ id: 'default',
+ title: 'Identify',
+ type: 'HTML'
+ }]);
+ expect(getLayerFeatureInfoViews({featureInfo: {format: 'TEXT'}})).toEqual([{
+ id: 'default',
+ title: 'Identify',
+ type: 'TEXT'
+ }]);
+ expect(getLayerFeatureInfoViews({featureInfo: {format: 'TEMPLATE', template: '{name}
'}})).toEqual([{
+ id: 'default',
+ title: 'Identify',
+ type: 'TEMPLATE',
+ template: '{name}
'
+ }]);
+ });
+
+ it('getLayerFeatureInfoViews should return configured views with defaults', () => {
+ expect(getLayerFeatureInfoViews({
+ featureInfo: {
+ views: [
+ {id: 'main', title: 'Main', type: 'PROPERTIES'},
+ {template: '{name}
', type: 'TEMPLATE'}
+ ]
+ }
+ })).toEqual([
+ {id: 'main', title: 'Main', type: 'PROPERTIES'},
+ {id: 'view-1', title: 'Identify', type: 'TEMPLATE', template: '{name}
'}
+ ]);
+ });
+
+ it('isLayerFeatureInfoDisabled should support legacy HIDDEN and disabled flag', () => {
+ expect(isLayerFeatureInfoDisabled()).toBe(false);
+ expect(isLayerFeatureInfoDisabled({featureInfo: {format: 'TEXT'}})).toBe(false);
+ expect(isLayerFeatureInfoDisabled({featureInfo: {format: 'HIDDEN'}})).toBe(true);
+ expect(isLayerFeatureInfoDisabled({featureInfo: {disabled: true}})).toBe(true);
+ expect(getLayerFeatureInfoViews({featureInfo: {format: 'HIDDEN'}})).toEqual([]);
+ expect(getLayerFeatureInfoViews({featureInfo: {disabled: true, views: [{id: 'main', type: 'TEXT'}]}})).toEqual([]);
+ expect(getLayerFeatureInfoViews({
+ featureInfo: {
+ disabled: true,
+ views: [{id: 'main', type: 'TEXT'}]
+ }
+ }, {includeDisabled: true})).toEqual([{
+ id: 'main',
+ title: 'Identify',
+ type: 'TEXT'
+ }]);
+ });
+
it('filterRequestParams', () => {
const excludeParams = ["SLD_BODY"];
const includeOptions = ["buffer", "cql_filter", "filter", "propertyName"];
@@ -797,6 +897,21 @@ describe('MapInfoUtils', () => {
expect(results).toEqual(false);
});
+ it('defaultQueryableFilter should return false if featureInfo is disabled', () => {
+ const layer = {
+ type: "wms",
+ name: "layer",
+ url: "http://localhost",
+ featureInfo: {
+ disabled: true,
+ views: [{ id: 'main', title: 'Main', type: 'TEXT' }]
+ },
+ queryable: true,
+ visibility: true
+ };
+ expect(defaultQueryableFilter(layer)).toEqual(false);
+ });
+
it("getMarkerLayer should return layer config", () => {
let features;
let styleName;