From 73d51f83f8d1b5c2bf46cd4c6eda2f91e497517f Mon Sep 17 00:00:00 2001 From: mahmoudadel54 Date: Wed, 25 Oct 2023 11:42:05 +0300 Subject: [PATCH] #9567: implement the new approach in zoom to records in table widgets + writing unit tests --- .../__tests__/onMapViewChanges-test.js | 22 ++++++ .../map/enhancers/onMapViewChanges.js | 5 +- web/client/components/map/openlayers/Map.jsx | 46 ++++++------ .../map/openlayers/__tests__/Map-test.jsx | 4 +- .../builder/wizard/table/TableOptions.jsx | 2 + .../__tests__/dependenciesToZoomTo-test.jsx | 21 +++--- .../enhancers/__tests__/tableWidget-test.jsx | 39 ++++++++++ .../widgets/enhancers/dependenciesToExtent.js | 6 +- .../widgets/enhancers/dependenciesToZoomTo.js | 23 ++++-- .../widgets/enhancers/tableWidget.js | 71 ++++++++++++++----- web/client/plugins/Widgets.jsx | 9 ++- web/client/plugins/featuregrid/gridTools.jsx | 5 +- web/client/selectors/widgets.js | 5 ++ 13 files changed, 189 insertions(+), 69 deletions(-) diff --git a/web/client/components/map/enhancers/__tests__/onMapViewChanges-test.js b/web/client/components/map/enhancers/__tests__/onMapViewChanges-test.js index e3648253fa..8dde240dea 100644 --- a/web/client/components/map/enhancers/__tests__/onMapViewChanges-test.js +++ b/web/client/components/map/enhancers/__tests__/onMapViewChanges-test.js @@ -44,5 +44,27 @@ describe('onMapViewChanges enhancer', () => { expect(map.mapStateSource).toExist(); expect(map.projection).toExist(); }); + it('onMapViewChanges rendering with zoomToExtentHandler', () => { + const Sink = onMapViewChanges(createSink( props => { + expect(props.eventHandlers.onMapViewChanges).toExist(); + setTimeout(props.eventHandlers.onMapViewChanges("CENTER", "ZOOM", { bbox: { x: 2 } }, "SIZE", "mapStateSource", "projection", {}, "RESOLUTION", "ORINATE", () => {})); + + })); + const actions = { + onMapViewChanges: () => {} + }; + const spy = expect.spyOn(actions, 'onMapViewChanges'); + ReactDOM.render(, document.getElementById("container")); + expect(spy).toHaveBeenCalled(); + const map = spy.calls[0].arguments[0]; + expect(map).toExist(); + expect(map.center).toExist(); + expect(map.zoom).toExist(); + expect(map.bbox).toExist(); + expect(map.size).toExist(); + expect(map.mapStateSource).toExist(); + expect(map.projection).toExist(); + expect(map.zoomToExtentHandler).toExist(); + }); }); diff --git a/web/client/components/map/enhancers/onMapViewChanges.js b/web/client/components/map/enhancers/onMapViewChanges.js index 27238cb940..b1d6ffc9d3 100644 --- a/web/client/components/map/enhancers/onMapViewChanges.js +++ b/web/client/components/map/enhancers/onMapViewChanges.js @@ -14,7 +14,7 @@ import { compose, withHandlers, withPropsOnChange } from 'recompose'; */ export default compose( withHandlers({ - onMapViewChanges: ({ map = {}, onMapViewChanges = () => {}}) => (center, zoom, bbox, size, mapStateSource, projection, viewerOptions, resolution, orientate) => { + onMapViewChanges: ({ map = {}, onMapViewChanges = () => {}}) => (center, zoom, bbox, size, mapStateSource, projection, viewerOptions, resolution, orientate, zoomToExtentHandler) => { onMapViewChanges({ ...map, center, @@ -27,7 +27,8 @@ export default compose( mapStateSource, projection, resolution, - orientate + orientate, + zoomToExtentHandler }); } }), diff --git a/web/client/components/map/openlayers/Map.jsx b/web/client/components/map/openlayers/Map.jsx index 2268f7ac62..3bee6010e8 100644 --- a/web/client/components/map/openlayers/Map.jsx +++ b/web/client/components/map/openlayers/Map.jsx @@ -491,7 +491,9 @@ class OpenlayersMap extends React.Component { this.props.id, this.props.projection, undefined, // viewerOptions, - view.getResolution() // resolution + view.getResolution(), // resolution + undefined, + this.zoomToExtentHandler ); } }; @@ -561,6 +563,26 @@ class OpenlayersMap extends React.Component { } }; + zoomToExtentHandler = (extent, { padding, crs, maxZoom: zoomLevel, duration, nearest} = {})=> { + let bounds = reprojectBbox(extent, crs, this.props.projection); + // TODO: improve this to manage all degenerated bounding boxes. + if (bounds && bounds[0] === bounds[2] && bounds[1] === bounds[3] && + crs === "EPSG:4326" && isArray(extent) && extent[0] === -180 && extent[1] === -90) { + bounds = this.map.getView().getProjection().getExtent(); + } + let maxZoom = zoomLevel; + if (bounds && bounds[0] === bounds[2] && bounds[1] === bounds[3] && isNil(maxZoom)) { + maxZoom = 21; // TODO: allow to this maxZoom to be customizable + } + this.map.getView().fit(bounds, { + size: this.map.getSize(), + padding: padding && [padding.top || 0, padding.right || 0, padding.bottom || 0, padding.left || 0], + maxZoom, + duration, + nearest + }); + } + registerHooks = () => { this.props.hookRegister.registerHook(mapUtils.RESOLUTIONS_HOOK, (srs) => { return this.getResolutions(srs); @@ -590,27 +612,7 @@ class OpenlayersMap extends React.Component { this.props.hookRegister.registerHook(mapUtils.GET_COORDINATES_FROM_PIXEL_HOOK, (pixel) => { return this.map.getCoordinateFromPixel(pixel); }); - this.props.hookRegister.registerHook(mapUtils.ZOOM_TO_EXTENT_HOOK, (extent, { padding, crs, maxZoom: zoomLevel, duration, nearest} = {}) => { - let bounds = reprojectBbox(extent, crs, this.props.projection); - // if EPSG:4326 with max extent (-180, -90, 180, 90) bounds are 0,0,0,0. In this case zoom to max extent - // TODO: improve this to manage all degenerated bounding boxes. - if (bounds && bounds[0] === bounds[2] && bounds[1] === bounds[3] && - crs === "EPSG:4326" && isArray(extent) && extent[0] === -180 && extent[1] === -90) { - bounds = this.map.getView().getProjection().getExtent(); - } - let maxZoom = zoomLevel; - if (bounds && bounds[0] === bounds[2] && bounds[1] === bounds[3] && isNil(maxZoom)) { - maxZoom = 21; // TODO: allow to this maxZoom to be customizable - } - - this.map.getView().fit(bounds, { - size: this.map.getSize(), - padding: padding && [padding.top || 0, padding.right || 0, padding.bottom || 0, padding.left || 0], - maxZoom, - duration, - nearest - }); - }); + this.props.hookRegister.registerHook(mapUtils.ZOOM_TO_EXTENT_HOOK, this.zoomToExtentHandler); }; } diff --git a/web/client/components/map/openlayers/__tests__/Map-test.jsx b/web/client/components/map/openlayers/__tests__/Map-test.jsx index b9900912f7..5ced20bdef 100644 --- a/web/client/components/map/openlayers/__tests__/Map-test.jsx +++ b/web/client/components/map/openlayers/__tests__/Map-test.jsx @@ -531,7 +531,7 @@ describe('OpenlayersMap', () => { olMap.on('moveend', () => { // The first call is triggered as soon as the map component is mounted, the second one is as a result of setZoom expect(spy.calls.length).toEqual(2); - expect(spy.calls[1].arguments.length).toEqual(8); + expect(spy.calls[1].arguments.length).toEqual(10); expect(normalizeFloat(spy.calls[1].arguments[0].y, 1)).toBe(43.9); expect(normalizeFloat(spy.calls[1].arguments[0].x, 1)).toBe(10.3); expect(spy.calls[1].arguments[1]).toBe(12); @@ -563,7 +563,7 @@ describe('OpenlayersMap', () => { olMap.on('moveend', () => { // The first call is triggered as soon as the map component is mounted, the second one is as a result of setCenter expect(spy.calls.length).toEqual(2); - expect(spy.calls[1].arguments.length).toEqual(8); + expect(spy.calls[1].arguments.length).toEqual(10); expect(normalizeFloat(spy.calls[1].arguments[0].y, 1)).toBe(44); expect(normalizeFloat(spy.calls[1].arguments[0].x, 1)).toBe(10); expect(spy.calls[1].arguments[1]).toBe(11); diff --git a/web/client/components/widgets/builder/wizard/table/TableOptions.jsx b/web/client/components/widgets/builder/wizard/table/TableOptions.jsx index c4c412f53d..c16ccb7d2e 100644 --- a/web/client/components/widgets/builder/wizard/table/TableOptions.jsx +++ b/web/client/components/widgets/builder/wizard/table/TableOptions.jsx @@ -9,6 +9,7 @@ import React from 'react'; import { Col, Form, Row } from 'react-bootstrap'; import {compose, withProps} from 'recompose'; +import { isGeometryType } from '../../../../../utils/ogc/WFS/base'; import AttributeTable from '../../../../data/featuregrid/AttributeTable'; import Message from '../../../../I18N/Message'; @@ -28,6 +29,7 @@ const AttributeSelector = compose( withProps( ({ attributes = [], options = {}, layer = {}} = {}) => ({ // TODO manage hide condition attributes: attributes + .filter(a => !isGeometryType(a)) .map( a => { const propertyNames = options?.propertyName?.map(p => p.name); const currPropertyName = options?.propertyName?.find(p => p.name === a.name); diff --git a/web/client/components/widgets/enhancers/__tests__/dependenciesToZoomTo-test.jsx b/web/client/components/widgets/enhancers/__tests__/dependenciesToZoomTo-test.jsx index eb9a14cffb..730ad560b1 100644 --- a/web/client/components/widgets/enhancers/__tests__/dependenciesToZoomTo-test.jsx +++ b/web/client/components/widgets/enhancers/__tests__/dependenciesToZoomTo-test.jsx @@ -15,9 +15,6 @@ import MockAdapter from 'axios-mock-adapter'; import dependenciesToZoomTo from '../dependenciesToZoomTo'; -import MapUtils from '../../../../utils/MapUtils'; - - describe('widgets dependenciesToZoomTo enhancer', () => { let mockAxios; const widgetsProps = [{ @@ -30,13 +27,18 @@ describe('widgets dependenciesToZoomTo enhancer', () => { maxZoom: 21 } }, + dependenciesMap: { + mapSync: "MapID[id]" + }, geomProp: "the_geom", mapSync: true }, { - id: "123Map", + id: "MapID", widgetType: "map", - maps: [{}], + maps: [{ + mapStateSource: "MapID" + }], mapSync: true }]; beforeEach((done) => { @@ -60,19 +62,14 @@ describe('widgets dependenciesToZoomTo enhancer', () => { }); it('dependenciesToZoomTo triggering zoom to extent', (done) => { - let hookRegisterProps = MapUtils.createRegisterHooks(); const Sink = dependenciesToZoomTo(createSink(({ - hookRegister = hookRegisterProps, widgets = widgetsProps + widgets = widgetsProps }) => { - expect(hookRegister).toExist(); - const hook = hookRegister.getHook(MapUtils.ZOOM_TO_EXTENT_HOOK); - expect(hook).toExist(); expect(widgets).toExist(); expect(widgets).toEqual(widgetsProps); done(); })); - hookRegisterProps.registerHook(MapUtils.ZOOM_TO_EXTENT_HOOK, {hookName: MapUtils.ZOOM_TO_EXTENT_HOOK}); - ReactDOM.render( { + ReactDOM.render( { expect(path).toBe("dependencies.extentObj"); done(); }}/>, document.getElementById("container")); diff --git a/web/client/components/widgets/enhancers/__tests__/tableWidget-test.jsx b/web/client/components/widgets/enhancers/__tests__/tableWidget-test.jsx index 2cb9680de0..8d5f9a7798 100644 --- a/web/client/components/widgets/enhancers/__tests__/tableWidget-test.jsx +++ b/web/client/components/widgets/enhancers/__tests__/tableWidget-test.jsx @@ -45,4 +45,43 @@ describe('widgets tableWidget enhancer', () => { expect(filter).toBe(someFilter); }}/>, document.getElementById("container")); }); + + it('tableWidget with gridTools including zoom icon for dashboard viewer', (done) => { + const Sink = tableWidget(createSink( props => { + expect(props).toExist(); + expect(props.gridTools.length).toEqual(1); + props.gridTools[0].events.onClick( + { + bbox: [-10, 0, 0, -10] + }, {}, "", { crs: "", maxZoom: null } + ); + done(); + })); + ReactDOM.render( { + expect(path).toBe("dependencies.extentObj"); + expect(id).toBe("123456"); + expect(value).toEqual({ + bbox: [-10, 0, 0, -10] + }, {}, "", { crs: "EPSG:4326", maxZoom: null }); + }}/>, document.getElementById("container")); + const container = document.getElementById('container'); + expect(container).toExist(); + + }); + it('tableWidget with gridTools including zoom icon for mapViewer', (done) => { + const Sink = tableWidget(createSink( props => { + expect(props).toExist(); + expect(props.gridTools.length).toEqual(1); + props.gridTools[0].events.onClick( + { + bbox: [-10, 0, 0, -10] + }, {}, "", { crs: "", maxZoom: null } + ); + done(); + })); + ReactDOM.render( , document.getElementById("container")); + const container = document.getElementById('container'); + expect(container).toExist(); + + }); }); diff --git a/web/client/components/widgets/enhancers/dependenciesToExtent.js b/web/client/components/widgets/enhancers/dependenciesToExtent.js index 728cecf9e9..d1b57c29f4 100644 --- a/web/client/components/widgets/enhancers/dependenciesToExtent.js +++ b/web/client/components/widgets/enhancers/dependenciesToExtent.js @@ -8,7 +8,7 @@ import xml2js from 'xml2js'; import { Observable } from 'rxjs'; -import { mapPropsStream, compose, branch, withPropsOnChange, defaultProps } from 'recompose'; +import { mapPropsStream, compose, branch, withPropsOnChange } from 'recompose'; import { isEmpty, isEqual } from 'lodash'; import { composeFilterObject } from './utils'; import wpsBounds from '../../../observables/wps/bounds'; @@ -22,9 +22,7 @@ import { createRegisterHooks, ZOOM_TO_EXTENT_HOOK } from '../../../utils/MapUtil * @returns {object} the map with center and zoom updated */ export default compose( - defaultProps({ - hookRegister: createRegisterHooks() // it is for zoomTo HOC - }), + branch( ({mapSync, dependencies} = {}) => { return mapSync && (!isEmpty(dependencies.quickFilters) || !isEmpty(dependencies.filter)); diff --git a/web/client/components/widgets/enhancers/dependenciesToZoomTo.js b/web/client/components/widgets/enhancers/dependenciesToZoomTo.js index 816d640f68..458bf2d92d 100644 --- a/web/client/components/widgets/enhancers/dependenciesToZoomTo.js +++ b/web/client/components/widgets/enhancers/dependenciesToZoomTo.js @@ -1,24 +1,33 @@ +/* + * 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, { useEffect, memo } from 'react'; -const dependenciesToZoomTo = (WrappedCompoennet)=>{ +const dependenciesToZoomTo = (WrappedComponent)=>{ function DependenciesToZoomTo(props) { let tblWidgetWithExtentObj = props.widgets.find(i=>i?.dependencies?.extentObj); const extentObj = tblWidgetWithExtentObj?.dependencies?.extentObj; + let mapWidget = props?.widgets?.find(i=>tblWidgetWithExtentObj?.dependenciesMap?.mapSync.includes(i.id)); useEffect(()=>{ - if ( extentObj && extentObj ) { - const hook = props?.hookRegister?.getHook("ZOOM_TO_EXTENT_HOOK"); - if (hook) { + if ( extentObj && mapWidget ) { + let connectedMap = mapWidget?.maps.find(i=>i.mapStateSource === mapWidget.id); + let zoomToExtentHandler = connectedMap?.zoomToExtentHandler; + if (zoomToExtentHandler) { // trigger "internal" zoom to extent - hook(extentObj.extent, { + zoomToExtentHandler(extentObj.extent, { crs: extentObj.crs, maxZoom: extentObj.maxZoom }); - // removeextentObj from state + // remove extentObj from state props?.updateProperty(tblWidgetWithExtentObj.id, `dependencies.extentObj`, undefined); } } }, [extentObj?.extent?.join(",")]); - return ; + return ; } return memo(DependenciesToZoomTo); }; diff --git a/web/client/components/widgets/enhancers/tableWidget.js b/web/client/components/widgets/enhancers/tableWidget.js index bd9ea05658..208bd4fa21 100644 --- a/web/client/components/widgets/enhancers/tableWidget.js +++ b/web/client/components/widgets/enhancers/tableWidget.js @@ -5,16 +5,19 @@ * 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 { get } from 'lodash'; import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import { compose, withPropsOnChange, withProps } from 'recompose'; +import { compose, withPropsOnChange } from 'recompose'; import debounce from 'lodash/debounce'; import bbox from '@turf/bbox'; import deleteWidget from './deleteWidget'; +import LoadingSpinner from '../../misc/LoadingSpinner'; import { defaultIcons, editableWidget, withHeaderTools } from './tools'; +import { zoomToExtent } from '../../../actions/map'; +import {error} from '../../../actions/notifications'; import { gridTools } from '../../../plugins/featuregrid/index'; +import { getFeature } from '../../../api/WFS'; const withSorting = () => withPropsOnChange(["gridEvents"], ({ gridEvents = {}, updateProperty = () => { }, id } = {}) => ({ gridEvents: { ...gridEvents, @@ -27,27 +30,63 @@ const withSorting = () => withPropsOnChange(["gridEvents"], ({ gridEvents = {}, */ export default compose( compose(connect(null, (dispatch, ownProps)=>{ - let geoPropName = ownProps?.geomProp; - let hasGeometryProp = !(ownProps?.columnSettings && ownProps?.columnSettings[geoPropName]?.hide); let isTblDashboard = ownProps?.mapSync && ownProps?.widgetType === 'table' && ownProps?.isDashboardOpened; + let isTblWidgetInMapViewer = ownProps?.widgetType && !isTblDashboard; let isTblSyncWithMap = ownProps?.mapSync; return { - gridTools: (hasGeometryProp && isTblSyncWithMap) ? gridTools.map((t) => ({ + gridTools: (isTblSyncWithMap && isTblDashboard) || (isTblWidgetInMapViewer) ? gridTools.map((t) => ({ ...t, - events: isTblDashboard ? { - onClick: (p, opts, describe, {crs, maxZoom} = {}) => { - ownProps?.updateProperty(ownProps.id, `dependencies.extentObj`, { - extent: bbox(p), - crs: crs || "EPSG:4326", maxZoom - }); + events: { + onClick: async(p, opts, describe, {crs, maxZoom} = {}) => { + if (ownProps?.recordZoomLoading) return; + try { + // fetch feature with geomnetry and zoom to it if geometry not exist + if (!p?.bbox) { + ownProps?.updateProperty(ownProps.id, `dependencies.zoomLoader`, true); // show loader instead of zoom icon + let { data: featureData } = await getFeature(ownProps?.layer?.search?.url, ownProps?.layer?.name, { + outputFormat: "application/json", + srsname: 'EPSG:4326', + featureId: p.id, + propertyName: ownProps?.geomProp || "the_geom" // fetch only the geometry + }); + p.geometry = featureData?.features[0].geometry; // set geometry to feature for the future hit + p.bbox = bbox(featureData?.features[0]); // set geometry to feature for the future hit + if (isTblDashboard) { // in case of table widget in dashboard view set extent to widget dependencies + ownProps?.updateProperty(ownProps.id, `dependencies.extentObj`, { + extent: p.bbox, + crs: crs || "EPSG:4326", maxZoom + }); + } else { // in case of table widget within the map viewer zoom to the feature + dispatch(zoomToExtent(p.bbox, crs || "EPSG:4326", maxZoom)); + } + ownProps?.updateProperty(ownProps.id, `dependencies.zoomLoader`, false); // stop zoom loader + } else { // in case the geometry is already existing --> zoom to feature directly without fetching + if (isTblDashboard) { + ownProps?.updateProperty(ownProps.id, `dependencies.extentObj`, { + extent: p.bbox, + crs: crs || "EPSG:4326", maxZoom + }); + } else { + dispatch(zoomToExtent(p.bbox, crs || "EPSG:4326", maxZoom)); + } + } + } catch (err) { + dispatch(error({ + title: "warning", + message: "Error loading GF Geom", // TODO add tranlations + action: { + label: "warning" // TODO add tranlations + }, + autoDismiss: 3, + position: "tc" + })); + ownProps?.updateProperty(ownProps.id, `dependencies.zoomLoader`, false); // stop zoom loader + } } - } : bindActionCreators(t.events, dispatch) + }, formatter: ownProps?.recordZoomLoading ? : t.tableWidgetFormatter })) : [] }; })), - withProps(()=>({ - showCheckbox: true // for selection - })), withPropsOnChange(["gridEvents"], ({ gridEvents = {}, updateProperty = () => {}, id } = {}) => { const _debounceOnAddFilter = debounce((...args) => updateProperty(...args), 500); return { diff --git a/web/client/plugins/Widgets.jsx b/web/client/plugins/Widgets.jsx index 27dc3e3549..64bcad7a93 100644 --- a/web/client/plugins/Widgets.jsx +++ b/web/client/plugins/Widgets.jsx @@ -22,7 +22,8 @@ import { getFloatingWidgetsLayout, getMaximizedState, getVisibleFloatingWidgets, - isTrayEnabled + isTrayEnabled, + getTblWidgetZoomLoader } from '../selectors/widgets'; import { changeLayout, @@ -56,7 +57,8 @@ compose( (state) => mapLayoutValuesSelector(state, { right: true}), state => state.browser && state.browser.mobile, getFloatingWidgets, - (id, widgets, layouts, maximized, dependencies, mapLayout, isMobileAgent, dropdownWidgets) => ({ + getTblWidgetZoomLoader, + (id, widgets, layouts, maximized, dependencies, mapLayout, isMobileAgent, dropdownWidgets, recordZoomLoading) => ({ id, widgets, layouts, @@ -64,7 +66,8 @@ compose( dependencies, mapLayout, isMobileAgent, - dropdownWidgets + dropdownWidgets, + recordZoomLoading }) ), { editWidget, diff --git a/web/client/plugins/featuregrid/gridTools.jsx b/web/client/plugins/featuregrid/gridTools.jsx index f440c9a2f7..6ce1314b8d 100644 --- a/web/client/plugins/featuregrid/gridTools.jsx +++ b/web/client/plugins/featuregrid/gridTools.jsx @@ -21,5 +21,8 @@ export default [{ : }> - + , + tableWidgetFormatter: }> + + }]; diff --git a/web/client/selectors/widgets.js b/web/client/selectors/widgets.js index d4bba4b428..97f44fc81e 100644 --- a/web/client/selectors/widgets.js +++ b/web/client/selectors/widgets.js @@ -185,3 +185,8 @@ export const getWidgetFilterKey = (state) => { // Set chart key if editor widget type is chart return selectedChartId ? `charts[${selectedChartId}].filter` : "filter"; }; + +export const getTblWidgetZoomLoader = state => { + let tableWidgets = (getFloatingWidgets(state) || []).filter(({ widgetType } = {}) => widgetType === "table"); + return tableWidgets?.find(t=>t.dependencies?.zoomLoader) ? true : false; +};