diff --git a/docs/developer-guide/mapstore-migration-guide.md b/docs/developer-guide/mapstore-migration-guide.md index 2d3633e3fa..d0a493a30c 100644 --- a/docs/developer-guide/mapstore-migration-guide.md +++ b/docs/developer-guide/mapstore-migration-guide.md @@ -22,6 +22,42 @@ This is a list of things to check if you want to update from a previous version ## Migration from 2023.02.xx to 2024.01.00 +### Adding spatial filter to dashboard widgets + +In order to enable the possibility to add in and the spatial filter to the widgets ( see [#9098](https://github.com/geosolutions-it/MapStore2/issues/9098) ) you have to edit the `QueryPanel` config in the `plugins.dashboard` array of the `localConfig.json` file by adding: + +- **useEmbeddedMap**: flag to enable the embedded map +- **spatialOperations**: The list of spatial operations allowed for this plugin +- **spatialMethodOptions**: the list of spatial methods to use. + +```json +... +"dashboard": [ +... +{ + "name": "QueryPanel", + "cfg": { + "toolsOptions": { + "hideCrossLayer": true, + "useEmbeddedMap": true + }, + "spatialPanelExpanded": false, + "spatialOperations": [ + {"id": "INTERSECTS", "name": "queryform.spatialfilter.operations.intersects"}, + {"id": "CONTAINS", "name": "queryform.spatialfilter.operations.contains"}, + {"id": "WITHIN", "name": "queryform.spatialfilter.operations.within"} + ], + "spatialMethodOptions": [ + {"id": "BBOX", "name": "queryform.spatialfilter.methods.box"}, + {"id": "Circle", "name": "queryform.spatialfilter.methods.circle"}, + {"id": "Polygon", "name": "queryform.spatialfilter.methods.poly"} + ], + "containerPosition": "columns" + } +} + +``` + ### MapFish Print update The **MapFish Print** library has been updated to work with the latest GeoTools version and Java 11 as well as being aligned with the same dependency used by the official GeoServer printing extension (see this issue ) diff --git a/web/client/actions/__tests__/queryform-test.js b/web/client/actions/__tests__/queryform-test.js index 1023d49665..7eb9a24026 100644 --- a/web/client/actions/__tests__/queryform-test.js +++ b/web/client/actions/__tests__/queryform-test.js @@ -83,11 +83,20 @@ import { changeSpatialFilterValue, updateCrossLayerFilterFieldOptions, upsertFilters, - removeFilters + changeMapEditor, + removeFilters, + CHANGE_MAP_EDITOR } from '../queryform'; describe('Test correctness of the queryform actions', () => { + it('changeMapEditor', () => { + var retval = changeMapEditor(null); + + expect(retval).toExist(); + expect(retval.type).toBe(CHANGE_MAP_EDITOR); + expect(retval.mapData).toBe(null); + }); it('addFilterField', () => { let groupId = 1; diff --git a/web/client/actions/__tests__/widgets-test.js b/web/client/actions/__tests__/widgets-test.js index 02ca5741bc..fb3a95e86e 100644 --- a/web/client/actions/__tests__/widgets-test.js +++ b/web/client/actions/__tests__/widgets-test.js @@ -27,8 +27,8 @@ import { DEPENDENCY_SELECTOR_KEY, TOGGLE_TRAY, TOGGLE_MAXIMIZE, - createChart, NEW_CHART, + createChart, exportCSV, exportImage, openFilterEditor, diff --git a/web/client/actions/queryform.js b/web/client/actions/queryform.js index c0828d0bd2..9394cec16a 100644 --- a/web/client/actions/queryform.js +++ b/web/client/actions/queryform.js @@ -58,8 +58,19 @@ export const LOAD_FILTER = 'QUERYFORM:LOAD_FILTER'; export const UPSERT_FILTERS = 'QUERYFORM:UPSERT_FILTERS'; export const REMOVE_FILTERS = 'QUERYFORM:REMOVE_FILTERS'; +export const CHANGE_MAP_EDITOR = "QUERYFORM:CHANGE_MAP_EDITOR"; + import axios from '../libs/ajax'; +/** + * Changes the map config to be used by query form for creating spatial filters + * @param {object} mapData the new map data + */ +export const changeMapEditor = (mapData) => ({ + type: CHANGE_MAP_EDITOR, + mapData +}); + export function addFilterField(groupId) { return { type: ADD_FILTER_FIELD, diff --git a/web/client/components/data/query/QueryBuilder.jsx b/web/client/components/data/query/QueryBuilder.jsx index a316a026ee..ecdbbd1e56 100644 --- a/web/client/components/data/query/QueryBuilder.jsx +++ b/web/client/components/data/query/QueryBuilder.jsx @@ -207,16 +207,19 @@ class QueryBuilder extends React.Component { />); const { spatialMethodOptions, toolsOptions, spatialOperations} = this.props; return this.props.attributes.length > 0 ? - - {this.renderItems('start', { spatialOperations, spatialMethodOptions, ...toolsOptions })} - {this.renderItems('attributes', { spatialOperations, spatialMethodOptions, ...toolsOptions })} - {this.renderItems('afterAttributes', { spatialOperations, spatialMethodOptions, ...toolsOptions })} - {this.renderItems('spatial', { spatialOperations, spatialMethodOptions, ...toolsOptions })} - {this.renderItems('afterSpatial', { spatialOperations, spatialMethodOptions, ...toolsOptions })} - {this.renderItems('layers', { spatialOperations, spatialMethodOptions, ...toolsOptions })} - {this.renderItems('end', { spatialOperations, spatialMethodOptions, ...toolsOptions })} - - :
; + <> + + {this.renderItems('start', { spatialOperations, spatialMethodOptions, ...toolsOptions })} + {this.renderItems('attributes', { spatialOperations, spatialMethodOptions, ...toolsOptions })} + {this.renderItems('afterAttributes', { spatialOperations, spatialMethodOptions, ...toolsOptions })} + {this.renderItems('spatial', { spatialOperations, spatialMethodOptions, ...toolsOptions })} + {this.renderItems('afterSpatial', { spatialOperations, spatialMethodOptions, ...toolsOptions })} + {this.renderItems('layers', { spatialOperations, spatialMethodOptions, ...toolsOptions })} + {this.renderItems('end', { spatialOperations, spatialMethodOptions, ...toolsOptions })} + + {this.renderItems('map', { spatialOperations, spatialMethodOptions, ...toolsOptions })} + + :
; } filterItem = (target, layerName) => (el) => { diff --git a/web/client/components/data/query/SpatialFilter.jsx b/web/client/components/data/query/SpatialFilter.jsx index 18ae5a30ed..5da79f0312 100644 --- a/web/client/components/data/query/SpatialFilter.jsx +++ b/web/client/components/data/query/SpatialFilter.jsx @@ -216,8 +216,8 @@ class SpatialFilter extends React.Component { {this.props.spatialMethodOptions.length > 1 ? this.renderSpatialHeader() : } {this.renderZoneFields()} {this.props.spatialField.method - && this.getMethodFromId(this.props.spatialField.method) - && this.getMethodFromId(this.props.spatialField.method).type === "wfsGeocoder" + && this.getMethodFromId(this.props.spatialField.method) + && this.getMethodFromId(this.props.spatialField.method).type === "wfsGeocoder" ? this.renderRoiPanel() : null} {this.props.spatialOperations.length > 1 ? diff --git a/web/client/components/data/query/__tests__/QueryBuilder-test.jsx b/web/client/components/data/query/__tests__/QueryBuilder-test.jsx index a01f730c12..8d3aa92179 100644 --- a/web/client/components/data/query/__tests__/QueryBuilder-test.jsx +++ b/web/client/components/data/query/__tests__/QueryBuilder-test.jsx @@ -10,21 +10,19 @@ import expect from 'expect'; import React from 'react'; import ReactDOM from 'react-dom'; import TestUtils from 'react-dom/test-utils'; - +import {Provider} from 'react-redux'; import QueryBuilder from '../QueryBuilder'; import standardItemsReference from "../../../../plugins/querypanel/index"; -import SwitchPanel from "../../../misc/switch/SwitchPanel"; +import configureMockStore from 'redux-mock-store'; +const mockStore = configureMockStore(); -const standardItems = Object.keys(standardItemsReference).reduce((prev, cur) => { - return {...prev, [cur]: standardItemsReference[cur].map(el => ({ - ...el, - plugin: () => - }))}; -}, {}); describe('QueryBuilder', () => { - + let store; beforeEach((done) => { + store = mockStore({ + queryform: {} + }); document.body.innerHTML = '
'; setTimeout(done); }); @@ -34,7 +32,13 @@ describe('QueryBuilder', () => { document.body.innerHTML = ''; setTimeout(done); }); - + const standardItems = Object.keys(standardItemsReference).reduce((prev, cur) => { + return {...prev, [cur]: standardItemsReference[cur].map(el => ({ + ...el, + plugin: el.plugin ? (props) => : undefined, + component: el.component ? (props) => : undefined + }))}; + }, {}); it('creates the QueryBuilder component with his default content', () => { const querybuilder = ReactDOM.render(, document.getElementById("container")); expect(querybuilder).toExist(); @@ -198,6 +202,53 @@ describe('QueryBuilder', () => { // only attribute filter should be shown expect(document.querySelectorAll('.mapstore-switch-panel').length).toBe(1); }); + it('useEmbeddedMap', () => { + const groupLevels = 5; + + const groupFields = []; + + const filterFields = [{ + rowId: 100, + groupId: 1, + attribute: "", + operator: null, + value: null, + exception: null + }]; + + const attributes = [{ + id: "Attribute", + type: "list", + values: [ + "attribute1", + "attribute2", + "attribute3", + "attribute4", + "attribute5" + ] + }]; + + const querybuilder = ReactDOM.render( + , + document.getElementById("container") + ); + expect(querybuilder).toExist(); + // only attribute filter should be shown + expect(document.querySelectorAll('.mapstore-switch-panel').length).toBe(2); + expect(document.querySelectorAll('.mapstore-query-map').length).toBe(1); + }); it('creates the QueryBuilder component in error state', () => { diff --git a/web/client/components/map/BaseMap.jsx b/web/client/components/map/BaseMap.jsx index 6da09af94b..ca728de42e 100644 --- a/web/client/components/map/BaseMap.jsx +++ b/web/client/components/map/BaseMap.jsx @@ -43,7 +43,8 @@ class BaseMap extends React.Component { plugins: PropTypes.any, tools: PropTypes.array, getLayerProps: PropTypes.func, - env: PropTypes.array + env: PropTypes.array, + zoomControl: PropTypes.bool }; static defaultProps = { @@ -60,7 +61,8 @@ class BaseMap extends React.Component { onLayerLoading: () => {}, onLayerError: () => {} }, - env: [] + env: [], + zoomControl: false }; getTool = (tool) => { @@ -143,7 +145,7 @@ class BaseMap extends React.Component { projectionDefs={this.props.projectionDefs} style={this.props.styleMap} id={this.props.id} - zoomControl={false} + zoomControl={this.props.zoomControl} center={{ x: 0, y: 0 }} zoom={1} hookRegister={this.props.hookRegister} diff --git a/web/client/components/widgets/builder/wizard/ChartWizard.jsx b/web/client/components/widgets/builder/wizard/ChartWizard.jsx index 914a456eba..3495ea68df 100644 --- a/web/client/components/widgets/builder/wizard/ChartWizard.jsx +++ b/web/client/components/widgets/builder/wizard/ChartWizard.jsx @@ -230,7 +230,7 @@ const ChartWizard = ({ hideButtons className={"chart-options"}> {[ChartOptions, WidgetOptions].map(component => - <> + (<> {component} - + ) )} ); }; diff --git a/web/client/components/widgets/builder/wizard/chart/ColorClassModal.jsx b/web/client/components/widgets/builder/wizard/chart/ColorClassModal.jsx index 17375f9c40..655de29dd2 100644 --- a/web/client/components/widgets/builder/wizard/chart/ColorClassModal.jsx +++ b/web/client/components/widgets/builder/wizard/chart/ColorClassModal.jsx @@ -140,14 +140,14 @@ const ColorClassModal = ({ ColorClassModal.propTypes = { modalClassName: PropTypes.string, - show: PropTypes.boolean, + show: PropTypes.bool, onClose: PropTypes.func, onSaveClassification: PropTypes.func, onChangeClassAttribute: PropTypes.func, classificationAttribute: PropTypes.string, onUpdateClasses: PropTypes.func, options: PropTypes.array, - placeHolder: PropTypes.string, + placeHolder: PropTypes.oneOfType(PropTypes.string, PropTypes.object), classification: PropTypes.array, rangeClassification: PropTypes.array, defaultCustomColor: PropTypes.string, diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index 28387fa81d..9551f09a05 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -814,8 +814,19 @@ "cfg": { "toolsOptions": { "hideCrossLayer": true, - "hideSpatialFilter": true + "useEmbeddedMap": true }, + "spatialPanelExpanded": false, + "spatialOperations": [ + {"id": "INTERSECTS", "name": "queryform.spatialfilter.operations.intersects"}, + {"id": "CONTAINS", "name": "queryform.spatialfilter.operations.contains"}, + {"id": "WITHIN", "name": "queryform.spatialfilter.operations.within"} + ], + "spatialMethodOptions": [ + {"id": "BBOX", "name": "queryform.spatialfilter.methods.box"}, + {"id": "Circle", "name": "queryform.spatialfilter.methods.circle"}, + {"id": "Polygon", "name": "queryform.spatialfilter.methods.poly"} + ], "containerPosition": "columns" } }, diff --git a/web/client/epics/widgets.js b/web/client/epics/widgets.js index 4b6887d8b7..84b57451cb 100644 --- a/web/client/epics/widgets.js +++ b/web/client/epics/widgets.js @@ -1,3 +1,12 @@ +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ + + import Rx from 'rxjs'; import { endsWith, has, get, includes, isEqual, omit, omitBy } from 'lodash'; @@ -18,10 +27,14 @@ import { UPDATE_PROPERTY, replaceWidgets, WIDGETS_MAPS_REGEX, - EDITOR_CHANGE + EDITOR_CHANGE, + OPEN_FILTER_EDITOR } from '../actions/widgets'; +import { changeMapEditor } from '../actions/queryform'; import { MAP_CONFIG_LOADED } from '../actions/config'; +import { TOGGLE_CONTROL } from '../actions/controls'; +import { queryPanelSelector } from '../selectors/controls'; import { availableDependenciesSelector, @@ -30,8 +43,8 @@ import { getFloatingWidgets, getWidgetLayer } from '../selectors/widgets'; - import { CHANGE_LAYER_PROPERTIES, LAYER_LOAD, LAYER_ERROR } from '../actions/layers'; + import { getLayerFromId } from '../selectors/layers'; import { pathnameSelector } from '../selectors/router'; import { isDashboardEditing } from '../selectors/dashboard'; @@ -40,8 +53,10 @@ import { DASHBOARD_LOADED } from '../actions/dashboard'; import { LOCATION_CHANGE } from 'connected-react-router'; import { saveAs } from 'file-saver'; import {downloadCanvasDataURL} from '../utils/FileUtils'; +import {reprojectBbox} from '../utils/CoordinatesUtils'; import converter from 'json-2-csv'; -import { updateDependenciesMapOfMapList } from "../utils/WidgetsUtils"; +import { defaultGetZoomForExtent } from '../utils/MapUtils'; +import { updateDependenciesMapOfMapList, DEFAULT_MAP_SETTINGS } from "../utils/WidgetsUtils"; const updateDependencyMap = (active, targetId, { dependenciesMap, mappings}) => { const tableDependencies = ["layer", "filter", "quickFilters", "options"]; @@ -301,11 +316,43 @@ export const onWidgetCreationFromMap = (action$, store) => const state = store.getState(); const layer = getWidgetLayer(state); if (layer) { - observable$ = Rx.Observable.of(onEditorChange('chart-layers', [layer])); + observable$ = Rx.Observable.of( + onEditorChange('chart-layers', [layer]) + ); } return observable$; }); + +export const onOpenFilterEditorEpic = (action$, store) => + action$.ofType(OPEN_FILTER_EDITOR) + .switchMap(() => { + const state = store.getState(); + const layer = getWidgetLayer(state); + const zoom = defaultGetZoomForExtent(reprojectBbox(layer.bbox.bounds, "EPSG:4326", "EPSG:3857", true), DEFAULT_MAP_SETTINGS.size, 0, 21, 96, DEFAULT_MAP_SETTINGS.resolutions); + const map = { + ...DEFAULT_MAP_SETTINGS, + zoom, + center: { + crs: layer.bbox.crs, + x: (layer.bbox.bounds.maxx + layer.bbox.bounds.minx) / 2, + y: (layer.bbox.bounds.maxy + layer.bbox.bounds.miny) / 2 + } + }; + const mapData = layer?.bbox ? map : null; + return Rx.Observable.of( changeMapEditor(mapData) ); + }); + + +export const onResetMapEpic = (action$, store) => + action$.ofType(TOGGLE_CONTROL) + .filter((type, control) => !queryPanelSelector(store.getState()) && control === "queryPanel" && isDashboardEditing(store.getState())) + .switchMap(() => { + return Rx.Observable.of( + changeMapEditor(null) + ); + }); + export default { exportWidgetData, alignDependenciesToWidgets, @@ -315,5 +362,7 @@ export default { updateLayerOnLayerPropertiesChange, updateLayerOnLoadingErrorChange, updateDependenciesMapOnMapSwitch, - onWidgetCreationFromMap + onWidgetCreationFromMap, + onOpenFilterEditorEpic, + onResetMapEpic }; diff --git a/web/client/plugins/QueryPanel.jsx b/web/client/plugins/QueryPanel.jsx index c1f323b393..77330ff2c6 100644 --- a/web/client/plugins/QueryPanel.jsx +++ b/web/client/plugins/QueryPanel.jsx @@ -20,6 +20,7 @@ import { changeDrawingStatus } from '../actions/draw'; import { getLayerCapabilities } from '../actions/layerCapabilities'; import { queryPanelSelector } from '../selectors/controls'; import { applyFilter, discardCurrentFilter, storeCurrentFilter } from '../actions/layerFilter'; + import { changeGroupProperties, changeLayerProperties, @@ -74,6 +75,7 @@ import queryFormEpics from '../epics/queryform'; import {featureTypeSelectedEpic, redrawSpatialFilterEpic, viewportSelectedEpic, wfsQueryEpic} from '../epics/wfsquery'; import layerFilterReducers from '../reducers/layerFilter'; import queryReducers from '../reducers/query'; +import drawReducers from '../reducers/draw'; import queryformReducers from '../reducers/queryform'; import { isDashboardAvailable } from '../selectors/dashboard'; import { groupsSelector, selectedLayerLoadingErrorSelector } from '../selectors/layers'; @@ -212,41 +214,21 @@ const tocSelector = createSelector( class QueryPanel extends React.Component { static propTypes = { - id: PropTypes.number, - buttonContent: PropTypes.node, - groups: PropTypes.array, - settings: PropTypes.object, - queryPanelEnabled: PropTypes.bool, - groupStyle: PropTypes.object, - groupPropertiesChangeHandler: PropTypes.func, - layerPropertiesChangeHandler: PropTypes.func, - onToggleGroup: PropTypes.func, - onToggleLayer: PropTypes.func, - onToggleQuery: PropTypes.func, - onZoomToExtent: PropTypes.func, - retrieveLayerData: PropTypes.func, - onSort: PropTypes.func, - onInit: PropTypes.func, - onSettings: PropTypes.func, - hideSettings: PropTypes.func, - updateSettings: PropTypes.func, - updateNode: PropTypes.func, - removeNode: PropTypes.func, - activateRemoveLayer: PropTypes.bool, - activateLegendTool: PropTypes.bool, - activateZoomTool: PropTypes.bool, - activateSettingsTool: PropTypes.bool, - visibilityCheckType: PropTypes.string, - settingsOptions: PropTypes.object, - layout: PropTypes.object, - toolsOptions: PropTypes.object, - appliedFilter: PropTypes.object, - storedFilter: PropTypes.object, advancedToolbar: PropTypes.bool, - onSaveFilter: PropTypes.func, - onRestoreFilter: PropTypes.func, + appliedFilter: PropTypes.object, items: PropTypes.array, - selectedLayer: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]) + layout: PropTypes.object, + loadingError: PropTypes.bool, + onInit: PropTypes.func, + onRestoreFilter: PropTypes.func, + onSaveFilter: PropTypes.func, + onToggleQuery: PropTypes.func, + queryPanelEnabled: PropTypes.bool, + selectedLayer: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + spatialMethodOptions: PropTypes.array, + spatialOperations: PropTypes.array, + storedFilter: PropTypes.object, + toolsOptions: PropTypes.object }; static defaultProps = { @@ -279,11 +261,15 @@ class QueryPanel extends React.Component { super(props); this.state = {showModal: false}; } + UNSAFE_componentWillReceiveProps(newProps) { - if (newProps.queryPanelEnabled === true && this.props.queryPanelEnabled === false) { + // triggering the init only if not using the embedded map since this was happening too early + // making the redraw of spatial filter not happening + if (!newProps.toolsOptions.useEmbeddedMap && newProps.queryPanelEnabled === true && this.props.queryPanelEnabled === false) { this.props.onInit(); } } + getNoBackgroundLayers = (group) => { return group.name !== 'background'; }; @@ -295,6 +281,7 @@ class QueryPanel extends React.Component { sidebar={this.renderQueryPanel()} sidebarClassName="query-form-panel-container" touch={false} + rootClassName="query-form-root" styles={{ sidebar: { ...this.props.layout, @@ -336,6 +323,7 @@ class QueryPanel extends React.Component { this.props.onSaveFilter(); this.props.onToggleQuery(); } + renderQueryPanel = () => { return (
* @prop {boolean} cfg.toolsOptions.hideCrossLayer force cross layer filter panel to hide (when is not used or not usable) * @prop {boolean} cfg.toolsOptions.hideAttributeFilter force attribute filter panel to hide (when is not used or not usable). In general any `hide${CapitailizedItemId}` works to hide a particular panel of the query panel. * @prop {boolean} cfg.toolsOptions.hideSpatialFilter force spatial filter panel to hide (when is not used or not usable) + * @prop {boolean} cfg.toolsOptions.useEmbeddedMap if spatial filter panel is present, this option allows to use the embedded map instead of the map plugin * * @example * // This example configure a layer with polygons geometry as spatial filter method @@ -517,6 +506,7 @@ const QueryPanelPlugin = connect(tocSelector, { export default { QueryPanelPlugin, reducers: { + draw: drawReducers, queryform: queryformReducers, query: queryReducers, layerFilter: layerFilterReducers diff --git a/web/client/plugins/querypanel/MapWithDraw.jsx b/web/client/plugins/querypanel/MapWithDraw.jsx new file mode 100644 index 0000000000..32e02c836b --- /dev/null +++ b/web/client/plugins/querypanel/MapWithDraw.jsx @@ -0,0 +1,55 @@ +/** +* Copyright 2023, GeoSolutions Sas. +* All rights reserved. +* +* This source code is licensed under the BSD-style license found in the +* LICENSE file in the root directory of this source tree. +*/ +import React from 'react'; +import { compose } from 'recompose'; +import PropTypes from 'prop-types'; + +import BaseMapComp from '../../components/map/BaseMap'; +import autoMapType from '../../components/map/enhancers/autoMapType'; +import autoResize from '../../components/map/enhancers/autoResize'; +import mapType from '../../components/map/enhancers/mapType'; +import onMapViewChanges from '../../components/map/enhancers/onMapViewChanges'; +import withDraw from '../../components/map/enhancers/withDraw'; +import mapEnhancer from './enhancers/mapEnhancer'; + +const MapWitDrawComp = compose( + mapEnhancer, + onMapViewChanges, + autoResize(0), + autoMapType, + mapType, + withDraw() +)(BaseMapComp); + + +const MapWithDraw = ({ + map, + mapStateSource, + layer = {}, + onMapReady = () => {} +}) => { + return map ? ( + + ) : null; +}; + +MapWithDraw.propTypes = { + map: PropTypes.object, + mapStateSource: PropTypes.string, + onMapReady: PropTypes.bool, + layer: PropTypes.object +}; + +export default MapWithDraw; diff --git a/web/client/plugins/querypanel/SpatialFilter.jsx b/web/client/plugins/querypanel/SpatialFilter.jsx index 1d31ec2819..09afed2e18 100644 --- a/web/client/plugins/querypanel/SpatialFilter.jsx +++ b/web/client/plugins/querypanel/SpatialFilter.jsx @@ -5,8 +5,10 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ +import React from 'react'; import {connect} from "react-redux"; import {mapSelector} from "../../selectors/map"; +import {getMapConfigSelector} from "../../selectors/queryform"; import {bindActionCreators} from "redux"; import { changeDwithinValue, @@ -21,14 +23,12 @@ import { import {changeDrawingStatus} from "../../actions/draw"; import SpatialFilterComponent from "../../components/data/query/SpatialFilter"; -const SpatialFilter = connect((state) => { +const BaseSpatialFilter = connect((state) => { return { useMapProjection: state.queryform.useMapProjection, spatialField: state.queryform.spatialField, showDetailsPanel: state.queryform.showDetailsPanel, - spatialPanelExpanded: state.queryform.spatialPanelExpanded, - zoom: (mapSelector(state) || {}).zoom, - projection: (mapSelector(state) || {}).projection + spatialPanelExpanded: state.queryform.spatialPanelExpanded }; }, dispatch => { return { @@ -49,4 +49,24 @@ const SpatialFilter = connect((state) => { }; })(SpatialFilterComponent); +/** + * Connected to the Map plugin + */ +const SpatialFilterMapPlugin = connect((state) => ({ + zoom: (mapSelector(state) || {}).zoom, + projection: (mapSelector(state) || {}).projection +}))(BaseSpatialFilter); + +/** + * Connected to the embedded map of the query panel + */ +export const SpatialFilterEmbeddedMap = connect((state) => ({ + zoom: (getMapConfigSelector(state) || {}).zoom, + projection: (getMapConfigSelector(state) || {}).projection +}))(BaseSpatialFilter); + +export const SpatialFilter = ({useEmbeddedMap, ...props}) => { + return useEmbeddedMap ? : ; +}; + export default SpatialFilter; diff --git a/web/client/plugins/querypanel/SpatialFilterMap.jsx b/web/client/plugins/querypanel/SpatialFilterMap.jsx new file mode 100644 index 0000000000..97f66e0e62 --- /dev/null +++ b/web/client/plugins/querypanel/SpatialFilterMap.jsx @@ -0,0 +1,59 @@ +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +import React from 'react'; + +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import Portal from '../../components/misc/Portal'; + +import withContainer from '../../components/misc/WithContainer'; + +import MapWithDraw from './MapWithDraw'; +import { + getWidgetLayer +} from '../../selectors/widgets'; +import { + getMapConfigSelector +} from '../../selectors/queryform'; +import { + initQueryPanel +} from '../../actions/wfsquery'; + +/** + * Component connected to the widgetLayer + */ +export const MapComponent = connect( + createSelector([ + getWidgetLayer, + getMapConfigSelector + ], (layer, map) => { + return { + layer, + map, + mapStateSource: "wizardMap" + }; + } + ), { + onMapReady: initQueryPanel + } )(MapWithDraw); + +export default withContainer((props) => { + const { + container, + useEmbeddedMap, + hideSpatialFilter, + queryPanelEnabled + } = props; + return useEmbeddedMap && !hideSpatialFilter && queryPanelEnabled ? + ( +
+ +
+
) + : null; +}); diff --git a/web/client/plugins/querypanel/enhancers/mapEnhancer.js b/web/client/plugins/querypanel/enhancers/mapEnhancer.js new file mode 100644 index 0000000000..aca260d504 --- /dev/null +++ b/web/client/plugins/querypanel/enhancers/mapEnhancer.js @@ -0,0 +1,93 @@ +/** +* Copyright 2023, GeoSolutions Sas. +* All rights reserved. +* +* This source code is licensed under the BSD-style license found in the +* LICENSE file in the root directory of this source tree. +*/ +import { compose, withStateHandlers, defaultProps, withPropsOnChange, withProps } from 'recompose'; +import { isEmpty } from 'lodash'; + +import { getCenterForExtent, getZoomForExtent, createRegisterHooks, ZOOM_TO_EXTENT_HOOK } from '../../../utils/MapUtils'; +import { reprojectBbox, getExtentFromViewport } from '../../../utils/CoordinatesUtils'; + +const defaultBaseLayer = { + group: "background", + id: "mapnik__0", + loading: false, + loadingError: false, + name: "mapnik", + source: "osm", + title: "Open Street Map", + type: "osm", + visibility: true +}; + + +const mapEnhancer = compose( + defaultProps({ + onMapReady: () => {}, + baseLayer: defaultBaseLayer + + }), + withPropsOnChange("baseLayer", props => { + return {layers: [props.baseLayer]}; + }), + withPropsOnChange(["id"], + ({hookRegister = null}) => ({ + hookRegister: hookRegister || createRegisterHooks() + })), + withStateHandlers(() => ({ + initialized: false + }), + { + onMapViewChanges: () => (map) => ( {map}), + onLayerLoad: ({map, initialized}, {onMapReady, baseLayer, hookRegister, layer}) => (layerId) => { + // Map is ready when background is loaded just first load + if (!initialized && layerId === baseLayer.id) { + onMapReady(map); + const bounds4326 = layer.bbox.bounds; + const hook = hookRegister.getHook(ZOOM_TO_EXTENT_HOOK); + if (hook) { + // trigger "internal" zoom to extent + hook(bounds4326, { + crs: layer.bbox.crs, + maxZoom: 21 + }); + } + return {initialized: true}; + } + return {}; + }, + centerLayer: (state, {layer: l}) => (map) => { + if (isEmpty(l)) { + return {}; + } + const newBbox = getExtentFromViewport(l.bbox, l.bbox.crs); + const center = getCenterForExtent(newBbox, l.bbox.crs); + const extent = l.bbox.crs !== (map.projection || "EPSG:3857") ? reprojectBbox(l.bbox.bounds, l.bbox.crs, map.projection || "EPSG:3857") : l.bbox.extent; + let zoom = 1; + if (map?.size) { + zoom = getZoomForExtent(extent, map.size, 0, 21); + } + const {bbox: omit, ...om} = map; + return {map: {...om, zoom, center, extent, mapStateSource: "mapModal"}}; + } + } + ), + withProps(props => { + return {layers: props.layers.concat(props.layer || [])}; + }), + withPropsOnChange(({map = {}}, {map: nM}) => { + return (!map?.size && nM?.size); + }, + ({ centerLayer, map}) => { + if (map?.size) { + centerLayer(map); + } + return {}; + }), + withProps(({onLayerLoad}) => ({eventHandlers: {onLayerLoad}})) +); +mapEnhancer.displayName = 'mapEnhancer'; +export default mapEnhancer; diff --git a/web/client/plugins/querypanel/index.js b/web/client/plugins/querypanel/index.js index a94877dc46..26bbe0dd1e 100644 --- a/web/client/plugins/querypanel/index.js +++ b/web/client/plugins/querypanel/index.js @@ -1,6 +1,7 @@ import AttributeFilter from "./AttributeFilter"; import SpatialFilter from "./SpatialFilter"; import CrossLayerFilter from "./CrossLayerFilter"; +import SpatialFilterMap from './SpatialFilterMap'; const standardItems = { start: [], @@ -30,7 +31,13 @@ const standardItems = { position: 1 } ], - end: [] + end: [], + map: [{ + id: "spatialFilterMap", + plugin: SpatialFilterMap, + cfg: {}, + position: 1 + }] }; export default standardItems; diff --git a/web/client/reducers/__tests__/queryform-test.js b/web/client/reducers/__tests__/queryform-test.js index a5e9abfd2d..429afb9174 100644 --- a/web/client/reducers/__tests__/queryform-test.js +++ b/web/client/reducers/__tests__/queryform-test.js @@ -23,13 +23,28 @@ import { removeCrossLayerFilterField, changeSpatialFilterValue, upsertFilters, - removeFilters + removeFilters, + changeMapEditor } from '../../actions/queryform'; import { END_DRAWING, CHANGE_DRAWING_STATUS } from '../../actions/draw'; +import { setEditing } from '../../actions/dashboard'; +import { insertWidget } from '../../actions/widgets'; describe('Test the queryform reducer', () => { + it('CHANGE_MAP_EDITOR', () => { + const state = queryform(undefined, changeMapEditor(null)); + expect(state.map).toEqual(null); + }); + it('DASHBOARD:SET_EDITING', () => { + const state = queryform(undefined, setEditing(false)); + expect(state.map).toEqual(null); + }); + it('WIDGETS:INSERT', () => { + const state = queryform(undefined, insertWidget({})); + expect(state.map).toEqual(null); + }); it('returns the initial state on unrecognized action', () => { const initialState = { diff --git a/web/client/reducers/queryform.js b/web/client/reducers/queryform.js index 75ce6ca72b..d0815eb446 100644 --- a/web/client/reducers/queryform.js +++ b/web/client/reducers/queryform.js @@ -49,7 +49,9 @@ import { LOAD_FILTER, UPDATE_CROSS_LAYER_FILTER_FIELD_OPTIONS, UPSERT_FILTERS, - REMOVE_FILTERS + REMOVE_FILTERS, + CHANGE_MAP_EDITOR, + QUERY_FORM_SEARCH } from '../actions/queryform'; import { END_DRAWING, CHANGE_DRAWING_STATUS } from '../actions/draw'; @@ -85,7 +87,8 @@ const initialState = { operation: "INTERSECTS", geometry: null }, - simpleFilterFields: [] + simpleFilterFields: [], + map: null }; const updateFilterField = (field = {}, action = {}) => { @@ -102,6 +105,18 @@ const updateFilterField = (field = {}, action = {}) => { function queryform(state = initialState, action) { switch (action.type) { + case CHANGE_MAP_EDITOR: { + return { + ...state, + map: action.mapData + }; + } + case QUERY_FORM_SEARCH: { + return { + ...state, + map: null + }; + } case ADD_FILTER_FIELD: { // // Calculate the key number, this should be different for each new element @@ -368,7 +383,8 @@ function queryform(state = initialState, action) { return assign({}, state, initialState, { spatialField, crossLayerFilter, - filters: [] + filters: [], + map: state.map }); } case SHOW_GENERATED_FILTER: { diff --git a/web/client/reducers/widgets.js b/web/client/reducers/widgets.js index 38f31236ab..16b5bf2506 100644 --- a/web/client/reducers/widgets.js +++ b/web/client/reducers/widgets.js @@ -54,6 +54,7 @@ const emptyState = { } }, builder: { + map: null, settings: { step: 0 } diff --git a/web/client/selectors/__tests__/controls-test.js b/web/client/selectors/__tests__/controls-test.js index 4ffabf555c..7950d9c392 100644 --- a/web/client/selectors/__tests__/controls-test.js +++ b/web/client/selectors/__tests__/controls-test.js @@ -57,7 +57,6 @@ describe('Test controls selectors', () => { expect(retVal).toExist(); expect(retVal).toBe(true); }); - it('test wfsDownloadSelector', () => { const retVal = wfsDownloadSelector(state); expect(retVal).toExist(); diff --git a/web/client/selectors/__tests__/queryform-test.js b/web/client/selectors/__tests__/queryform-test.js index fd78c9c715..055f59a005 100644 --- a/web/client/selectors/__tests__/queryform-test.js +++ b/web/client/selectors/__tests__/queryform-test.js @@ -8,11 +8,14 @@ import expect from 'expect'; +import { set } from '../../utils/ImmutableUtils'; + import { availableCrossLayerFilterLayersSelector, spatialFieldSelector, spatialFieldGeomSelector, spatialFieldGeomTypeSelector, + getMapConfigSelector, spatialFieldGeomProjSelector, spatialFieldGeomCoordSelector, spatialFieldMethodSelector, @@ -97,6 +100,10 @@ const initialState = { }; describe('Test queryform selectors', () => { + it('getMapConfigSelector ', () => { + const state = set(`queryform.map`, { id: "map-id" }, {}); + expect(getMapConfigSelector(state)).toEqual({ id: "map-id" }); + }); it('spatialFieldSelector', () => { const spatialfield = spatialFieldSelector(initialState); expect(spatialfield).toExist(); diff --git a/web/client/selectors/queryform.js b/web/client/selectors/queryform.js index e4d529b491..b5cf6d5bfe 100644 --- a/web/client/selectors/queryform.js +++ b/web/client/selectors/queryform.js @@ -12,6 +12,7 @@ import { layersSelector } from './layers'; import { currentLocaleSelector } from './locale'; import { getLocalizedProp } from '../utils/LocaleUtils'; +export const getMapConfigSelector = state => get(state, "queryform.map"); export const crossLayerFilterSelector = state => get(state, "queryform.crossLayerFilter"); // TODO we should also check if the layer are from the same source to allow cross layer filtering export const availableCrossLayerFilterLayersSelector = state =>(layersSelector(state) || []).filter(({type, group} = {}) => type === "wms" && group !== "background").map(({title, ...layer}) => ({...layer, title: getLocalizedProp(currentLocaleSelector(state), title)})); diff --git a/web/client/selectors/widgets.js b/web/client/selectors/widgets.js index d4bba4b428..40472ce7df 100644 --- a/web/client/selectors/widgets.js +++ b/web/client/selectors/widgets.js @@ -18,6 +18,7 @@ import { createSelector, createStructuredSelector } from 'reselect'; import { createShallowSelector } from '../utils/ReselectUtils'; import { getAttributesNames } from "../utils/FeatureGridUtils"; + export const getEditorSettings = state => get(state, "widgets.builder.settings"); export const getDependenciesMap = s => get(s, "widgets.dependencies") || {}; export const getDependenciesKeys = s => Object.keys(getDependenciesMap(s)).map(k => getDependenciesMap(s)[k]); diff --git a/web/client/themes/default/less/dashboard.less b/web/client/themes/default/less/dashboard.less index e35a1ff7f3..4fdcee055b 100644 --- a/web/client/themes/default/less/dashboard.less +++ b/web/client/themes/default/less/dashboard.less @@ -35,6 +35,7 @@ position: relative; overflow: auto; } + } .dashboard-editor { diff --git a/web/client/themes/default/less/query-panel.less b/web/client/themes/default/less/query-panel.less index d26059ab83..19a474c036 100644 --- a/web/client/themes/default/less/query-panel.less +++ b/web/client/themes/default/less/query-panel.less @@ -38,6 +38,10 @@ .border-left-color-var(@theme-vars[main-color]); } } + .mapstore-query-map { + .background-color-var(@theme-vars[main-bg]); + box-shadow: inset 0 0 10px #6e6e6e; + } } // ************** @@ -228,3 +232,22 @@ } } } +.mapstore-query-map { + position: absolute; + top: 0; + z-index: 1000; + /* left: 0; */ + bottom: 0; + right: 0; + width: calc(100% - 600px); // TODO: 600px is width of query panel. It should be calculated dynamically or set in config + padding: 10px; + animation: query-map-fade-in 0.3s ease-in; +} +@keyframes query-map-fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index fe46cd87f8..72d655d4c4 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -2150,6 +2150,7 @@ }, "mapSync": "Live-Filter nach Ansichtsfenster", "displayLegend": { + "default": "Legende anzeigen", "line": "Legende anzeigen", "pie": "Legende anzeigen", "bar": "Legende anzeigen", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 201b875908..d7f1b70bc0 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -2113,6 +2113,7 @@ }, "mapSync": "Live Filter by viewport", "displayLegend": { + "default": "Display Legend", "line": "Display Legend", "pie": "Display Legend", "bar": "Display Legend", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index 0276f9611d..344b80b0c5 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -2113,6 +2113,7 @@ }, "mapSync": "Filtro dinámico por extensión de la vista actual", "displayLegend": { + "default": "Mostrar leyenda", "line": "Mostrar leyenda", "pie": "Mostrar leyenda", "bar": "Mostrar leyenda", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index bccacf0974..bf60b12b8c 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -2113,6 +2113,7 @@ }, "mapSync": "Live Filter par viewport", "displayLegend": { + "default": "Afficher la légende", "line": "Afficher la légende", "pie": "Afficher la légende", "bar": "Afficher la légende", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 08055cf4e1..8e0412c991 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -2114,6 +2114,7 @@ }, "mapSync": "Filtra dati sull'area visibile", "displayLegend": { + "default": "Mostra legenda", "line": "Mostra legenda", "pie": "Mostra legenda", "bar": "Mostra legenda", diff --git a/web/client/utils/CoordinatesUtils.js b/web/client/utils/CoordinatesUtils.js index 7d1e8d5f9e..37d6314e34 100644 --- a/web/client/utils/CoordinatesUtils.js +++ b/web/client/utils/CoordinatesUtils.js @@ -1035,6 +1035,20 @@ export const transformExtentToObj = (extent) => { }; }; +/** + * helper use to transform the extent object to array { minx, miny, maxx, maxy } + * if there is no provided param extent it will return the default bound object of wgs84 + * @param {object} bounds is an object in the shape {minx, miny, maxx, maxy} + * @return {number[]} extent is an array of 4 ordered coordinates [minx, miny, maxx, maxy] + */ +export const transformExtentToArray = (bounds) => { + return [ + bounds.minx, + bounds.miny, + bounds.maxx, + bounds.maxy + ]; +}; /** diff --git a/web/client/utils/WidgetsUtils.js b/web/client/utils/WidgetsUtils.js index 53242cd8d7..fa8af34a22 100644 --- a/web/client/utils/WidgetsUtils.js +++ b/web/client/utils/WidgetsUtils.js @@ -331,3 +331,60 @@ export const getSelectedWidgetData = (widget = {}) => { } return widget; }; + +export const DEFAULT_MAP_SETTINGS = { + projection: 'EPSG:900913', + units: 'm', + center: { + x: 11.22894105149402, + y: 43.380053862794, + crs: 'EPSG:4326' + }, + maxExtent: [ + -20037508.34, + -20037508.34, + 20037508.34, + 20037508.34 + ], + mapId: null, + size: { + width: 1300, + height: 920 + }, + version: 2, + limits: {}, + mousePointer: 'pointer', + resolutions: [ + 156543.03392804097, + 78271.51696402048, + 39135.75848201024, + 19567.87924100512, + 9783.93962050256, + 4891.96981025128, + 2445.98490512564, + 1222.99245256282, + 611.49622628141, + 305.748113140705, + 152.8740565703525, + 76.43702828517625, + 38.21851414258813, + 19.109257071294063, + 9.554628535647032, + 4.777314267823516, + 2.388657133911758, + 1.194328566955879, + 0.5971642834779395, + 0.29858214173896974, + 0.14929107086948487, + 0.07464553543474244, + 0.03732276771737122, + 0.01866138385868561, + 0.009330691929342804, + 0.004665345964671402, + 0.002332672982335701, + 0.0011663364911678506, + 0.0005831682455839253, + 0.00029158412279196264, + 0.00014579206139598132 + ] +}; diff --git a/web/client/utils/__tests__/CoordinatesUtils-test.js b/web/client/utils/__tests__/CoordinatesUtils-test.js index a019bc2955..8d73c4a703 100644 --- a/web/client/utils/__tests__/CoordinatesUtils-test.js +++ b/web/client/utils/__tests__/CoordinatesUtils-test.js @@ -37,7 +37,11 @@ import { makeNumericEPSG, getPolygonFromCircle, checkIfLayerFitsExtentForProjection, - getLonLatFromPoint, convertRadianToDegrees, convertDegreesToRadian, transformExtentToObj + getLonLatFromPoint, + convertRadianToDegrees, + convertDegreesToRadian, + transformExtentToObj, + transformExtentToArray } from '../CoordinatesUtils'; import Proj4js from 'proj4'; @@ -781,6 +785,11 @@ describe('CoordinatesUtils', () => { }); }); }); + it('test transformExtentToArray', ()=>{ + const extent = [1, 1, 5, 5]; + const bounds = { minx: 1, miny: 1, maxx: 5, maxy: 5 }; + expect(transformExtentToArray(bounds)).toEqual(extent); + }); it('extractCrsFromURN #1', () => { const urn = 'urn:ogc:def:crs:EPSG:6.6:4326'; expect(extractCrsFromURN(urn)).toBe('EPSG:4326');