diff --git a/build/docma-config.json b/build/docma-config.json index 866162f11f6..8dc114e5c3c 100644 --- a/build/docma-config.json +++ b/build/docma-config.json @@ -265,6 +265,7 @@ "web/client/plugins/Language.jsx", "web/client/plugins/LayerDownload.jsx", "web/client/plugins/LayerInfo.jsx", + "web/client/plugins/LayersSelection/index.js", "web/client/plugins/Locate.jsx", "web/client/plugins/Login.jsx", "web/client/plugins/LongitudinalProfileTool.jsx", diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json index bc6b9875374..eda14def5c9 100644 --- a/project/standard/templates/configs/pluginsConfig.json +++ b/project/standard/templates/configs/pluginsConfig.json @@ -153,6 +153,13 @@ "description": "plugins.Permalink.description", "denyUserSelection": true }, + { + "name": "LayersSelection", + "glyph": "hand-down", + "title": "plugins.LayersSelection.title", + "description": "plugins.LayersSelection.description", + "dependencies": ["SidebarMenu"] + }, { "name": "BackgroundSelector", "title": "plugins.BackgroundSelector.title", diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json index 1ce8f863a29..e7b8155ee38 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -155,6 +155,13 @@ "title": "plugins.Permalink.title", "description": "plugins.Permalink.description" }, + { + "name": "LayersSelection", + "glyph": "hand-down", + "title": "plugins.LayersSelection.title", + "description": "plugins.LayersSelection.description", + "dependencies": ["SidebarMenu"] + }, { "name": "BackgroundSelector", "title": "plugins.BackgroundSelector.title", diff --git a/web/client/plugins/LayersSelection/actions/__tests__/layersSelection-test.js b/web/client/plugins/LayersSelection/actions/__tests__/layersSelection-test.js new file mode 100644 index 00000000000..03eb9c2659e --- /dev/null +++ b/web/client/plugins/LayersSelection/actions/__tests__/layersSelection-test.js @@ -0,0 +1,59 @@ + +import expect from 'expect'; +import { + cleanSelection, + SELECT_CLEAN_SELECTION, + storeConfiguration, + SELECT_STORE_CFG, + addOrUpdateSelection, + ADD_OR_UPDATE_SELECTION, + updateSelectionFeature, + UPDATE_SELECTION_FEATURE +} from '../layersSelection'; + +describe('LayersSelection Actions', () => { + describe('cleanSelection', () => { + it('should create action with a geometry type Point', () => { + const geomType = 'Point'; + const action = cleanSelection(geomType); + + expect(action).toEqual({ + type: SELECT_CLEAN_SELECTION, + geomType + }); + }); + + it('should create action of a store Configuration', () => { + const cfg = {}; + const action = storeConfiguration(cfg); + + expect(action).toEqual({ + type: SELECT_STORE_CFG, + cfg + }); + }); + + it('should create action to add or update selection', () => { + const layer = 'name layer'; + const geoJsonData = {}; + const action = addOrUpdateSelection(layer, geoJsonData); + + expect(action).toEqual({ + type: ADD_OR_UPDATE_SELECTION, + layer, + geoJsonData + }); + }); + + it('should create action to update feature selection', () => { + const feature = {}; + const action = updateSelectionFeature(feature); + + expect(action).toEqual({ + type: UPDATE_SELECTION_FEATURE, + feature + }); + }); + + }); +}); diff --git a/web/client/plugins/LayersSelection/actions/layersSelection.js b/web/client/plugins/LayersSelection/actions/layersSelection.js new file mode 100644 index 00000000000..47f1b985977 --- /dev/null +++ b/web/client/plugins/LayersSelection/actions/layersSelection.js @@ -0,0 +1,51 @@ +export const SELECT_CLEAN_SELECTION = "SELECT:CLEAN_SELECTION"; +export const SELECT_STORE_CFG = "SELECT:STORE_CFG"; +export const ADD_OR_UPDATE_SELECTION = "SELECT:ADD_OR_UPDATE_SELECTION"; +export const UPDATE_SELECTION_FEATURE = "SELECT:UPDATE_SELECTION_FEATURE"; +/** + * Action creator to clean the current selection based on geometry type. + * + * @param {string} geomType - The type of geometry to clean (e.g., "Point", "Polygon"). + * @returns {{ type: string, geomType: string }} The action object. + */ +export function cleanSelection(geomType) { + return { + type: SELECT_CLEAN_SELECTION, + geomType + }; +} + +/** + * Action creator to store configuration settings related to selection. + * + * @param {Object} cfg - Configuration object to store. + * @returns {{ type: string, cfg: Object }} The action object. + */ +export function storeConfiguration(cfg) { + return { + type: SELECT_STORE_CFG, + cfg + }; +} + +/** + * Action creator to add or update a layer selection with GeoJSON data. + * + * @param {string} layer - The name or ID of the layer. + * @param {Object} geoJsonData - The GeoJSON data representing the selection. + * @returns {{ type: string, layer: string, geoJsonData: Object }} The action object. + */ +export function addOrUpdateSelection(layer, geoJsonData) { + return { + type: ADD_OR_UPDATE_SELECTION, + layer, + geoJsonData + }; +} + +export function updateSelectionFeature(feature) { + return { + type: UPDATE_SELECTION_FEATURE, + feature + }; +} diff --git a/web/client/plugins/LayersSelection/components/EllipsisButton.jsx b/web/client/plugins/LayersSelection/components/EllipsisButton.jsx new file mode 100644 index 00000000000..7551cba25f8 --- /dev/null +++ b/web/client/plugins/LayersSelection/components/EllipsisButton.jsx @@ -0,0 +1,198 @@ +import React, { useState, useEffect } from 'react'; +import bbox from '@turf/bbox'; +import { saveAs } from 'file-saver'; +import axios from 'axios'; + +import Message from '../../../components/I18N/Message'; +import { describeFeatureType } from '../../../api/WFS'; +import Statistics from './Statistics'; +import { DropdownButton, Glyphicon, MenuItem } from 'react-bootstrap'; +import uuidv1 from 'uuid/v1'; + +/** + * EllipsisButton provides a contextual menu for selected layer data. + * It allows users to: + * - Zoom to selection extent + * - View statistics + * - Create a new layer from selection + * - Export data (GeoJSON, JSON, CSV) + * - Apply attribute filters (if supported) + * - Clear the selection + * + * @param {Object} props - Component props. + * @param {Object} props.node - Layer node (descriptor). + * @param {Object} props.selectionData - GeoJSON FeatureCollection. + * @param {Function} props.onAddOrUpdateSelection - Callback to update selection. + * @param {Function} props.onZoomToExtent - Callback to zoom to selection. + * @param {Function} props.onAddLayer - Callback to add a new layer. + * @param {Function} props.onChangeLayerProperties - Callback to change layer properties. + */ +export default ({ + node = {}, + selectionData = {}, + onAddOrUpdateSelection = () => { }, + onZoomToExtent = () => { }, + onAddLayer = () => { }, + onChangeLayerProperties = () => { } +}) => { + + const [statisticsOpen, setStatisticsOpen] = useState(false); + const [numericFields, setNumericFields] = useState([]); + const [primaryKey, setPrimaryKey] = useState(null); + + const triggerAction = (action) => { + switch (action) { + case 'clear': { + onAddOrUpdateSelection(node, {}); + break; + } + case 'zoomTo': { + if (selectionData.features?.length > 0) { + const extent = bbox(selectionData); + if (extent) { onZoomToExtent(extent, selectionData.crs.properties[selectionData.crs.type]); } + } + break; + } + case 'createLayer': { + if (selectionData.features?.length > 0) { + + const nodeName = node.title + '_Select_'; + const layerBbox = bbox(selectionData); + const uniqueId = uuidv1(); + + onAddLayer({ + id: uniqueId, + type: 'vector', + visibility: true, + name: nodeName + uniqueId, + hideLoading: true, + bbox: layerBbox, + features: selectionData.features + }); + } + break; + } + case 'exportToGeoJson': { + if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData)], { type: 'application/json' }), node.title + '.json'); } + break; + } + case 'exportToJson': { + if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData.features.map(feature => feature.properties))], { type: 'application/json' }), node.title + '.json'); } + break; + } + case 'exportToCsv': { + if (selectionData.features?.length > 0) { saveAs(new Blob([Object.keys(selectionData.features[0].properties).join(',') + '\n' + selectionData.features.map(feature => Object.values(feature.properties).join(',')).join('\n')], { type: 'text/csv' }), node.title + '.csv'); } + break; + } + case 'filterData': { + + switch (node.type) { + case 'arcgis': { + // TODO : implement here when MapStore supports filtering for arcgis services + throw new Error(`Unsupported layer type: ${node.type}`); + } + case 'wms': + case 'wfs': { + onChangeLayerProperties(node.id, { + layerFilter: { + groupFields: [ + { + id: 1, + logic: 'OR', + index: 0 + } + ], + filterFields: selectionData.features.map(feature => ({ + rowId: uuidv1(), + groupId: 1, + attribute: primaryKey, + operator: '=', + value: feature.properties[primaryKey], + type: 'number', + fieldOptions: { + valuesCount: 0, + currentPage: 1 + }, + exception: null + })) + } + }); + break; + } + default: + throw new Error(`Unsupported layer type: ${node.type}`); + } + break; + } + default: + } + }; + + useEffect(() => { + switch (node.type) { + case 'arcgis': { + const arcgisNumericFields = new Set(['esriFieldTypeSmallInteger', 'esriFieldTypeInteger', 'esriFieldTypeSingle', 'esriFieldTypeDouble']); + const singleLayerId = parseInt(node.name ?? '', 10); + Promise.all((Number.isInteger(singleLayerId) ? node.options.layers.filter(l => l.id === singleLayerId) : node.options.layers).map(l => axios.get(`${node.url}/${l.id}`, { params: { f: 'json' } }) + .then(describe => describe.data.fields.filter(field => field.domain === null && arcgisNumericFields.has(field.type)).map(field => field.name)) + .catch(() => []) + )) + .then(responses => { + setPrimaryKey(null); + setNumericFields(responses.map(response => response ?? []).flat()); + }) + .catch(() => { + setPrimaryKey(null); + setNumericFields([]); + }); + break; + } + case 'wms': + case 'wfs': { + describeFeatureType(node.url, node.name) + .then(describe => { + const featureType = describe.featureTypes.find(fType => node.name.endsWith(fType.typeName)); + const newNumericFields = featureType.properties.filter(property => property.localType === 'number').map(property => property.name); + // primary key is not always exposed + const newPrimaryKey = featureType.properties + .find(property => + ['xsd:string', 'xsd:int'].includes(property.type) && !property.nillable && property.maxOccurs === 1 && property.minOccurs === 1 + )?.name || null; + setPrimaryKey(newPrimaryKey); + setNumericFields(newNumericFields); + }) + .catch(() => { + setPrimaryKey(null); + setNumericFields([]); + }); + break; + } + default: + } + }, [node.name]); + + return ( + <> + } + className="_border-transparent" + noCaret + > + triggerAction('zoomTo')}> + { selectionData.features?.length > 0 ? setStatisticsOpen(true) : null; }}> + triggerAction('createLayer')}> + {primaryKey ? triggerAction('filterData')}> : null} + triggerAction('exportToGeoJson')}> + triggerAction('exportToJson')}> + triggerAction('exportToCsv')}> + triggerAction('clear')}> + + {statisticsOpen && } + + ); +}; diff --git a/web/client/plugins/LayersSelection/components/LayersSelection.jsx b/web/client/plugins/LayersSelection/components/LayersSelection.jsx new file mode 100644 index 00000000000..61c47a2171c --- /dev/null +++ b/web/client/plugins/LayersSelection/components/LayersSelection.jsx @@ -0,0 +1,226 @@ +import React, { useEffect, useState } from 'react'; +import { ControlLabel, Glyphicon } from 'react-bootstrap'; + +import { ControlledTOC } from '../../TOC/components/TOC'; +import ResizableModal from '../../../components/misc/ResizableModal'; +import Message from '../../../components/I18N/Message'; +import VisibilityCheck from '../../TOC/components/VisibilityCheck'; +import NodeHeader from '../../TOC/components/NodeHeader'; +import { getLayerTypeGlyph } from '../../../utils/LayersUtils'; +import NodeTool from '../../TOC/components/NodeTool'; +import InlineLoader from '../../TOC/components/InlineLoader'; +import EllipsisButton from './EllipsisButton'; +import { isSelectQueriable, filterLayerForSelect } from '../selectors/layersSelection'; +import FlexBox from '../../../components/layout/FlexBox'; +import Button from '../../../components/layout/Button'; +import Select from 'react-select'; +import { getMessageById } from '../../../utils/LocaleUtils'; +import PropTypes from 'prop-types'; + +import './layersSelection.css'; + +const SelectionToolsTypes = { + Point: { type: 'Point', value: 'Point', labelId: 'layersSelection.button.selectByPoint', icon: '1-point' }, + LineString: { type: 'LineString', value: 'LineString', labelId: 'layersSelection.button.selectByLine', icon: 'polyline' }, + Circle: { type: 'Circle', value: 'Circle', labelId: 'layersSelection.button.selectByCircle', icon: '1-circle' }, + Rectangle: { type: 'Rectangle', value: 'BBOX', labelId: 'layersSelection.button.selectByRectangle', icon: 'unchecked' }, + Polygon: { type: 'Polygon', value: 'Polygon', labelId: 'layersSelection.button.selectByPolygon', icon: 'polygon' } +}; + +/** + * Appends or updates a cache-busting `_v_` parameter on the layer's legendParams object. + * + * @param {Object} layer - The layer object to apply the parameter to. + * @returns {Object} A new layer object with the `_v_` legend param added. + */ +function applyVersionParamToLegend(layer) { + // we need to pass a parameter that invalidate the cache for GetLegendGraphic + // all layer inside the dataset viewer apply a new _v_ param each time we switch page + return { ...layer, legendParams: { ...layer?.legendParams, _v_: layer?._v_ } }; +} + +/** + * Select tool UI component wrapped with react-intl internationalization. + * + * @component + * @param {Object} props - Component props. + * @param {Array} props.layers - List of layers from the map. + * @param {Function} props.onUpdateNode - Redux action to update a layer node. + * @param {Function} props.onClose - Callback for closing the modal. + * @param {Boolean} props.isVisible - Whether the modal is visible. + * @param {Object} props.highlightOptions - Highlighting options for selected features. + * @param {Object} props.queryOptions - Options for querying features. + * @param {Array} props.selectTools - Toolbar tools for the selection module. + * @param {Function} props.storeConfiguration - Saves configuration to the Redux store. + * @param {Object} props.intl - Internationalization object from `injectIntl`. + * @param {Object} props.selections - Selection results grouped by layer ID. + * @param {Number} props.maxFeatureCount - Maximum number of features allowed per selection. + * @param {Function} props.cleanSelection - Action to clear selection results. + * @param {Function} props.addOrUpdateSelection - Action to update the current selection. + * @param {Function} props.zoomToExtent - Action to zoom to the extent of selected features. + * @param {Function} props.addLayer - Action to add a new layer. + * @param {Function} props.changeLayerProperties - Action to update layer properties. + * + * @returns {JSX.Element} The rendered Select tool modal. + */ +const LayersSelection = ({ + layers, + onUpdateNode, + onClose, + isVisible, + highlightOptions, + queryOptions, + selectTools = [ + 'Point', + 'LineString', + 'Circle', + 'Rectangle', + 'Polygon' + ], + storeConfiguration, + selections, + maxFeatureCount, + cleanSelection, + addOrUpdateSelection, + zoomToExtent, + addLayer, + changeLayerProperties +}, context) => { + + const filterLayers = layers.filter(filterLayerForSelect); + + /** + * Renders a custom layer node component inside the TOC. + * + * @param {Object} props + * @param {Object} props.node - The layer node. + * @param {Object} props.config - Configuration options such as locale. + * @returns {JSX.Element} Rendered layer node with feature count, tools, and visibility check. + */ + const customLayerNodeComponent = ({node, config}) => { + const selectionData = selections[node.id] ?? {}; + return ( +
  • + + + onUpdateNode(node.id, 'layers', { isSelectQueriable: checked })} + /> + + + } + afterTitle={ + <> + {selectionData.error ? ( + + ) : ( +
    + {selectionData.features && selectionData.features.length === maxFeatureCount && } /* tooltip={"ouech"} */ glyph="exclamation-mark"/>} +

    {selectionData.loading ? '⊙' : (selectionData.features?.length ?? 0)}

    +
    + )} + + {selectionData.features?.length > 0 && } + + } + /> +
  • + ); + }; + + useEffect(() => storeConfiguration({ highlightOptions, queryOptions }), []); + + const [selectedTool, setSelectedTool] = useState(null); + + const selectionOptions = selectTools + .filter(tool => SelectionToolsTypes[tool]) + .map(tool => { + const option = SelectionToolsTypes[tool]; + return { ...option, label: <>{' '} }; + }); + + return ( + + {' '} + + } + dialogClassName=" select-dialog" + show={isVisible} + draggable + style={{zIndex: 1993}}> + + + + +