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/components/TOC/fragments/settings/FeatureInfo.jsx b/web/client/components/TOC/fragments/settings/FeatureInfo.jsx index 243dc8dfc00..559cdea5335 100644 --- a/web/client/components/TOC/fragments/settings/FeatureInfo.jsx +++ b/web/client/components/TOC/fragments/settings/FeatureInfo.jsx @@ -9,21 +9,155 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Accordion from '../../../misc/panels/Accordion'; import { getSupportedFormat as getSupportedFormatWMS } from '../../../../api/WMS'; import { getSupportedFormat as getSupportedFormatWFS } from '../../../../api/WFS'; import Loader from '../../../misc/Loader'; -import { Glyphicon } from 'react-bootstrap'; -import Message from '../../../I18N/Message'; +import { Button, Checkbox, FormControl, Glyphicon } from 'react-bootstrap'; +import Select from 'react-select'; +import { DragSource as dragSource, DropTarget as dropTarget } from 'react-dnd'; import includes from 'lodash/includes'; import isEmpty from 'lodash/isEmpty'; -import { getDefaultInfoViewMode } from '../../../../utils/MapInfoUtils'; +import { getDefaultInfoViewMode, getLayerFeatureInfoViews, isLayerFeatureInfoDisabled } from '../../../../utils/MapInfoUtils'; +import Message from '../../../I18N/Message'; +import FeatureInfoEditor from './FeatureInfoEditor'; const supportedFormatRequests = { wms: getSupportedFormatWMS, wfs: getSupportedFormatWFS }; +const FeatureInfoView = ({ + view, + views, + canEdit, + connectDragSource = cmp => cmp, + connectDragPreview = cmp => cmp, + connectDropTarget = cmp => cmp, + isDisabled = false, + isDraggable, + onEdit = () => {}, + onRemove = () => {}, + onUpdateView = () => {}, + renderTypeSelect = () => null +}) => { + const content = ( +
+ {isDraggable ? connectDragSource( +
event.stopPropagation()}> + +
+ ) : ( +
+ +
+ )} + onUpdateView(view.id, { title: event.target.value })}/> +
+ {renderTypeSelect(view)} +
+ + +
+ ); + return isDraggable ? connectDragPreview(connectDropTarget(content)) : content; +}; + +const ITEM_KEY = 'feature-info-view'; + +const drag = dragSource(ITEM_KEY, + { + beginDrag: ({ view, index }) => ({ + id: view.id, + index + }) + }, + (connect, monitor) => ({ + connectDragSource: connect.dragSource(), + connectDragPreview: connect.dragPreview(), + isDragging: monitor.isDragging() + }) +); + +const drop = dropTarget(ITEM_KEY, + { + hover: (props, monitor) => { + const item = monitor.getItem(); + const { index, view, onMove = () => {} } = props; + const node = document.querySelector(`[data-id="feature-info-view-${view.id}"]`); + + if (!node?.getBoundingClientRect) { + return null; + } + const dragIndex = item.index; + const hoverIndex = index; + if (dragIndex === hoverIndex) { + return null; + } + + const hoverBoundingRect = node.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return null; + } + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return null; + } + + onMove(dragIndex, hoverIndex); + item.index = hoverIndex; + return null; + } + }, + (connect, monitor) => ({ + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }) +); + +const DraggableFeatureInfoView = drag(drop(FeatureInfoView)); + /** * Component for rendering FeatureInfo an Accordion with current available format for get feature info * @memberof components.TOC.fragments.settings @@ -50,7 +184,8 @@ export default class extends React.Component { }; state = { - loading: false + loading: false, + editingViewId: null }; componentDidMount() { @@ -69,20 +204,8 @@ export default class extends React.Component { } } - getInfoViews = (infoFormats) => { - return Object.keys(infoFormats).map((infoFormat) => { - const Body = this.props.formatCards[infoFormat] && this.props.formatCards[infoFormat].body; - return { - id: infoFormat, - head: { - preview: , - title: this.props.formatCards[infoFormat] && this.props.formatCards[infoFormat].titleId && || '', - description: this.props.formatCards[infoFormat] && this.props.formatCards[infoFormat].descId && || '', - size: 'sm' - }, - body: Body && || null - }; - }); + getTypeOptions = () => { + return Object.keys(this.transformInfoFormatsToViews(this.supportedInfoFormats())); } transformInfoFormatsToViews = (infoFormats) => { @@ -97,16 +220,113 @@ export default class extends React.Component { return infoFormats; } - render() { - // the selected value if missing on that layer should be set to the general info format value and not the first one. - const data = this.getInfoViews( - this.transformInfoFormatsToViews( - { - 'HIDDEN': true, - ...this.supportedInfoFormats() - } - ) + getFeatureInfo = (disabled, views) => { + const { format, template, viewer, ...featureInfo } = this.props.element.featureInfo || {}; + return { + ...featureInfo, + disabled, + views + }; + } + + updateFeatureInfo = (disabled, views) => { + this.props.onChange("featureInfo", this.getFeatureInfo(disabled, views)); + } + + getViews = () => { + const defaultType = this.getTypeOptions()[0] || 'PROPERTIES'; + return getLayerFeatureInfoViews(this.props.element, { + defaultType, + includeDisabled: true + }); + } + + updateView = (viewId, changes) => { + const views = this.getViews().map((view) => view.id === viewId ? { + ...view, + ...changes + } : view); + this.updateFeatureInfo(isLayerFeatureInfoDisabled(this.props.element), views); + } + + addView = () => { + const views = this.getViews(); + const defaultType = this.getTypeOptions()[0] || 'PROPERTIES'; + this.updateFeatureInfo(isLayerFeatureInfoDisabled(this.props.element), [ + ...views, + { + id: `view-${Date.now()}`, + title: 'Identify', + type: defaultType + } + ]); + } + + removeView = (viewId) => { + const views = this.getViews().filter((view) => view.id !== viewId); + this.updateFeatureInfo(isLayerFeatureInfoDisabled(this.props.element), views); + } + + reorderView = (sourceIndex, targetIndex) => { + const views = this.getViews(); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) { + return; + } + const updatedViews = [...views]; + const [view] = updatedViews.splice(sourceIndex, 1); + updatedViews.splice(targetIndex, 0, view); + this.updateFeatureInfo(isLayerFeatureInfoDisabled(this.props.element), updatedViews); + } + + renderTypeSelect = (view, isDisabled) => { + const options = this.getTypeOptions().map((type) => ({ + value: type, + label: type, + glyph: this.props.formatCards[type]?.glyph || 'ext-empty' + })); + return ( +