diff --git a/docs/user-guide/attributes-table.md b/docs/user-guide/attributes-table.md index de20b5df32..1c60c826f1 100644 --- a/docs/user-guide/attributes-table.md +++ b/docs/user-guide/attributes-table.md @@ -228,3 +228,11 @@ With a click on the button: + +## Restriction by area + +MapStore [allows to configure](https://mapstore.geosolutionsgroup.com/mapstore/docs/api/plugins#plugins.FeatureEditor) attribute table in order to limit features consultation by a geometric area. + +Note that this restriction is never active for adminstrators. If active, the user see an icon to the left of the attribute table toolbar : + + diff --git a/docs/user-guide/img/attributes-table/restricted_area_icon.png b/docs/user-guide/img/attributes-table/restricted_area_icon.png new file mode 100644 index 0000000000..eff0e2207d Binary files /dev/null and b/docs/user-guide/img/attributes-table/restricted_area_icon.png differ diff --git a/web/client/actions/featuregrid.js b/web/client/actions/featuregrid.js index ec62c14b6c..c6e7e10ede 100644 --- a/web/client/actions/featuregrid.js +++ b/web/client/actions/featuregrid.js @@ -7,6 +7,7 @@ */ export const SET_UP = 'FEATUREGRID:SET_UP'; +export const SET_RESTRICTED_AREA = "FEATUREGRID:SET_RESTRICTED_AREA"; export const SELECT_FEATURES = 'FEATUREGRID:SELECT_FEATURES'; export const DESELECT_FEATURES = 'FEATUREGRID:DESELECT_FEATURES'; export const CLEAR_SELECTION = 'FEATUREGRID:CLEAR_SELECTION'; @@ -404,3 +405,8 @@ export const setViewportFilter = (viewportFilter) => ({ type: SET_VIEWPORT_FILTER, value: viewportFilter }); + +export const setRestrictedArea = (area) => ({ + type: SET_RESTRICTED_AREA, + area: area +}); diff --git a/web/client/components/data/featuregrid/FeatureGrid.jsx b/web/client/components/data/featuregrid/FeatureGrid.jsx index 19168b7e20..4448462b22 100644 --- a/web/client/components/data/featuregrid/FeatureGrid.jsx +++ b/web/client/components/data/featuregrid/FeatureGrid.jsx @@ -33,6 +33,9 @@ class FeatureGrid extends React.PureComponent { static propTypes = { autocompleteEnabled: PropTypes.bool, editingAllowedRoles: PropTypes.array, + restrictedArea: PropTypes.object, + restrictedAreaUrl: PropTypes.string, + restrictedAreaOperator: PropTypes.string, gridOpts: PropTypes.object, changes: PropTypes.object, selectBy: PropTypes.object, @@ -56,6 +59,9 @@ class FeatureGrid extends React.PureComponent { }; static defaultProps = { editingAllowedRoles: ["ADMIN"], + restrictedArea: {}, + restrictedAreaUrl: "", + restrictedAreaOperator: "CONTAINS", autocompleteEnabled: false, gridComponent: AdaptiveGrid, changes: {}, diff --git a/web/client/components/data/featuregrid/toolbars/Toolbar.jsx b/web/client/components/data/featuregrid/toolbars/Toolbar.jsx index 47a5b15e19..bae1b34b51 100644 --- a/web/client/components/data/featuregrid/toolbars/Toolbar.jsx +++ b/web/client/components/data/featuregrid/toolbars/Toolbar.jsx @@ -1,7 +1,7 @@ import React from 'react'; import './toolbar.css'; -import { sortBy } from 'lodash'; -import {ButtonGroup, Checkbox, Glyphicon, FormControl, FormGroup, Col} from 'react-bootstrap'; +import { sortBy, isEmpty } from 'lodash'; +import {ButtonGroup, Checkbox, Glyphicon, FormControl, FormGroup, Col, Button} from 'react-bootstrap'; import Message from '../../../I18N/Message'; import withHint from '../enhancers/withHint'; @@ -35,7 +35,23 @@ const standardButtons = { disabled={disabled} visible={mode === "VIEW" && isEditingAllowed && areLayerFeaturesEditable(layer)} onClick={events.switchEditMode} - glyph="pencil"/>), + glyph="pencil" />), + isRestrictedByArea: ({ restrictedArea }) => { + return (); + }, filter: ({isFilterActive = false, viewportFilter, disabled, isSearchAllowed, mode, showAdvancedFilterButton = true, events = {}}) => ( { const defaultFeatureProj = getDefaultFeatureProjection(); @@ -205,7 +212,15 @@ const setupDrawSupport = (state, original) => { }); // Remove features with geometry null or id "empty_row" - const cleanFeatures = features.filter(ft => ft.geometry !== null || ft.id !== 'empty_row'); + const cleanFeatures = features.filter(ft => { + const restrictedArea = restrictedAreaSelector(state); + let isValidFeature = ft.geometry !== null || ft.id !== 'empty_row'; + if (isValidFeature && !isEmpty(restrictedArea)) { + // allow only feature inside restricted area + isValidFeature = booleanIntersects(restrictedArea, ft.geometry); + } + return isValidFeature; + }); if (cleanFeatures.length > 0) { return Rx.Observable.from([ @@ -262,7 +277,7 @@ const createLoadPageFlow = (store) => ({page, size, reason} = {}) => { wfsURL(state), addPagination({ ...(wfsFilter(state)), - ...viewportFilter(state) + ...additionnalGridFilters(state) }, getPagination(state, {page, size}) ), @@ -298,7 +313,7 @@ const updateFilterFunc = (store) => ({update = {}, append} = {}) => { // If an advanced filter is present it's filterFields should be composed with the action' const {id} = selectedLayerSelector(store.getState()); const filterObj = {...get(store.getState(), `featuregrid.advancedFilters["${id}"]`)}; - if (filterObj) { + if (filterObj && !isEmpty(filterObj)) { // TODO: make append with advanced filters work const attributesFilter = getAttributeFilters(store.getState()) || {}; const columnsFilters = reduce(attributesFilter, (cFilters, value, attribute) => { @@ -1283,3 +1298,34 @@ export const resetViewportFilter = (action$, store) => return viewportFilter(store.getState()) !== null ? Rx.Observable.of(setViewportFilter(null)) : Rx.Observable.empty(); }); + +export const requestRestrictedArea = (action$, store) => + action$.ofType(OPEN_FEATURE_GRID, LOGIN_SUCCESS) + .filter(() => { + return !isAdminUserSelector(store.getState()) + && isLoggedIn(store.getState()) + && !isEmpty(restrictedAreaSrcSelector(store.getState())); + }) + .switchMap(() => { + const src = restrictedAreaSrcSelector(store.getState()); + if (src.url) { + return Rx.Observable.defer(() => fetch(src?.url).then(r => r?.json?.())) + .switchMap(result => { + return Rx.Observable.of( + setRestrictedArea(rawAsGeoJson(result)), + changePage(0) + ); + }); + } + return Rx.Observable.of( + setRestrictedArea(rawAsGeoJson(src.raw) || {}), + changePage(0) + ); + }); + +export const resetRestrictedArea = (action$, store) => + action$.ofType(LOGOUT, CLOSE_FEATURE_GRID) + .filter(() => !isEmpty(restrictedAreaSrcSelector(store.getState()))) + .switchMap(() => Rx.Observable.of( + setRestrictedArea({}) + )); diff --git a/web/client/plugins/FeatureEditor.jsx b/web/client/plugins/FeatureEditor.jsx index 0f141132e2..4231efb330 100644 --- a/web/client/plugins/FeatureEditor.jsx +++ b/web/client/plugins/FeatureEditor.jsx @@ -79,6 +79,9 @@ import {isViewportFilterActive} from "../selectors/featuregrid"; * @prop {array} cfg.showFilterByViewportTool Show button to toggle filter by viewport in toolbar. * @prop {object} cfg.dateFormats Allows to specify custom date formats ( in [ISO_8601](https://en.wikipedia.org/wiki/ISO_8601) format) to use to display dates in the table. `date` `date-time` and `time` are the supported entries for the date format. Example: * @prop {boolean} cfg.showPopoverSync default false. Hide the popup of map sync if false, shows the popup of map sync if true + * @prop {string} cfg.restrictedArea.url Geometry definition as WKT or GeoJSON loaded from URL or path. + * @prop {string} cfg.restrictedArea.raw Geometry definition as WKT or GeoJSON. + * @prop {string} cfg.restrictedArea.operator Spatial operation to performed between features and the given geometry. * ``` * "dateFormats": { * "date-time": "MM DD YYYY - HH:mm:ss", @@ -114,6 +117,11 @@ import {isViewportFilterActive} from "../selectors/featuregrid"; * }, * "editingAllowedRoles": ["ADMIN"], * "snapTool": true, + * "restrictedArea": { + * "url": "/wkt_or_geojson_geometry", + * "raw": "POLYGON ((-64.8 32.3, -65.5 18.3, -80.3 25.2, -64.8 32.3))", + * "operator": "WITHIN" + * }, * "snapConfig": { * "vertex": true, * "edge": true, @@ -175,7 +183,9 @@ const EditorPlugin = connect( virtualScroll: this.props.virtualScroll ?? true, editingAllowedRoles: this.props.editingAllowedRoles, editingAllowedGroups: this.props.editingAllowedGroups, - maxStoredPages: this.props.maxStoredPages + maxStoredPages: this.props.maxStoredPages, + restrictedAreaUrl: this.props.restrictedAreaUrl, + restrictedArea: this.props.restrictedArea }); }, componentDidUpdate(prevProps) { diff --git a/web/client/plugins/featuregrid/FeatureEditor.jsx b/web/client/plugins/featuregrid/FeatureEditor.jsx index 0873b815e6..2486f3aa0d 100644 --- a/web/client/plugins/featuregrid/FeatureEditor.jsx +++ b/web/client/plugins/featuregrid/FeatureEditor.jsx @@ -96,6 +96,9 @@ const Dock = connect(createSelector( * @prop {array} cfg.snapConfig.additionalLayers Array of additional layers to include into snapping layers list. Provides a way to include layers from "state.additionallayers" * @prop {object} cfg.dateFormats object containing custom formats for one of the date/time attribute types. Following keys are supported: "date-time", "date", "time" * @prop {boolean} cfg.showPopoverSync default false. Hide the popup of map sync if false, shows the popup of map sync if true + * @prop {string} cfg.restrictedArea.url Geometry definition as WKT or GeoJSON loaded from URL or path. + * @prop {string} cfg.restrictedArea.raw Geometry definition as WKT or GeoJSON. + * @prop {string} cfg.restrictedArea.operator Spatial operation to performed between features and the given geometry. * * @classdesc * `FeatureEditor` Plugin, also called *FeatureGrid*, provides functionalities to browse/edit data via WFS. The grid can be configured to use paging or @@ -124,6 +127,11 @@ const Dock = connect(createSelector( * }, * "editingAllowedRoles": ["ADMIN"], * "snapTool": true, + * "restrictedArea": { + * "url": "/wkt_or_geojson_geometry", + * "raw": "POLYGON ((-64.8 32.3, -65.5 18.3, -80.3 25.2, -64.8 32.3))", + * "operator": "WITHIN" + * }, * "snapConfig": { * "vertex": true, * "edge": true, diff --git a/web/client/plugins/featuregrid/panels/index.jsx b/web/client/plugins/featuregrid/panels/index.jsx index d07fc5af1b..8b5b2ef6a4 100644 --- a/web/client/plugins/featuregrid/panels/index.jsx +++ b/web/client/plugins/featuregrid/panels/index.jsx @@ -47,7 +47,8 @@ import { timeSyncActive, isViewportFilterActive, isFilterByViewportSupported, - selectedLayerSelector + selectedLayerSelector, + restrictedAreaSelector } from '../../../selectors/featuregrid'; import { mapLayoutValuesSelector } from '../../../selectors/maplayout'; import {isCesium, mapTypeSelector} from '../../../selectors/maptype'; @@ -95,6 +96,7 @@ const Toolbar = connect( disableZoomAll: (state) => state && state.featuregrid.virtualScroll || featureCollectionResultSelector(state).features.length === 0, isSearchAllowed: (state) => !isCesium(state), isEditingAllowed: isEditingAllowedSelector, + restrictedArea: restrictedAreaSelector, hasSupportedGeometry, isFilterActive, showTimeSyncButton: showTimeSync, diff --git a/web/client/reducers/featuregrid.js b/web/client/reducers/featuregrid.js index 4ecbd3438f..fd3e671d91 100644 --- a/web/client/reducers/featuregrid.js +++ b/web/client/reducers/featuregrid.js @@ -47,7 +47,8 @@ import { SET_TIME_SYNC, UPDATE_EDITORS_OPTIONS, SET_PAGINATION, - SET_VIEWPORT_FILTER + SET_VIEWPORT_FILTER, + SET_RESTRICTED_AREA } from '../actions/featuregrid'; import { MAP_CONFIG_LOADED } from '../actions/config'; @@ -156,7 +157,9 @@ function featuregrid(state = emptyResultsState, action) { editingAllowedRoles: action.options.editingAllowedRoles || state.editingAllowedRoles || ["ADMIN"], editingAllowedGroups: action.options.editingAllowedGroups || state.editingAllowedGroups || [], virtualScroll: !!action.options.virtualScroll, - maxStoredPages: action.options.maxStoredPages || 5 + maxStoredPages: action.options.maxStoredPages || 5, + restrictedAreaUrl: action.options.restrictedAreaUrl || "", + restrictedArea: action.options.restrictedArea || {} }); } case LOAD_MORE_FEATURES: @@ -441,6 +444,9 @@ function featuregrid(state = emptyResultsState, action) { case MAP_CONFIG_LOADED: { return {...state, ...get(action, 'config.featureGrid', {})}; } + case SET_RESTRICTED_AREA: { + return { ...state, restrictedArea: { ...state.restrictedArea, geometry: action.area } }; + } default: return state; } diff --git a/web/client/selectors/featuregrid.js b/web/client/selectors/featuregrid.js index 8f460fa483..05c80bc6f7 100644 --- a/web/client/selectors/featuregrid.js +++ b/web/client/selectors/featuregrid.js @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { head, get, isObject } from 'lodash'; +import { head, get, isObject, isEmpty } from 'lodash'; import { getLayerFromId } from './layers'; import { findGeometryProperty } from '../utils/ogc/WFS/base'; @@ -202,6 +202,11 @@ export const isEditingAllowedSelector = (state) => { })(state); return (canEdit || isAllowed) && !isCesium(state); }; + +export const restrictedAreaSrcSelector = state => get(state, "featuregrid.restrictedArea"); +export const restrictedAreaOperatorSelector = state => get(state, "featuregrid.restrictedArea.operator"); +export const restrictedAreaSelector = state => get(state, "featuregrid.restrictedArea.geometry"); + export const paginationSelector = state => get(state, "featuregrid.pagination"); export const useLayerFilterSelector = state => get(state, "featuregrid.useLayerFilter", true); @@ -209,11 +214,13 @@ export const isViewportFilterActive = state => get(state, 'featuregrid.viewportF export const isFilterByViewportSupported = state => mapTypeSelector(state) !== MapLibraries.CESIUM; +export const spatialFieldFilters = state => get(state, 'query.filterObj.spatialField'); + export const viewportFilter = createShallowSelectorCreator(isEqual)( isViewportFilterActive, mapBboxSelector, projectionSelector, - state => get(state, 'query.filterObj.spatialField'), + spatialFieldFilters, describeSelector, isFilterByViewportSupported, (viewportFilterIsActive, box, projection, spatialField = [], describeLayer, viewportFilterIsSupported) => { @@ -221,7 +228,8 @@ export const viewportFilter = createShallowSelectorCreator(isEqual)( const existingFilter = spatialField?.operation ? [spatialField] : spatialField; return viewportFilterIsActive && viewportFilterIsSupported ? { spatialField: [ - ...existingFilter, + // avoid restricted area filter dupplication + ...existingFilter.filter(f => !f.viewport && !f.restrictedArea), { geometry: { ...bboxToFeatureGeometry(box.bounds), @@ -229,9 +237,52 @@ export const viewportFilter = createShallowSelectorCreator(isEqual)( }, attribute: attribute, method: "Rectangle", - operation: "INTERSECTS" + operation: "INTERSECTS", + viewport: true + } + ] + } : {}; + } +); + +export const restrictedAreaFilter = createShallowSelectorCreator(isEqual)( + restrictedAreaSelector, + spatialFieldFilters, + viewportFilter, + projectionSelector, + describeSelector, + state => restrictedAreaOperatorSelector(state), + (restrictedArea, spatialField = [], viewPortFilter, projection, describeLayer, operator) => { + const attribute = findGeometryProperty(describeLayer)?.name; + let existingFilter = []; + // if activate, viewportFilter already get existing filter + if (isEmpty(viewPortFilter) && !isEmpty(spatialField)) { + existingFilter = spatialField?.operation ? [spatialField] : spatialField; + } + return !isEmpty(restrictedArea) ? { + spatialField: [ + ...existingFilter, + { + geometry: { + ...restrictedArea, + projection: "EPSG:4326" + }, + attribute: attribute, + method: "Polygon", + operation: operator || "CONTAINS", + restrictedArea: true } ] } : {}; } ); + +/** + * Create spatialField filters array. + * Contains filters from viewportFilter, restrictedArea, exsting WFS filter + */ +export const additionnalGridFilters = (state) => { + const restrictedArea = restrictedAreaFilter(state)?.spatialField || []; + const viewport = viewportFilter(state)?.spatialField || []; + return {spatialField: [...restrictedArea, ...viewport]}; +}; diff --git a/web/client/utils/FeatureGridUtils.js b/web/client/utils/FeatureGridUtils.js index b5406acd93..badabf7da6 100644 --- a/web/client/utils/FeatureGridUtils.js +++ b/web/client/utils/FeatureGridUtils.js @@ -17,6 +17,8 @@ import { isValidValueForPropertyName as isValidValueForPropertyNameBase } from './ogc/WFS/base'; +import { WKT } from 'ol/format'; + import { applyDefaultToLocalizedString } from '../components/I18N/LocalizedString'; const getGeometryName = (describe) => get(findGeometryProperty(describe), "name"); @@ -392,3 +394,38 @@ export const supportsFeatureEditing = (layer) => includes(supportedEditLayerType * @returns {boolean} flag */ export const areLayerFeaturesEditable = (layer) => !layer?.disableFeaturesEditing && supportsFeatureEditing(layer); + +export const isWKT = (wktString) => { + let isWKTGeom = false; + try { + const reader = new WKT(); + const feature = reader.readFeature(wktString); + if (feature) { + isWKTGeom = true; + } + } catch (e) { + isWKTGeom = false; + } + return isWKTGeom; +}; + +export const wktToGeoJson = (wktString) => { + const reader = new WKT(); + const feature = reader.readFeature(wktString); + return { + type: feature.getGeometry().getType(), + coordinates: feature.getGeometry().getCoordinates() + }; +}; + +/** + * Return GeoJSON geometry. Transform WKT to GeoJSON if necessary. + * @param {string} raw - geometry + * @returns geometry object + */ +export const rawAsGeoJson = (raw) => { + if (isWKT(raw)) { + return wktToGeoJson(raw); + } + return raw; +};