diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index be22b5ba2d..2a0fe3b60e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -8,14 +8,23 @@ on: - master # using filter pattern: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet - '[cC][0-9][0-9][0-9]-+**' # c123 or c123-something for custom branch - - '[0-9][0-9][0-9][0-9].[0-9][0-9].xx' # stable brances. E.g. 2021.01.xx + - '[0-9][0-9][0-9][0-9].[0-9][0-9].xx' # stable branches. E.g. 2021.01.xx pull_request: + types: [opened, synchronize, reopened] branches: - master # using filter pattern: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet - '[cC][0-9][0-9][0-9]-+**' # c123 or c123-something for custom branch - - '[0-9][0-9][0-9][0-9].[0-9][0-9].xx' # stable brances. E.g. 2021.01.xx - + - '[0-9][0-9][0-9][0-9].[0-9][0-9].xx' # stable branches. E.g. 2021.01.xx + - '[geonode]-[0-9].[0-9].x' # stable branches for GeoNode. E.g. geonode-4.4.x + pull_request_target: + types: [opened, synchronize, reopened] + branches: + - master + # using filter pattern: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet + - '[cC][0-9][0-9][0-9]-+**' # c123 or c123-something for custom branch + - '[0-9][0-9][0-9][0-9].[0-9][0-9].xx' # stable branches. E.g. 2021.01.xx + - '[geonode]-[0-9].[0-9].x' # stable branches for GeoNode. E.g. geonode-4.4.x jobs: test-front-end: runs-on: ubuntu-latest @@ -180,6 +189,10 @@ jobs: # Here it deploys only java modules and root, needed for MS project builds. # Product, binary modules are to big to be hosted on the repository in snapshots, so they are skipped run: | + # Setup SSH keys for SFTP + mkdir -p ~/.ssh && chmod 700 ~/.ssh + # add geo-solutions.it to known hosts to avoid prompts + ssh-keyscan -H maven.geo-solutions.it >> ~/.ssh/known_hosts # deploys java packages mvn clean install deploy -f java/pom.xml # deploys also the root module, needed for dependencies @@ -189,4 +202,3 @@ jobs: MAVEN_PASSWORD: ${{ secrets.GS_MAVEN_PASSWORD }} - diff --git a/binary/bin-war/pom.xml b/binary/bin-war/pom.xml index 362345d95a..707585b97d 100644 --- a/binary/bin-war/pom.xml +++ b/binary/bin-war/pom.xml @@ -65,6 +65,29 @@ + + + maven-assembly-plugin + 2.1 + + + ../bin.xml + + mapstore2-${binary.number} + + ${project.build.directory}/../../target + + + + make-assembly + verify + + single + + false + + + diff --git a/binary/bin.xml b/binary/bin.xml index d3ef17718e..2c5ec5233c 100644 --- a/binary/bin.xml +++ b/binary/bin.xml @@ -4,10 +4,12 @@ zip false + + - - ./bin-war/target/ + + target/ mapstore2/webapps/ mapstore.war @@ -22,7 +24,7 @@ - + target/dependency mapstore2/lib @@ -31,8 +33,8 @@ - - bin + + ../bin keep mapstore2/ @@ -40,8 +42,8 @@ - - bin + + ../bin unix mapstore2/ 0755 @@ -51,21 +53,21 @@ - - tomcat + + ../tomcat mapstore2 **/* - - logs + + ../logs mapstore2 - - work + + ../work mapstore2 diff --git a/binary/pom.xml b/binary/pom.xml index 2e31c26c96..b0dc3b65ec 100644 --- a/binary/pom.xml +++ b/binary/pom.xml @@ -13,7 +13,7 @@ http://www.geo-solutions.it UTF-8 - 9.0.108 + 9.0.110 ${mapstore2.version} @@ -142,27 +142,6 @@ - - - maven-assembly-plugin - 2.1 - - - bin.xml - - mapstore2-${binary.number} - ${project.build.directory} - - - - make-assembly - install - - single - - - - diff --git a/build/testConfig.js b/build/testConfig.js index 2ccf9ad99a..1ef479d663 100644 --- a/build/testConfig.js +++ b/build/testConfig.js @@ -63,7 +63,7 @@ module.exports = ({browsers = [ 'ChromeHeadless' ], files, path, testFile, singl }, browserConsoleLogOptions: { terminal: true, - level: 'DISABLE' + level: 'DEBUG' }, webpack: { devtool: 'eval', diff --git a/docs/developer-guide/mapstore-migration-guide.md b/docs/developer-guide/mapstore-migration-guide.md index 5b32f04278..c66eacb0b2 100644 --- a/docs/developer-guide/mapstore-migration-guide.md +++ b/docs/developer-guide/mapstore-migration-guide.md @@ -22,6 +22,28 @@ This is a list of things to check if you want to update from a previous version ## Migration from 2025.01.01 to 2025.02.00 +### Update authenticationRules in localConfig.json + +The previous default authentication rule used a broad pattern (`.*geostore.*`) that unintentionally matched internal GeoServer delegation endpoints (e.g., `/rest/security/usergroup/service/geostore/...`). This could cause delegated user/group requests to fail due to forced `bearer` authentication overriding the intended method (e.g., `authkey`). + +To avoid this conflict, update the authenticationRules entry in localConfig.json as follows: + +``` diff +{ + "authenticationRules": [ + { +- "urlPattern": ".*geostore.*", ++ "urlPattern": ".*rest/geostore.*", + "method": "bearer" + }, + { + "urlPattern": ".*rest/config.*", + "method": "bearer" + } + ] +} +``` + ### Set minimum NodeJS version to 20 Node 16 and 18 are at end of life. Therefore there is no reason to keep maintaining compatibility with these old versions. In the meantime we want to concentrate to Make MapStore compatible with future version of NodeJS, and update the libraries to reduce the dependency tree. diff --git a/docs/user-guide/filtering-layers.md b/docs/user-guide/filtering-layers.md index 4e93f90b7e..665a2e24ad 100644 --- a/docs/user-guide/filtering-layers.md +++ b/docs/user-guide/filtering-layers.md @@ -18,7 +18,7 @@ In [MapStore](https://mapstore.geosolutionsgroup.com/mapstore/#/) it is possible * With the [Quick Filter](attributes-table.md#quick-filter) available in the [Attribute Table](attributes-table.md#attribute-table) -### Layer Filter +### Layer Filters This filter is applicable from the **Filter layer** button in TOC's [Layers Toolbar](toc.md#toolbar-options) and it will persist in the following situations: @@ -56,7 +56,7 @@ This tool is used to define advanced filters in [MapStore](https://mapstore.geos -#### Attribute filter +#### Attribute Filter This filter allows to set one or more conditions referred to the [Attribute Table](attributes-table.md#attribute-table) fields.
First of all it is possible to choose if the filter will match: @@ -87,7 +87,7 @@ A simple *Attribute Filter* applied for a numerical field can be, for example: -#### Area of interest +#### Area of Interest In order to set this filter the user can: @@ -107,7 +107,7 @@ Once this filter is set, it is always possible to edit the coordinates and the d Also for [Dashboard](exploring-dashboards.md) [widgets](widgets.md) (charts, table and counter) it is possible to define a spatial filter without necessarily connect the widget to the map widget by using the usual **Area of interest** filtering section. The example below sows how: -#### Layer filter +#### Layer Filter This tool allows to set [cross-layer filters](https://docs.geoserver.org/stable/en/user/extensions/querylayer/index.html) for a layer by using another layer or even the same one. @@ -132,3 +132,11 @@ In particular, if our goal is to take a look at the Italian Regions that contain !!! note The **Layer Filter** option is only available for [widgets](widgets.md) defined in [Map viewer](exploring-maps.md) and not for [Dashboards](exploring-dashboards.md) widgets. + +#### Combining Multiple Filtering + +To filter a layer, the user can also combine the two methods described above. This way, the user can first apply an [Area of Interest Filter](filtering-layers.md#area-of-interest) to a layer and then use a second layer to define the cross-layer filter using the [Layer Filter](filtering-layers.md#layer-filter) method. + +The following example shows this workflow: the meteorites layer is filtered using a square area of interest, and then a second filter excludes features located in the state of California by using the USA States layer. It is also worth clarifying that the AOI thus defined is used in this case to also restrict the [Layer Filter's](filtering-layers.md#layer-filter) action to that area only. + + diff --git a/docs/user-guide/img/button/new_tag_button.jpg b/docs/user-guide/img/button/new_tag_button.jpg index 515eed2147..5ce350c17e 100644 Binary files a/docs/user-guide/img/button/new_tag_button.jpg and b/docs/user-guide/img/button/new_tag_button.jpg differ diff --git a/docs/user-guide/img/filtering-layers/cascading-search-filter.jpg b/docs/user-guide/img/filtering-layers/cascading-search-filter.jpg new file mode 100644 index 0000000000..105e939b31 Binary files /dev/null and b/docs/user-guide/img/filtering-layers/cascading-search-filter.jpg differ diff --git a/docs/user-guide/img/layer-settings/display-3d-tiles.jpg b/docs/user-guide/img/layer-settings/display-3d-tiles.jpg index 80882b3897..b0b4d027e5 100644 Binary files a/docs/user-guide/img/layer-settings/display-3d-tiles.jpg and b/docs/user-guide/img/layer-settings/display-3d-tiles.jpg differ diff --git a/docs/user-guide/img/layer-settings/imagery-layers.jpg b/docs/user-guide/img/layer-settings/imagery-layers.jpg new file mode 100644 index 0000000000..4cf0a736a2 Binary files /dev/null and b/docs/user-guide/img/layer-settings/imagery-layers.jpg differ diff --git a/docs/user-guide/img/tags/new_tag.jpg b/docs/user-guide/img/tags/new_tag.jpg index 11b5cace34..1be72fd458 100644 Binary files a/docs/user-guide/img/tags/new_tag.jpg and b/docs/user-guide/img/tags/new_tag.jpg differ diff --git a/docs/user-guide/img/tags/search_tag.jpg b/docs/user-guide/img/tags/search_tag.jpg index 9a878ad368..b23d3b4895 100644 Binary files a/docs/user-guide/img/tags/search_tag.jpg and b/docs/user-guide/img/tags/search_tag.jpg differ diff --git a/docs/user-guide/img/tags/tags-panel.jpg b/docs/user-guide/img/tags/tags-panel.jpg index 916d8d2651..3d58574ddb 100644 Binary files a/docs/user-guide/img/tags/tags-panel.jpg and b/docs/user-guide/img/tags/tags-panel.jpg differ diff --git a/docs/user-guide/img/widgets/customize-current-time.jpg b/docs/user-guide/img/widgets/customize-current-time.jpg new file mode 100644 index 0000000000..b6f9c58d71 Binary files /dev/null and b/docs/user-guide/img/widgets/customize-current-time.jpg differ diff --git a/docs/user-guide/img/widgets/show-current-time.jpg b/docs/user-guide/img/widgets/show-current-time.jpg new file mode 100644 index 0000000000..ecc698a1cb Binary files /dev/null and b/docs/user-guide/img/widgets/show-current-time.jpg differ diff --git a/docs/user-guide/img/widgets/trace_null_value.jpg b/docs/user-guide/img/widgets/trace_null_value.jpg new file mode 100644 index 0000000000..47bb769460 Binary files /dev/null and b/docs/user-guide/img/widgets/trace_null_value.jpg differ diff --git a/docs/user-guide/layer-settings.md b/docs/user-guide/layer-settings.md index d798be4e9d..fcb2c0bc70 100644 --- a/docs/user-guide/layer-settings.md +++ b/docs/user-guide/layer-settings.md @@ -132,6 +132,10 @@ On the *Display* tab, only the following options are available for a **3D Tile** * The **Visibility limits** to display the layer only within certain scale limits, as reported above. +* The **Imagery Layers Overlay** to drape imagery layers, such as `WMS`, `TMS`, or `WMTS`, on top of `3D Tiles` and rendering them sequentially in the order defined in the TOC. An example can be the following one: + + + * The **Height Offset** above the ground. * The **Format** choosing between `3D Model` and `Point Cloud`. The *Point Cloud* option allows the user to customize the `Maximum Attenuation` of the points based on the distance from the current viewpoint and customize the `Lighting strength` and the `Lighting radius` to improve visualization of the point cloud. diff --git a/docs/user-guide/tags.md b/docs/user-guide/tags.md index ae79beaf89..8a44414d43 100644 --- a/docs/user-guide/tags.md +++ b/docs/user-guide/tags.md @@ -10,17 +10,17 @@ As an admin user, it is possible to manage **Tags** by selecting **Manage Tags** -The *Tags panel* opens, allowing the admin user to: +The *Manager* page opens on **Tags** tab, allowing the admin user to: - + * Create a **New tag** through the button and customize it by adding a *Name*, a *Description* and choosing a *Color* for the label. - + * **Search** for a tag using the search bar - + * **Edit** a tag through the button next to each tag in the list. diff --git a/docs/user-guide/widgets.md b/docs/user-guide/widgets.md index fbd9a02db3..b8220b3199 100644 --- a/docs/user-guide/widgets.md +++ b/docs/user-guide/widgets.md @@ -73,6 +73,7 @@ Once the chart type is chosen, it is possible to set up the trace with the follo * **Trace style** * **Trace axes** * **Trace value formatting** +* **Null Value Handling** ##### Trace Data @@ -182,6 +183,18 @@ An example of a custom trace value tooltip can be the following: +##### Null Value Handling + +The user can customize how **Null Value** are handled for the `X Attribute` field by selecting a *Strategy* from the following options: + + + +* **Ignore** to keep the *Null* values unchanged in the data. + +* **Exclude** to remove all records where the value is *Null* + +* **Use Placeholder** to replace *Null* values with a custom value provided by the user + ##### Trace legend options For the *Pie Charts*, the *Trace legend options* is available and it is displayed as follows: @@ -210,6 +223,16 @@ Through this section, for each axis, the user is allowed to: * Choose the **Type** (between `Auto`, `Linear`, `Category`, `Log` or `Date`): the axis type is auto-detected by looking at the data (*Auto* option is automatically managed and selected by the tool and it is usually good as default setting). +!!! Note + If **`Date`** is selected in the *Type* option, the **Show the Current Time** setting becomes available in the *Axes* panel, allowing you to highlight the current date in the chart. + + Once enabled, you can customize the appearance of the current time line using the following options: + + + * **Color**: choose the line color using the *Color Picker*. + * **Size**: set the line thickness in `px`. + * **Style**: select the line style from `Solid`, `Dot`, `Dash`, `LongDash` or `DashDot` + * Change the **Color** through the color picker * Change the **Font size** diff --git a/java/services/pom.xml b/java/services/pom.xml index a75312b32b..4d556bc101 100644 --- a/java/services/pom.xml +++ b/java/services/pom.xml @@ -163,15 +163,15 @@ org.apache.maven.wagon - wagon-ftp - 1.0-beta-2 + wagon-ssh + 3.5.3 - + geosolutions - ftp://maven.geo-solutions.it/ + sftp://maven.geo-solutions.it/ diff --git a/java/web/pom.xml b/java/web/pom.xml index 957260f419..494b2a0801 100644 --- a/java/web/pom.xml +++ b/java/web/pom.xml @@ -104,20 +104,20 @@ - + org.apache.maven.wagon - wagon-ftp - 1.0-beta-2 + wagon-ssh + 3.5.3 - + geosolutions - ftp://maven.geo-solutions.it/ + sftp://maven.geo-solutions.it/ diff --git a/package.json b/package.json index 325394ac68..1bf87b3b61 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "@babel/core": "7.28.4", "@babel/preset-env": "7.28.3", "@babel/preset-react": "7.27.1", - "@babel/runtime": "7.23.9", "@geosolutions/acorn-jsx": "4.0.2", "@geosolutions/jsdoc": "3.4.4", "@geosolutions/mocha": "6.2.1-3", diff --git a/pom.xml b/pom.xml index ab8a530e1a..96486d8ae8 100644 --- a/pom.xml +++ b/pom.xml @@ -49,7 +49,7 @@ 2.4-SNAPSHOT 2.3.4 - 1.6-SNAPSHOT + 1.5.0 2.3 @@ -397,10 +397,10 @@
- + geosolutions - ftp://maven.geo-solutions.it/ + sftp://maven.geo-solutions.it/ @@ -467,8 +467,8 @@ org.apache.maven.wagon - wagon-ftp - 1.0-beta-2 + wagon-ssh + 3.5.3 diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json index 60be44cc5b..dbd0ee4232 100644 --- a/project/standard/templates/configs/pluginsConfig.json +++ b/project/standard/templates/configs/pluginsConfig.json @@ -139,6 +139,17 @@ "children": ["Permalink"], "autoEnableChildren": ["Permalink"] }, + { + "name": "DynamicLegend", + "glyph": "align-left", + "title": "plugins.DynamicLegend.title", + "description": "plugins.DynamicLegend.description", + "dependencies": ["SidebarMenu"], + "defaultConfig": { + "isFloating": false, + "flatLegend": false + } + }, { "name": "Permalink", "glyph": "link", diff --git a/project/standard/templates/pom.xml b/project/standard/templates/pom.xml index 43e6947a92..3a282d3711 100644 --- a/project/standard/templates/pom.xml +++ b/project/standard/templates/pom.xml @@ -29,9 +29,9 @@ 1.10.2 1.10-SNAPSHOT - 2.4-SNAPSHOT - 1.6-SNAPSHOT - 2.3.3 + 2.3-SNAPSHOT + 1.5.0 + 2.3.4 diff --git a/web/client/api/GeoStoreDAO.js b/web/client/api/GeoStoreDAO.js index 60bcbcf470..3904afdae0 100644 --- a/web/client/api/GeoStoreDAO.js +++ b/web/client/api/GeoStoreDAO.js @@ -298,7 +298,7 @@ const Api = { }, writeSecurityRules: function(SecurityRuleList = {}) { return "" + - (castArray(SecurityRuleList.SecurityRule) || []).map( rule => { + (castArray(SecurityRuleList.SecurityRule) || []).flatMap( rule => { if (rule.canRead || rule.canWrite) { if (rule.user) { return "" @@ -312,8 +312,18 @@ const Api = { + "" + boolToString(rule.canWrite) + "" + "" + (rule.group.id || "") + "" + (rule.group.groupName || "") + "" + ""; + } else if (rule.ipRanges) { + // Create a separate SecurityRule for each IP range + const ipRangesArray = castArray(rule.ipRanges.ipRange); + return ipRangesArray.map(ipRange => + "" + + "" + boolToString(rule.canRead || rule.canWrite) + "" + + "" + boolToString(rule.canWrite) + "" + + "" + (ipRange.id) + "" + + "" + ); } - // NOTE: if rule has no group or user, it is skipped + // NOTE: if rule has no group, user, or ipRanges, it is skipped // NOTE: if rule is "no read and no write", it is skipped } return ""; @@ -660,6 +670,42 @@ const Api = { removeFavoriteResource: (userId, resourceId, options) => { const url = `/users/user/${userId}/favorite/${resourceId}`; return axios.delete(url, Api.addBaseUrl(parseOptions(options))).then((response) => response.data); + }, + getIPRanges: function(options = {}) { + const url = "ipranges/"; + return axios.get(url, this.addBaseUrl(parseOptions(options))).then(function(response) {return response.data || []; }); + }, + createIPRange: function(ipRange, options) { + const url = "ipranges/"; + const xmlPayload = [ + '', + ``, + ``, + '' + ].join(''); + return axios.post(url, xmlPayload, this.addBaseUrl(merge({ + headers: { + 'Content-Type': "application/xml" + } + }, parseOptions(options)))).then(function(response) {return response.data; }); + }, + updateIPRange: function(id, ipRange, options = {}) { + const url = "ipranges/" + id; + const xmlPayload = [ + '', + ``, + ``, + '' + ].join(''); + return axios.put(url, xmlPayload, this.addBaseUrl(merge({ + headers: { + 'Content-Type': "application/xml" + } + }, parseOptions(options)))).then(function(response) {return response.data; }); + }, + deleteIPRange: function(id, options = {}) { + const url = "ipranges/" + id; + return axios.delete(url, this.addBaseUrl(parseOptions(options))).then(function(response) {return response.data; }); } }; diff --git a/web/client/components/TOC/fragments/settings/ThreeDTilesSettings.jsx b/web/client/components/TOC/fragments/settings/ThreeDTilesSettings.jsx index 3f5aa9f99f..5042c05c91 100644 --- a/web/client/components/TOC/fragments/settings/ThreeDTilesSettings.jsx +++ b/web/client/components/TOC/fragments/settings/ThreeDTilesSettings.jsx @@ -9,11 +9,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { FormGroup, ControlLabel, InputGroup } from 'react-bootstrap'; +import { FormGroup, ControlLabel, InputGroup, Checkbox } from 'react-bootstrap'; import DebouncedFormControl from '../../../misc/DebouncedFormControl'; import Message from '../../../I18N/Message'; import PointCloudShadingSettings from './PointCloudShadingSettings'; import Select from 'react-select'; +import InfoPopover from '../../../widgets/widget/InfoPopover'; /** * ThreeDTilesSettings. This component shows the 3d tiles options available @@ -29,6 +30,17 @@ function ThreeDTilesSettings({ } return (
+ + onChange("enableImageryOverlay", e.target.checked)} + > + } /> + + diff --git a/web/client/components/TOC/fragments/settings/__tests__/ThreeDTilesSettings-test.jsx b/web/client/components/TOC/fragments/settings/__tests__/ThreeDTilesSettings-test.jsx index 27553553d6..8353825d9d 100644 --- a/web/client/components/TOC/fragments/settings/__tests__/ThreeDTilesSettings-test.jsx +++ b/web/client/components/TOC/fragments/settings/__tests__/ThreeDTilesSettings-test.jsx @@ -40,9 +40,10 @@ describe('ThreeDTilesSettings', () => { ReactDOM.render(, document.getElementById('container')); + }} onImageryLayersTreeUpdate={() => {}}/>, document.getElementById('container')); const checkboxNodes = document.querySelectorAll('.checkbox'); expect([...checkboxNodes].map(node => node.innerText)).toEqual([ + 'layerProperties.3dTiles.enableImageryOverlay', 'layerProperties.3dTiles.pointCloudShading.attenuation', 'layerProperties.3dTiles.pointCloudShading.eyeDomeLighting' ]); diff --git a/web/client/components/manager/ipmanager/IPActions.jsx b/web/client/components/manager/ipmanager/IPActions.jsx new file mode 100644 index 0000000000..104ea3600d --- /dev/null +++ b/web/client/components/manager/ipmanager/IPActions.jsx @@ -0,0 +1,73 @@ +/* + * Copyright 2025, 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 { Button } from 'react-bootstrap'; +import Message from '../../I18N/Message'; +import InputControl from '../../../plugins/ResourcesCatalog/components/InputControl'; + +/** + * New IP button component for toolbar + */ +export function NewIP({ onNewIP }) { + return ( + + ); +} + +/** + * Edit IP button component for card actions + */ +export function EditIP({ component, onEdit, resource: ip }) { + const Component = component; + return ( + onEdit(ip)} + glyph="wrench" + labelId="ipManager.editTooltip" + square + /> + ); +} + +/** + * Delete IP button component for card actions + */ +export function DeleteIP({ component, onDelete, resource: ip }) { + const Component = component; + return ( + onDelete(ip)} + glyph="trash" + labelId="ipManager.deleteTooltip" + square + bsStyle="danger" + /> + ); +} + +/** + * IP Filter search component for toolbar + */ +export function IPFilter({ onSearch, query }) { + const handleFieldChange = (params) => { + onSearch({ params: { q: params } }); + }; + return ( + + ); +} + diff --git a/web/client/components/manager/ipmanager/IPDialog.jsx b/web/client/components/manager/ipmanager/IPDialog.jsx new file mode 100644 index 0000000000..e3e2aa00bf --- /dev/null +++ b/web/client/components/manager/ipmanager/IPDialog.jsx @@ -0,0 +1,111 @@ +/* + * Copyright 2025, 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, { useState, useEffect } from 'react'; +import { Button, Glyphicon, FormGroup, ControlLabel, FormControl, HelpBlock } from 'react-bootstrap'; +import Modal from '../../misc/Modal'; +import Message from '../../I18N/Message'; +import ConfirmDialog from '../../layout/ConfirmDialog'; +import Text from '../../layout/Text'; +import { validateIPAddress } from '../../../utils/IPValidationUtils'; + +/** + * Dialog for creating/editing IP ranges + */ +export default function IPDialog({ show, ip, onSave, onClose, loading = false }) { + const [ipAddress, setIpAddress] = useState(ip?.cidr || ''); + const [description, setDescription] = useState(ip?.description || ''); + const [validationError, setValidationError] = useState(''); + + useEffect(() => { + setIpAddress(ip?.cidr || ''); + setDescription(ip?.description || ''); + setValidationError(''); + }, [ip, show]); + + const handleSave = () => { + // Clear previous errors + setValidationError(''); + + // Validate IP address + const ipValidation = validateIPAddress(ipAddress); + if (!ipValidation.isValid) { + setValidationError(ipValidation.error); + return; + } + + // If validation passes, save + onSave({ id: ip?.id, ipAddress, description }); + }; + + return ( + + + + + + + + + + setIpAddress(e.target.value)} + placeholder="e.g., 192.168.1.1/32 or 192.168.1.0/24" + /> + {validationError && ( + + + + )} + + + + setDescription(e.target.value)} + placeholder="(Optional) Enter description" + /> + + + + + + + + ); +} + +/** + * Delete confirmation dialog for IP ranges + */ +export function DeleteConfirm({ show, ip, onDelete, onClose, loading = false }) { + return ( + onDelete(ip)} + titleId="ipManager.deleteTitle" + loading={loading} + cancelId="ipManager.cancel" + confirmId="ipManager.deleteButton" + variant="danger" + > + {ip?.cidr}? + + ); +} + diff --git a/web/client/components/manager/ipmanager/__tests__/IPDialog-test.jsx b/web/client/components/manager/ipmanager/__tests__/IPDialog-test.jsx new file mode 100644 index 0000000000..eeeda55817 --- /dev/null +++ b/web/client/components/manager/ipmanager/__tests__/IPDialog-test.jsx @@ -0,0 +1,107 @@ +/* + * Copyright 2025, 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 expect from 'expect'; +import ReactDOM from 'react-dom'; +import ReactTestUtils from 'react-dom/test-utils'; + +import IPDialog, { DeleteConfirm } from '../IPDialog'; + +describe('IPDialog component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should call onSave with valid CIDR data when save clicked', () => { + const onSave = expect.createSpy(); + const onClose = expect.createSpy(); + + ReactDOM.render( + , + document.getElementById("container") + ); + + const ipInput = document.querySelectorAll('input[type="text"]')[0]; + const descInput = document.querySelectorAll('input[type="text"]')[1]; + + ipInput.value = '192.168.1.0/24'; + ReactTestUtils.Simulate.change(ipInput); + + descInput.value = 'Office Network'; + ReactTestUtils.Simulate.change(descInput); + + const buttons = document.querySelectorAll('.modal-footer button'); + const saveButton = buttons[1]; + ReactTestUtils.Simulate.click(saveButton); + + expect(onSave).toHaveBeenCalled(); + expect(onSave.calls[0].arguments[0].ipAddress).toBe('192.168.1.0/24'); + expect(onSave.calls[0].arguments[0].description).toBe('Office Network'); + }); + + it('should show validation error for invalid IP', () => { + const onSave = expect.createSpy(); + const onClose = expect.createSpy(); + + ReactDOM.render( + , + document.getElementById("container") + ); + + const ipInput = document.querySelectorAll('input[type="text"]')[0]; + ipInput.value = '192.168.1.1'; // No CIDR mask + ReactTestUtils.Simulate.change(ipInput); + + const buttons = document.querySelectorAll('.modal-footer button'); + const saveButton = buttons[1]; + ReactTestUtils.Simulate.click(saveButton); + + const errorBlock = document.querySelector('.help-block'); + expect(errorBlock).toExist(); + expect(onSave).toNotHaveBeenCalled(); + }); +}); + +describe('DeleteConfirm component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should call onDelete when confirmed', () => { + const onDelete = expect.createSpy(); + const onClose = expect.createSpy(); + const ip = { id: 1, cidr: '192.168.1.0/24' }; + + ReactDOM.render( + , + document.getElementById("container") + ); + + const buttons = document.querySelectorAll('button'); + const deleteButton = buttons[buttons.length - 1]; + ReactTestUtils.Simulate.click(deleteButton); + + expect(onDelete).toHaveBeenCalled(); + expect(onDelete.calls[0].arguments[0]).toBe(ip); + }); +}); + diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/rulesgrid.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/rulesgrid.js index 87f82b0005..9d6f44df7a 100644 --- a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/rulesgrid.js +++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/rulesgrid.js @@ -97,7 +97,7 @@ export default compose( const isStandAloneGeofence = Api.getRuleServiceType() === 'geofence'; let columns = [{ key: 'rolename', name: , filterable: true, filterRenderer: FilterRenderers.RolesFilter}, { key: 'username', name: , filterable: true, filterRenderer: FilterRenderers.UsersFilter}, - { key: 'ipaddress', name: , filterable: false}, + { key: 'ipaddress', name: , filterable: true, filterRenderer: FilterRenderers.IPAddressFilter}, { key: 'service', name: , filterable: true, filterRenderer: FilterRenderers.ServicesFilter}, { key: 'request', name: , filterable: true, filterRenderer: FilterRenderers.RequestsFilter }, { key: 'workspace', name: , filterable: true, filterRenderer: FilterRenderers.WorkspacesFilter}, diff --git a/web/client/components/map/cesium/Layer.jsx b/web/client/components/map/cesium/Layer.jsx index c31f64a830..6e4ff2d72f 100644 --- a/web/client/components/map/cesium/Layer.jsx +++ b/web/client/components/map/cesium/Layer.jsx @@ -22,7 +22,9 @@ class CesiumLayer extends React.Component { onCreationError: PropTypes.func, position: PropTypes.number, securityToken: PropTypes.string, - zoom: PropTypes.number + zoom: PropTypes.number, + imageryLayersTreeUpdatedCount: PropTypes.number, + onImageryLayersTreeUpdate: PropTypes.func }; componentDidMount() { @@ -33,6 +35,9 @@ class CesiumLayer extends React.Component { if (this.props.options && this.layer && visibility) { this.addLayer(this.props); this.updateZIndex(); + if (this.provider) { + this.props.onImageryLayersTreeUpdate(); + } } } @@ -48,6 +53,7 @@ class CesiumLayer extends React.Component { if (this.provider) { this.provider._position = newProps.position; } + this.props.onImageryLayersTreeUpdate(); } if (this.props.options && this.props.options.params && this.layer.updateParams && newProps.options.visibility) { const changed = Object.keys(this.props.options.params).reduce((found, param) => { @@ -64,7 +70,6 @@ class CesiumLayer extends React.Component { setTimeout(() => { this.removeLayer(oldProvider); }, 1000); - } } this.updateLayer(newProps, this.props); @@ -77,11 +82,7 @@ class CesiumLayer extends React.Component { if (this.layer.detached && this.layer?.remove) { this.layer.remove(); } else { - if (this.layer.destroy) { - this.layer.destroy(); - } - - this.props.map.imageryLayers.remove(this.provider); + this.removeLayer(); } if (this.refreshTimer) { clearInterval(this.refreshTimer); @@ -159,12 +160,17 @@ class CesiumLayer extends React.Component { setImageryLayerVisibility = (visibility, props) => { // this type of layer will be added and removed from the imageryLayers array of Cesium - if (visibility) { - this.addLayer(props); - this.updateZIndex(); + if (!this.provider) { + if (visibility) { + this.addLayer(props); + this.updateZIndex(); + return; + } + this.removeLayer(); return; } - this.removeLayer(); + // use the native show property to avoid re-creation of an imagery layer + this.provider.show = !!visibility; return; } @@ -205,7 +211,7 @@ class CesiumLayer extends React.Component { }; setLayerOpacity = (opacity) => { - var oldOpacity = this.props.options && this.props.options.opacity !== undefined ? this.props.options.opacity : 1.0; + const oldOpacity = this.props.options && this.props.options.opacity !== undefined ? this.props.options.opacity : 1.0; if (opacity !== oldOpacity && this.layer && this.provider) { this.provider.alpha = opacity; this.props.map.scene.requestRender(); @@ -222,6 +228,7 @@ class CesiumLayer extends React.Component { const opts = { ...options, ...(position ? { zIndex: position } : null), + position, securityToken, ...(this._isProxy ? { forceProxy: this._isProxy } : null) }; @@ -244,12 +251,16 @@ class CesiumLayer extends React.Component { { ...newProps.options, securityToken: newProps.securityToken, - forceProxy: this._isProxy + forceProxy: this._isProxy, + imageryLayersTreeUpdatedCount: newProps.imageryLayersTreeUpdatedCount, + position: newProps.position }, { ...oldProps.options, securityToken: oldProps.securityToken, - forceProxy: this._prevIsProxy + forceProxy: this._prevIsProxy, + imageryLayersTreeUpdatedCount: oldProps.imageryLayersTreeUpdatedCount, + position: oldProps.position }, this.props.map); if (newLayer) { @@ -273,6 +284,7 @@ class CesiumLayer extends React.Component { this.provider.alpha = newProps.options.opacity; } } + this.props.onImageryLayersTreeUpdate(); newProps.map.scene.requestRender(); }; @@ -311,9 +323,13 @@ class CesiumLayer extends React.Component { } removeLayer = (provider) => { + if (this.layer.destroy) { + this.layer.destroy(); + } const toRemove = provider || this.provider; if (toRemove) { this.props.map.imageryLayers.remove(toRemove); + this.props.onImageryLayersTreeUpdate(); } // detached layers are layers that do not work through a provider // for this reason they cannot be added or removed from the map imageryProviders diff --git a/web/client/components/map/cesium/Map.jsx b/web/client/components/map/cesium/Map.jsx index 97e69f219b..7fab2da5aa 100644 --- a/web/client/components/map/cesium/Map.jsx +++ b/web/client/components/map/cesium/Map.jsx @@ -24,7 +24,7 @@ import { getResolutions } from '../../../utils/MapUtils'; import { reprojectBbox } from '../../../utils/CoordinatesUtils'; -import { throttle, isEqual } from 'lodash'; +import { throttle, isEqual, debounce } from 'lodash'; class CesiumMap extends React.Component { static propTypes = { @@ -81,7 +81,8 @@ class CesiumMap extends React.Component { }; state = { - renderError: null + renderError: null, + imageryLayersTreeUpdatedCount: 0 }; UNSAFE_componentWillMount() { @@ -194,6 +195,7 @@ class CesiumMap extends React.Component { this.updateLighting({}, this.props); this.forceUpdate(); map.scene.requestRender(); + } UNSAFE_componentWillReceiveProps(newProps) { @@ -451,7 +453,13 @@ class CesiumMap extends React.Component { map: map, projection: mapProj, onCreationError: this.props.onCreationError, - zoom: this.props.zoom + zoom: this.props.zoom, + imageryLayersTreeUpdatedCount: this.state.imageryLayersTreeUpdatedCount, + onImageryLayersTreeUpdate: debounce(() => + this.setState(({ imageryLayersTreeUpdatedCount }) => ({ + imageryLayersTreeUpdatedCount: imageryLayersTreeUpdatedCount + 1 + })), + 50) }) : null; }) : null; const ErrorPanel = this.props.errorPanel; diff --git a/web/client/components/map/cesium/__tests__/Layer-test.jsx b/web/client/components/map/cesium/__tests__/Layer-test.jsx index df7167e191..62bba00a1f 100644 --- a/web/client/components/map/cesium/__tests__/Layer-test.jsx +++ b/web/client/components/map/cesium/__tests__/Layer-test.jsx @@ -33,14 +33,64 @@ import ConfigUtils from '../../../../utils/ConfigUtils'; import MockAdapter from 'axios-mock-adapter'; import axios from '../../../../libs/ajax'; +const tilesetMock = { + "asset": { + "version": "1.0" + }, + "geometricError": 100, + "root": { + "boundingVolume": { + "region": [ + -1.3197004795898053, + 0.6988582109, + -1.3196595204101946, + 0.6988897891, + 0, + 20 + ] + }, + "geometricError": 10, + "refine": "REPLACE", + "content": { + "uri": "file.i3dm" + }, + "children": [ + { + "boundingVolume": { + "region": [ + -1.3197004795898053, + 0.6988582109, + -1.3196595204101946, + 0.6988897891, + 0, + 20 + ] + }, + "geometricError": 0, + "content": { + "uri": "tree.i3dm" + } + } + ] + }, + "properties": { + "Height": { + "minimum": 20, + "maximum": 20 + } + } +}; + describe('Cesium layer', () => { let map; let mockAxios; + let originalFromUrl; beforeEach((done) => { mockAxios = new MockAdapter(axios); document.body.innerHTML = '
'; map = new Cesium.Viewer("map"); map.imageryLayers.removeAll(); + originalFromUrl = Cesium.Cesium3DTileset.fromUrl; setTimeout(done); }); @@ -54,6 +104,7 @@ describe('Cesium layer', () => { } catch(e) {} /* eslint-enable */ document.body.innerHTML = ''; + Cesium.Cesium3DTileset.fromUrl = originalFromUrl; setTimeout(done); }); it('missing layer', () => { @@ -109,7 +160,7 @@ describe('Cesium layer', () => { // create layers var layer = ReactDOM.render( , document.getElementById("container")); + options={options} map={map} onImageryLayersTreeUpdate={() => {}}/>, document.getElementById("container")); expect(layer).toExist(); expect(map.imageryLayers.length).toBe(1); @@ -126,7 +177,7 @@ describe('Cesium layer', () => { // create layer var layer = ReactDOM.render( , document.getElementById("container")); + options={options} map={map} onImageryLayersTreeUpdate={() => {}}/>, document.getElementById("container")); expect(layer).toExist(); expect(map.imageryLayers.length).toBe(1); @@ -506,18 +557,18 @@ describe('Cesium layer', () => { // create layers var layer = ReactDOM.render( , document.getElementById("container")); + options={{}} position={0} map={map} onImageryLayersTreeUpdate={() => {}}/>, document.getElementById("container")); expect(layer).toExist(); expect(map.imageryLayers.length).toBe(0); // not visibile layers are removed from the leaflet maps layer = ReactDOM.render( , document.getElementById("container")); + options={{visibility: false}} position={0} map={map} onImageryLayersTreeUpdate={() => {}}/>, document.getElementById("container")); expect(map.imageryLayers.length).toBe(0); layer = ReactDOM.render( , document.getElementById("container")); + options={{visibility: true}} position={0} map={map} onImageryLayersTreeUpdate={() => {}}/>, document.getElementById("container")); expect(map.imageryLayers.length).toBe(1); }); @@ -534,7 +585,7 @@ describe('Cesium layer', () => { // create layers var layer = ReactDOM.render( , document.getElementById("container")); + options={options} position={0} map={map} onImageryLayersTreeUpdate={() => {}}/>, document.getElementById("container")); expect(layer).toExist(); @@ -572,12 +623,12 @@ describe('Cesium layer', () => { }; const layer1 = ReactDOM.render( + options={options1} map={map} position={2} onImageryLayersTreeUpdate={() => {}}/> , document.getElementById("container")); const layer2 = ReactDOM.render( + options={options2} map={map} position={1} onImageryLayersTreeUpdate={() => {}}/> , document.getElementById("container2")); waitFor(() => { @@ -596,7 +647,7 @@ describe('Cesium layer', () => { // create layers var layer = ReactDOM.render( , document.getElementById("container")); + options={options} map={map} onImageryLayersTreeUpdate={() => {}}/>, document.getElementById("container")); expect(layer).toExist(); @@ -1124,6 +1175,7 @@ describe('Cesium layer', () => { position={0} map={map} zoom={0} + onImageryLayersTreeUpdate={() => {}} />, document.getElementById("container")); expect(layer).toBeTruthy(); @@ -1139,11 +1191,12 @@ describe('Cesium layer', () => { position={0} map={map} zoom={11} + onImageryLayersTreeUpdate={() => {}} />, document.getElementById("container")); expect(layer).toBeTruthy(); // layer removed - expect(map.imageryLayers.length).toBe(0); + expect(map.imageryLayers.get(0).show).toBe(false); }); @@ -1160,10 +1213,11 @@ describe('Cesium layer', () => { position={0} map={map} zoom={11} + onImageryLayersTreeUpdate={() => {}} />, document.getElementById("container")); expect(layer).toBeTruthy(); - expect(map.imageryLayers.length).toBe(1); + expect(map.imageryLayers.get(0).show).toBe(true); layer = ReactDOM.render( { position={0} map={map} zoom={0} + onImageryLayersTreeUpdate={() => {}} />, document.getElementById("container")); expect(layer).toBeTruthy(); // layer removed - expect(map.imageryLayers.length).toBe(0); + expect(map.imageryLayers.get(0).show).toBe(false); }); @@ -1198,6 +1253,7 @@ describe('Cesium layer', () => { position={0} map={map} zoom={0} + onImageryLayersTreeUpdate={() => {}} />, document.getElementById("container")); expect(layer).toBeTruthy(); @@ -1216,6 +1272,7 @@ describe('Cesium layer', () => { position={0} map={map} zoom={0} + onImageryLayersTreeUpdate={() => {}} />, document.getElementById("container")); expect(layer).toBeTruthy(); @@ -1317,10 +1374,34 @@ describe('Cesium layer', () => { expect(cmp.layer.getTileSet).toBeTruthy(); expect(cmp.layer.getTileSet()).toBe(undefined); }); - it('should create a 3d tiles layer with and offset applied to the height', (done) => { + // skipping because randomly fails in CI see https://github.com/geosolutions-it/MapStore2/issues/11691 + it.skip('should create a 3d tiles layer with and offset applied to the height', (done) => { + Cesium.Cesium3DTileset.fromUrl = () => { + const tileset = new Cesium.Cesium3DTileset({ + dynamicScreenSpaceError: false + }); + tileset._root = { + updateTransform: () => {}, + boundingSphere: new Cesium.BoundingSphere(), + computedTransform: new Cesium.Matrix4(), + updateVisibility: () => {}, + updateExpiration: () => {}, + destroy: () => {}, + tileset: { + _maximumPriority: {}, + _minimumPriority: {}, + _priorityHolder: {} + } + }; + tileset.destroy = () => {}; + return Promise.resolve(tileset); + }; + mockAxios.onGet().reply(() =>{ + return [200, tilesetMock]; + }); const options = { type: '3dtiles', - url: 'base/web/client/test-resources/3dtiles/tileset.json', + url: '/test/tileset.json', title: 'Title', visibility: true, heightOffset: 100, @@ -1350,18 +1431,23 @@ describe('Cesium layer', () => { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, - 19, -74, 64, 1 + 100, 0, 0, 1 ] ); done(); }) .catch(done); }); + // skipping because randomly fails in CI see https://github.com/geosolutions-it/MapStore2/issues/11691 + it.skip('should not crash if the heightOffset is not a number', (done) => { - it('should not crash if the heightOffset is not a number', (done) => { + Cesium.Cesium3DTileset.fromUrl = () => Promise.resolve(new Cesium.Cesium3DTileset()); + mockAxios.onGet().reply(()=>{ + return [200, tilesetMock]; + }); const options = { type: '3dtiles', - url: 'base/web/client/test-resources/3dtiles/tileset.json', + url: 'http://test/tileset.json', title: 'Title', visibility: true, heightOffset: NaN, diff --git a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js index 86b2578782..17d065087d 100644 --- a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js +++ b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js @@ -73,6 +73,26 @@ function clip3DTiles(tileSet, options, map) { }); } +const applyImageryLayers = (tileSet, options, map) => { + if (!options.enableImageryOverlay || !tileSet || tileSet.isDestroyed()) return; + // Collect map layers that should be applied to primitive + const mapLayers = []; + for (let i = 0; i < map.imageryLayers.length; i++) { + const layer = map.imageryLayers.get(i); + if (layer._position > options.position) { + mapLayers.push(layer); + } + } + // Add layers in the correct order + mapLayers.forEach((layer, idx) => { + const current = tileSet.imageryLayers.get(idx); + if (current !== layer) { + tileSet.imageryLayers.add(layer); + } + }); + map.scene.requestRender(); +}; + let pendingCallbacks = {}; function ensureReady(layer, callback, eventKey) { @@ -130,6 +150,9 @@ const createLayer = (options, map) => { let promise; const removeTileset = () => { updateGooglePhotorealistic3DTilesBrandLogo(map, options, tileSet); + if (tileSet?.imageryLayers) { + tileSet.imageryLayers.removeAll(false); + } map.scene.primitives.remove(tileSet); tileSet = undefined; }; @@ -137,46 +160,58 @@ const createLayer = (options, map) => { getTileSet: () => tileSet, getResource: () => resource }; + + let timeout = undefined; + return { detached: true, ...layer, add: () => { - resource = new Cesium.Resource({ - url: options.url, - proxy: options.forceProxy ? new Cesium.DefaultProxy(getProxyUrl()) : undefined - // TODO: axios supports also adding access tokens or credentials (e.g. authkey, Authentication header ...). - // if we want to use internal cesium functionality to retrieve data - // we need to create a utility to set a CesiumResource that applies also this part. - // in addition to this proxy. - }); - promise = Cesium.Cesium3DTileset.fromUrl(resource, - { - showCreditsOnScreen: true - } - ).then((_tileSet) => { - tileSet = _tileSet; - updateGooglePhotorealistic3DTilesBrandLogo(map, options, tileSet); - map.scene.primitives.add(tileSet); - // assign the original mapstore id of the layer - tileSet.msId = options.id; - ensureReady(layer, () => { - updateModelMatrix(tileSet, options); - clip3DTiles(tileSet, options, map); - updateShading(tileSet, options, map); - getStyle(options) - .then((style) => { - if (style) { - tileSet.style = new Cesium.Cesium3DTileStyle(style); - } - Object.keys(pendingCallbacks).forEach((eventKey) => { - pendingCallbacks[eventKey](tileSet); + // delay creation of tileset when frequents recreation are requested + timeout = setTimeout(() => { + timeout = undefined; + resource = new Cesium.Resource({ + url: options.url, + proxy: options.forceProxy ? new Cesium.DefaultProxy(getProxyUrl()) : undefined + // TODO: axios supports also adding access tokens or credentials (e.g. authkey, Authentication header ...). + // if we want to use internal cesium functionality to retrieve data + // we need to create a utility to set a CesiumResource that applies also this part. + // in addition to this proxy. + }); + promise = Cesium.Cesium3DTileset.fromUrl(resource, + { + showCreditsOnScreen: true + } + ).then((_tileSet) => { + tileSet = _tileSet; + updateGooglePhotorealistic3DTilesBrandLogo(map, options, tileSet); + map.scene.primitives.add(tileSet); + // assign the original mapstore id of the layer + tileSet.msId = options.id; + ensureReady(layer, () => { + updateModelMatrix(tileSet, options); + clip3DTiles(tileSet, options, map); + updateShading(tileSet, options, map); + getStyle(options) + .then((style) => { + if (style) { + tileSet.style = new Cesium.Cesium3DTileStyle(style); + } + Object.keys(pendingCallbacks).forEach((eventKey) => { + pendingCallbacks[eventKey](tileSet); + }); + pendingCallbacks = {}; + applyImageryLayers(tileSet, options, map); }); - pendingCallbacks = {}; - }); + }); }); - }); + }, 50); }, remove: () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } if (tileSet) { removeTileset(); return; @@ -194,7 +229,11 @@ const createLayer = (options, map) => { Layers.registerType('3dtiles', { create: createLayer, update: function(layer, newOptions, oldOptions, map) { - if (newOptions.forceProxy !== oldOptions.forceProxy) { + if (newOptions.forceProxy !== oldOptions.forceProxy + // recreate the tileset when the imagery has been updated and the layer has enableImageryOverlay set to true + || newOptions.enableImageryOverlay && (newOptions.imageryLayersTreeUpdatedCount !== oldOptions.imageryLayersTreeUpdatedCount) + || (newOptions.enableImageryOverlay !== oldOptions.enableImageryOverlay) + ) { return createLayer(newOptions, map); } if ( diff --git a/web/client/components/mapcontrols/mouseposition/mousePosition.css b/web/client/components/mapcontrols/mouseposition/mousePosition.css index 4b710598b3..36ba220445 100644 --- a/web/client/components/mapcontrols/mouseposition/mousePosition.css +++ b/web/client/components/mapcontrols/mouseposition/mousePosition.css @@ -62,8 +62,6 @@ background-color: white; width: 160px; height: 46px; - padding-left: 5px; - padding-top: 2px; } #mapstore-mouseposition h5 { diff --git a/web/client/components/mapviews/MapViewSettings.jsx b/web/client/components/mapviews/MapViewSettings.jsx index 80a29eb697..1e3cc7da4c 100644 --- a/web/client/components/mapviews/MapViewSettings.jsx +++ b/web/client/components/mapviews/MapViewSettings.jsx @@ -17,6 +17,7 @@ import LayersSection from './settings/LayersSection'; import { getResourceFromLayer } from '../../api/MapViews'; import { ViewSettingsTypes } from '../../utils/MapViewsUtils'; import Message from '../I18N/Message'; +import useBatchedUpdates from '../../hooks/useBatchedUpdates'; const sections = { [ViewSettingsTypes.DESCRIPTION]: DescriptionSection, @@ -54,20 +55,68 @@ function ViewSettings({ return false; }); + /** + * Custom batching logic for layers and groups. + * Accumulates changes in an object with separate keys for layers and groups, + * then applies them all at once to prevent race conditions. + */ + const [batchedUpdate] = useBatchedUpdates( + (changes) => { + const updatedView = { ...view }; + const { layers: layerChanges = {}, groups: groupChanges = {} } = changes; + + // Apply layer changes + if (Object.keys(layerChanges).length > 0) { + const updatedLayers = [...(view?.layers || [])]; + Object.entries(layerChanges).forEach(([layerId, layerOptions]) => { + const layerIndex = updatedLayers.findIndex(layer => layer.id === layerId); + if (layerIndex >= 0) { + updatedLayers[layerIndex] = { ...updatedLayers[layerIndex], ...layerOptions }; + } else { + updatedLayers.push({ id: layerId, ...layerOptions }); + } + }); + updatedView.layers = updatedLayers; + } + + // Apply group changes + if (Object.keys(groupChanges).length > 0) { + const updatedGroups = [...(view?.groups || [])]; + Object.entries(groupChanges).forEach(([groupId, groupOptions]) => { + const groupIndex = updatedGroups.findIndex(group => group.id === groupId); + if (groupIndex >= 0) { + updatedGroups[groupIndex] = { ...updatedGroups[groupIndex], ...groupOptions }; + } else { + updatedGroups.push({ id: groupId, ...groupOptions }); + } + }); + updatedView.groups = updatedGroups; + } + + onChange(updatedView); + }, + { + delay: 0, + reducer: (accumulated, type, id, options) => { + const current = accumulated || { layers: {}, groups: {} }; + return { + layers: type === 'layers' ? { ...current.layers, [id]: { ...current.layers[id], ...options } } : current.layers, + groups: type === 'groups' ? { ...current.groups, [id]: { ...current.groups[id], ...options } } : current.groups + }; + } + } + ); + function handleChange(options) { onChange({ ...view, ...options }); } + /** + * Handles layer changes with batching to prevent race conditions. + * Multiple calls are batched and flushed together. + */ function handleChangeLayer(layerId, options) { - const viewLayer = view?.layers?.find(vLayer => vLayer.id === layerId); - const viewLayers = viewLayer - ? (view?.layers || []) - .map((vLayer) => vLayer.id === layerId ? ({ ...viewLayer, ...options }) : vLayer) - : [...(view?.layers || []), { id: layerId, ...options }]; - onChange({ - ...view, - layers: viewLayers - }); + batchedUpdate('layers', layerId, options); } function handleResetLayer(layerId) { @@ -78,16 +127,12 @@ function ViewSettings({ }); } + /** + * Handles group changes with batching to prevent race conditions. + * Multiple calls are batched and flushed together. + */ function handleChangeGroup(groupId, options) { - const viewGroup = view?.groups?.find(vGroup => vGroup.id === groupId); - const viewGroups = viewGroup - ? (view?.groups || []) - .map((vGroup) => vGroup.id === groupId ? ({ ...viewGroup, ...options }) : vGroup) - : [...(view?.groups || []), { id: groupId, ...options }]; - onChange({ - ...view, - groups: viewGroups - }); + batchedUpdate('groups', groupId, options); } function handleResetGroup(groupId) { diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index 7dcc0277c8..547fb5bdd6 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -50,7 +50,7 @@ }, "authenticationRules": [ { - "urlPattern": ".*geostore.*", + "urlPattern": ".*rest/geostore.*", "method": "bearer" }, { @@ -1239,6 +1239,7 @@ "UserManager", "GroupManager", "TagsManager", + "IPManager", "Footer", { "name": "About" } ] diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json index aca03bc914..8fec6625f7 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -142,6 +142,17 @@ "children": ["Permalink"], "autoEnableChildren": ["Permalink"] }, + { + "name": "DynamicLegend", + "glyph": "align-left", + "title": "plugins.DynamicLegend.title", + "description": "plugins.DynamicLegend.description", + "dependencies": ["SidebarMenu"], + "defaultConfig": { + "isFloating": false, + "flatLegend": false + } + }, { "name": "Permalink", "glyph": "link", diff --git a/web/client/epics/__tests__/longitudinalProfile-test.js b/web/client/epics/__tests__/longitudinalProfile-test.js index a492f1f5be..037149b8b1 100644 --- a/web/client/epics/__tests__/longitudinalProfile-test.js +++ b/web/client/epics/__tests__/longitudinalProfile-test.js @@ -9,7 +9,8 @@ import expect from 'expect'; import { - LPonDockClosedEpic + LPonDockClosedEpic, + LPclickToProfileEpic } from '../longitudinalProfile'; import { testEpic } from './epicTestUtils'; @@ -17,8 +18,9 @@ import { setControlProperty } from '../../actions/controls'; import { CONTROL_DOCK_NAME, CONTROL_NAME, LONGITUDINAL_OWNER, LONGITUDINAL_VECTOR_LAYER_ID, LONGITUDINAL_VECTOR_LAYER_ID_POINT } from '../../plugins/longitudinalProfile/constants'; import { CHANGE_GEOMETRY } from '../../actions/longitudinalProfile'; import { REMOVE_ADDITIONAL_LAYER } from '../../actions/additionallayers'; -import { UNREGISTER_EVENT_LISTENER } from '../../actions/map'; +import { UNREGISTER_EVENT_LISTENER, CLICK_ON_MAP } from '../../actions/map'; import { CHANGE_DRAWING_STATUS } from '../../actions/draw'; +import { SHOW_NOTIFICATION } from '../../actions/notifications'; describe('longitudinalProfile Epics', () => { it('test default LPonDockClosedEpic epic', (done) => { @@ -88,4 +90,50 @@ describe('longitudinalProfile Epics', () => { } }); }); + + it('LPclickToProfileEpic should not process clicks when in draw mode', (done) => { + const NUM_ACTIONS = 0; + const point = { latlng: { lat: 44.0, lng: 5.0 } }; + const startActions = [{ type: CLICK_ON_MAP, point }]; + + testEpic(LPclickToProfileEpic, NUM_ACTIONS, startActions, actions => { + expect(actions.length).toBe(0); + done(); + }, { + longitudinalProfile: { + mode: 'draw' + }, + map: { + present: { + eventListeners: { + click: [CONTROL_NAME] + } + } + } + }); + }); + + it('LPclickToProfileEpic should process clicks when in select mode', (done) => { + const NUM_ACTIONS = 1; + const point = { latlng: { lat: 44.0, lng: 5.0 } }; + const startActions = [{ type: CLICK_ON_MAP, point }]; + + testEpic(LPclickToProfileEpic, NUM_ACTIONS, startActions, actions => { + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(SHOW_NOTIFICATION); + expect(actions[0].level).toBe('warning'); + done(); + }, { + longitudinalProfile: { + mode: 'select' + }, + map: { + present: { + eventListeners: { + click: [CONTROL_NAME] + } + } + } + }); + }); }); diff --git a/web/client/epics/longitudinalProfile.js b/web/client/epics/longitudinalProfile.js index 15ee4fbd77..7262382db2 100644 --- a/web/client/epics/longitudinalProfile.js +++ b/web/client/epics/longitudinalProfile.js @@ -416,7 +416,13 @@ export const LPdeactivateIdentifyEnabledEpic = (action$, store) => export const LPclickToProfileEpic = (action$, {getState}) => action$ .ofType(CLICK_ON_MAP) - .filter(() => isListeningClickSelector(getState())) + .filter(() => { + const state = getState(); + const isListeningClick = isListeningClickSelector(state); + const mode = dataSourceModeSelector(state); + // Only process clicks when in select mode to avoid triggering during drawing + return isListeningClick && mode === 'select'; + }) .switchMap(({point}) => { const state = getState(); const map = mapSelector(state); diff --git a/web/client/hooks/__tests__/useBatchedUpdates-test.js b/web/client/hooks/__tests__/useBatchedUpdates-test.js new file mode 100644 index 0000000000..73949c194c --- /dev/null +++ b/web/client/hooks/__tests__/useBatchedUpdates-test.js @@ -0,0 +1,159 @@ +/* + * Copyright 2025, 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 ReactDOM from 'react-dom'; +import expect from 'expect'; +import { act } from 'react-dom/test-utils'; +import useBatchedUpdates from '../useBatchedUpdates'; + +const TestComponent = ({ callback, reducer, delay, onMount }) => { + const [batchedUpdate, forceFlush] = useBatchedUpdates(callback, { reducer, delay }); + + React.useEffect(() => { + if (onMount) { + onMount({ batchedUpdate, forceFlush }); + } + }, []); + + return
; +}; + +describe('useBatchedUpdates hook', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should throw error when reducer is not provided', () => { + expect(() => { + act(() => { + ReactDOM.render( + {}} />, + document.getElementById("container") + ); + }); + }).toThrow('useBatchedUpdates: reducer function is required'); + }); + + it('should batch multiple updates into a single callback', (done) => { + const callback = expect.createSpy(); + const reducer = (accumulated, update) => ({ ...(accumulated || {}), ...update }); + + act(() => { + ReactDOM.render( + { + batchedUpdate({ key1: 'value1' }); + batchedUpdate({ key2: 'value2' }); + batchedUpdate({ key3: 'value3' }); + + expect(callback).toNotHaveBeenCalled(); + + setTimeout(() => { + expect(callback.calls.length).toBe(1); + expect(callback.calls[0].arguments[0]).toEqual({ + key1: 'value1', + key2: 'value2', + key3: 'value3' + }); + done(); + }, 10); + }} + />, + document.getElementById("container") + ); + }); + }); + + it('should force flush immediately', (done) => { + const callback = expect.createSpy(); + const reducer = (accumulated, update) => ({ ...(accumulated || {}), ...update }); + + act(() => { + ReactDOM.render( + { + batchedUpdate({ key1: 'value1' }); + batchedUpdate({ key2: 'value2' }); + + expect(callback).toNotHaveBeenCalled(); + + forceFlush(); + + expect(callback.calls.length).toBe(1); + expect(callback.calls[0].arguments[0]).toEqual({ + key1: 'value1', + key2: 'value2' + }); + done(); + }} + />, + document.getElementById("container") + ); + }); + }); + + it('should handle complex nested object accumulation', (done) => { + const callback = expect.createSpy(); + const reducer = (accumulated, type, id, options) => { + const current = accumulated || { layers: {}, groups: {} }; + return { + ...current, + [type]: { + ...current[type], + [id]: { + ...(current[type][id] || {}), + ...options + } + } + }; + }; + + act(() => { + ReactDOM.render( + { + batchedUpdate('layers', 'layer1', { visibility: false }); + batchedUpdate('layers', 'layer1', { opacity: 0.5 }); + batchedUpdate('layers', 'layer2', { visibility: true }); + batchedUpdate('groups', 'group1', { expanded: true }); + + setTimeout(() => { + expect(callback.calls.length).toBe(1); + expect(callback.calls[0].arguments[0]).toEqual({ + layers: { + layer1: { visibility: false, opacity: 0.5 }, + layer2: { visibility: true } + }, + groups: { + group1: { expanded: true } + } + }); + done(); + }, 10); + }} + />, + document.getElementById("container") + ); + }); + }); +}); diff --git a/web/client/hooks/useBatchedUpdates.js b/web/client/hooks/useBatchedUpdates.js new file mode 100644 index 0000000000..5081ced15c --- /dev/null +++ b/web/client/hooks/useBatchedUpdates.js @@ -0,0 +1,74 @@ +/* + * Copyright 2025, 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 { useRef, useCallback } from 'react'; + +/** + * Custom hook to batch multiple updates into a single callback execution. + * + * @param {function} callback - The function to call with batched updates + * @param {object} options - Configuration options + * @param {number} options.delay - Delay in milliseconds before flushing (default: 0) + * @param {function} options.reducer - Function to merge updates: (accumulated, ...args) => newAccumulated + * @returns {Array} [batchedUpdate, forceFlush] - Batched update function and manual flush + * + * @example + * const [batchedUpdate] = useBatchedUpdates( + * (result) => onChange(result), + * { reducer: (accumulated, update) => ({ ...accumulated, ...update }) } + * ); + */ +const useBatchedUpdates = (callback, { delay = 0, reducer } = {}) => { + const timeoutRef = useRef(null); + const accumulatedRef = useRef(null); + + if (!reducer) { + throw new Error('useBatchedUpdates: reducer function is required'); + } + + + // Flushes all accumulated updates by calling the callback + const flush = useCallback(() => { + if (accumulatedRef.current !== null) { + callback(accumulatedRef.current); + accumulatedRef.current = null; + } + }, [callback]); + + + // Batched update function that accumulates updates and schedules a flush + const batchedUpdate = useCallback((...args) => { + // Accumulate the update using the reducer + accumulatedRef.current = reducer(accumulatedRef.current, ...args); + + // Clear existing timeout and schedule new flush + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + flush(); + timeoutRef.current = null; + }, delay); + }, [reducer, delay, flush]); + + + // Force an immediate flush (useful for cleanup or manual flushing) + const forceFlush = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + flush(); + }, [flush]); + + return [batchedUpdate, forceFlush]; +}; + +export default useBatchedUpdates; + diff --git a/web/client/plugins/CRSSelector.jsx b/web/client/plugins/CRSSelector.jsx index e872801cc2..7730949788 100644 --- a/web/client/plugins/CRSSelector.jsx +++ b/web/client/plugins/CRSSelector.jsx @@ -104,7 +104,7 @@ class Selector extends React.Component { +
+ ); +}; + +describe('useIPRanges', () => { + let getIPRangesSpy; + + beforeEach((done) => { + document.body.innerHTML = '
'; + getIPRangesSpy = expect.spyOn(GeoStoreDAO, 'getIPRanges'); + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + if (getIPRangesSpy) { + getIPRangesSpy.restore(); + } + setTimeout(done); + }); + + it('should fetch IP ranges on first request call', (done) => { + const mockIPRanges = { + IPRangeList: { + IPRange: [ + { cidr: '192.168.1.0/24', description: 'Test Range 1' }, + { cidr: '10.0.0.0/8', description: 'Test Range 2' } + ] + } + }; + + getIPRangesSpy.andReturn(Promise.resolve(mockIPRanges)); + + act(() => { + ReactDOM.render( { + expect(result.ips.length).toBe(2); + expect(result.ips[0].cidr).toBe('192.168.1.0/24'); + expect(getIPRangesSpy.calls.length).toBe(1); + done(); + }} />, document.getElementById("container")); + }); + + Simulate.click(document.querySelector('#fetch')); + }); + + it('should filter IP ranges by search query', (done) => { + const mockIPRanges = { + IPRangeList: { + IPRange: [ + { cidr: '192.168.1.0/24', description: 'Office Network' }, + { cidr: '10.0.0.0/8', description: 'VPN Range' }, + { cidr: '172.16.0.0/12', description: 'Office Backup' } + ] + } + }; + + getIPRangesSpy.andReturn(Promise.resolve(mockIPRanges)); + + act(() => { + ReactDOM.render( { + expect(result.ips.length).toBe(2); + expect(result.ips[0].cidr).toBe('192.168.1.0/24'); + expect(result.ips[1].cidr).toBe('172.16.0.0/12'); + done(); + }} + />, document.getElementById("container")); + }); + + Simulate.click(document.querySelector('#fetch')); + }); + +}); diff --git a/web/client/plugins/ResourcesCatalog/hooks/useIPRanges.js b/web/client/plugins/ResourcesCatalog/hooks/useIPRanges.js new file mode 100644 index 0000000000..246a0081d4 --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/hooks/useIPRanges.js @@ -0,0 +1,129 @@ +/* + * Copyright 2025, 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 { useState, useRef, useCallback } from 'react'; +import GeoStoreDAO from '../../../api/GeoStoreDAO'; +import { castArray } from 'lodash'; + +/** + * Custom hook to manage IP ranges fetching and caching + * + * Provides: + * - request: Function for PermissionsAddEntriesPanel + * - isLoading: Loading state + * - error: Error state + * - refresh: Function to clear cache and refetch + * + * @returns {Object} Hook API + */ +const useIPRanges = () => { + const [allIPRanges, setAllIPRanges] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const ipRangesFetched = useRef(false); + const ipRangesFetchPromise = useRef(null); + + /** + * Fetches IP ranges from API (lazy fetch on first call) + * @returns {Promise} Array of IP ranges + */ + const fetchIPRanges = useCallback(() => { + if (ipRangesFetched.current) { + // Already fetched, return resolved promise with cached data + return Promise.resolve(allIPRanges); + } + + if (ipRangesFetchPromise.current) { + // Fetch already in progress, return the same promise + return ipRangesFetchPromise.current; + } + + // Start fetching + ipRangesFetched.current = true; + setIsLoading(true); + setError(null); + + ipRangesFetchPromise.current = GeoStoreDAO.getIPRanges() + .then((response) => { + const ipRanges = castArray(response?.IPRangeList?.IPRange || []); + setAllIPRanges(ipRanges); + setIsLoading(false); + return ipRanges; + }) + .catch((err) => { + console.error('Error fetching IP ranges:', err); + setError(err); + setIsLoading(false); + ipRangesFetched.current = false; // Reset on error to allow retry + ipRangesFetchPromise.current = null; + return []; + }) + .finally(() => { + ipRangesFetchPromise.current = null; + }); + + return ipRangesFetchPromise.current; + }, [allIPRanges]); + + /** + * Request function for PermissionsAddEntriesPanel + * Handles filtering, pagination, and formatting + */ + const request = useCallback(({ q, page: pageParam, pageSize }) => { + // Fetch IP ranges on first call (when IP tab is opened) + return fetchIPRanges().then((fetchedIPRanges) => { + const page = pageParam - 1; + let ipRanges = [...fetchedIPRanges]; + + // Client-side filtering + if (q) { + const lowerQ = q.toLowerCase(); + ipRanges = ipRanges.filter(ip => + ip.cidr?.toLowerCase().includes(lowerQ) || + ip.description?.toLowerCase().includes(lowerQ) + ); + } + + // Client-side pagination + const start = page * pageSize; + const end = start + pageSize; + const paginatedRanges = ipRanges.slice(start, end); + + // Return paginated results with formatted labels + return { + ips: paginatedRanges.map((ip) => ({ + ...ip, + filterValue: ip.cidr, + value: ip.cidr + })), + isNextPageAvailable: end < ipRanges.length + }; + }); + }, [fetchIPRanges]); + + /** + * Clears cache and re-fetches IP ranges + */ + const refresh = useCallback(() => { + ipRangesFetched.current = false; + ipRangesFetchPromise.current = null; + setAllIPRanges([]); + setError(null); + fetchIPRanges(); + }, [fetchIPRanges]); + + return { + request, + isLoading, + error, + refresh + }; +}; + +export default useIPRanges; + diff --git a/web/client/plugins/StreetView/components/CyclomediaView/Credentials.jsx b/web/client/plugins/StreetView/components/CyclomediaView/Credentials.jsx index c59137a17e..8b6cad23d5 100644 --- a/web/client/plugins/StreetView/components/CyclomediaView/Credentials.jsx +++ b/web/client/plugins/StreetView/components/CyclomediaView/Credentials.jsx @@ -1,6 +1,6 @@ import React, {useState} from 'react'; import Message from '../../../../components/I18N/Message'; -import { Form, Button, ControlLabel, FormControl, Glyphicon } from 'react-bootstrap'; +import { Form, Button, ControlLabel, FormControl, Glyphicon, Alert } from 'react-bootstrap'; import tooltip from '../../../../components/misc/enhancers/tooltip'; const ButtonT = tooltip(Button); /** @@ -11,9 +11,10 @@ const ButtonT = tooltip(Button); * @prop {object} credentials object with username and password * @prop {boolean} showCredentialsForm show form * @prop {function} setShowCredentialsForm function to set showCredentialsForm + * @prop {boolean} isCredentialsInvalid flag to indicate if credentials are invalid * @returns {JSX.Element} The rendered component */ -export default ({setCredentials = () => {}, credentials, showCredentialsForm, setShowCredentialsForm = () => {}}) => { +export default ({setCredentials = () => {}, credentials, showCredentialsForm, setShowCredentialsForm = () => {}, isCredentialsInvalid = false}) => { const [username, setUsername] = useState(credentials?.username || ''); const [password, setPassword] = useState(credentials?.password || ''); const onSubmit = () => { @@ -38,10 +39,15 @@ export default ({setCredentials = () => {}, credentials, showCredentialsForm, se setUsername(e.target.value)}/> setPassword(e.target.value)}/> + {isCredentialsInvalid && ( + + + + )}
{ - credentials?.username && credentials?.password && diff --git a/web/client/plugins/StreetView/components/CyclomediaView/CyclomediaView.js b/web/client/plugins/StreetView/components/CyclomediaView/CyclomediaView.js index 302676c459..cb15cd6f2e 100644 --- a/web/client/plugins/StreetView/components/CyclomediaView/CyclomediaView.js +++ b/web/client/plugins/StreetView/components/CyclomediaView/CyclomediaView.js @@ -215,6 +215,9 @@ const CyclomediaView = ({ apiKey, style, location = {}, setPov = () => {}, setLo setInitializing(false); setError(err); setReloadAllowed(true); + if (isInvalidCredentials(err) >= 0) { + setShowCredentialsForm(true); + } if (err) { console.error('Cyclomedia API: init: error: ' + err); } @@ -328,8 +331,11 @@ const CyclomediaView = ({ apiKey, style, location = {}, setPov = () => {}, setLo showCredentialsForm={showCredentialsForm} setShowCredentialsForm={setShowCredentialsForm} credentials={credentials} + isCredentialsInvalid={isInvalidCredentials(error) >= 0} setCredentials={(newCredentials) => { setCredentials(newCredentials); + setError(null); + setReload(prev => prev + 1); }}/>} {showLogout && initialized @@ -368,7 +374,7 @@ const CyclomediaView = ({ apiKey, style, location = {}, setPov = () => {}, setLo {getErrorMessage(error, {srs})}
- {initialized || reloadAllowed ?
); diff --git a/web/client/plugins/__tests__/Login-test.js b/web/client/plugins/__tests__/Login-test.js index 5e2c2b8cd5..45adc071dd 100644 --- a/web/client/plugins/__tests__/Login-test.js +++ b/web/client/plugins/__tests__/Login-test.js @@ -145,8 +145,8 @@ describe('Login Plugin', () => { ReactDOM.render(, document.getElementById("container")); expect(document.querySelector('#mapstore-login-menu .glyphicon-user')).toBeTruthy(); const entries = document.querySelectorAll("#mapstore-login-menu ~ ul li[role=\"presentation\"]"); - expect(entries.length).toEqual(6); - expect([...entries].map(entry => entry.innerText)).toEqual(['user.info', 'user.changePwd', 'users.title', 'usergroups.title', 'resourcesCatalog.manageTags', 'user.logout']); + expect(entries.length).toEqual(7); + expect([...entries].map(entry => entry.innerText)).toEqual(['user.info', 'user.changePwd', 'users.title', 'usergroups.title', 'resourcesCatalog.manageTags', 'resourcesCatalog.manageIPs', 'user.logout']); }); it('test show change password in case ms user ', () => { const storeState = stateMocker(toggleControl('LoginForm', 'enabled'), loginSuccess({ User: { name: "Test", access_token: "some-token", role: 'USER' }}) ); diff --git a/web/client/plugins/__tests__/RulesDataGrid-test.jsx b/web/client/plugins/__tests__/RulesDataGrid-test.jsx new file mode 100644 index 0000000000..7b34a89f52 --- /dev/null +++ b/web/client/plugins/__tests__/RulesDataGrid-test.jsx @@ -0,0 +1,92 @@ +/* + * Copyright 2025, 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 ReactDOM from 'react-dom'; +import expect from 'expect'; +import { act } from 'react-dom/test-utils'; + +import { getPluginForTest } from './pluginsTestUtils'; +import RulesDataGrid from '../RulesDataGrid'; + +import ConfigUtils from '../../utils/ConfigUtils'; + +describe('RulesDataGridPlugin', () => { + + beforeEach(() => { + document.body.innerHTML = '
'; + }); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(document.getElementById('container')); + document.body.innerHTML = ''; + ConfigUtils.removeConfigProp('geoFenceServiceType'); + + }); + + it('renders RulesDataGridPlugin with (non-standalone) geofence', () => { + ConfigUtils.setConfigProp('geoFenceServiceType', "geoserver"); + + const { Plugin } = getPluginForTest(RulesDataGrid, { + rulesmanager: { + activeGrid: 'rules' + } + }); + act(() => { + ReactDOM.render(, document.getElementById('container')); + }); + + // In non-standalone mode, only RulesGrid is shown + const container = document.querySelector('.rules-data-gird'); + expect(container).toExist(); + + // Should not render tabs when geofence is not standalone + const tabs = document.getElementById('rules-manager-tabs'); + expect(tabs).toNotExist(); + }); + + it('renders with standalone GeoFence and shows Tabs', async() => { + ConfigUtils.setConfigProp('geoFenceServiceType', "geofence"); + + const { Plugin } = getPluginForTest(RulesDataGrid, { + rulesmanager: { + activeGrid: 'rules' + } + }); + const comp = ReactDOM.render(, document.getElementById('container')); + await act(async() => comp); + // Tabs should exist now + const tabs = document.getElementById('rules-manager-tabs'); + expect(tabs).toExist(); + + // The div container should exist + const container = document.querySelector('.rules-data-gird'); + expect(container).toExist(); + // the columns names + const columnsElems = container.querySelectorAll('.react-grid-HeaderRow .react-grid-HeaderCell .widget-HeaderCell__value span'); + expect(columnsElems.length).toBe(10); + expect(columnsElems[0].innerHTML).toEqual('rulesmanager.gsInstance'); + }); + it('renders with standalone GeoFence and existing ipRange filter', async() => { + ConfigUtils.setConfigProp('geoFenceServiceType', "geofence"); + const { Plugin } = getPluginForTest(RulesDataGrid, { + rulesmanager: { + activeGrid: 'rules' + } + }); + const comp = ReactDOM.render(, document.getElementById('container')); + await act(async() => comp); + + // The div container should exist + const container = document.querySelector('.rules-data-gird'); + expect(container).toExist(); + // the columns names + const ipFilterElem = container.querySelectorAll('.react-grid-HeaderCell .autocompleteField .input-clearable input')[0]; + expect(ipFilterElem).toBeTruthy(); + expect(ipFilterElem.placeholder).toEqual('rulesmanager.placeholders.ipRange'); + }); +}); diff --git a/web/client/plugins/manager/EditorEnhancer.js b/web/client/plugins/manager/EditorEnhancer.js index f886b8a005..6dc0962903 100644 --- a/web/client/plugins/manager/EditorEnhancer.js +++ b/web/client/plugins/manager/EditorEnhancer.js @@ -33,7 +33,8 @@ const dataStreamFactory = prop$ => { const geoFenceType = ConfigUtils.getDefaults().geoFenceServiceType; // in case non stand-alone geofence if (geoFenceType !== "geofence") { - return getStylesAndAttributes(layer, workspace).do(opts => optionsLoaded(opts)) + const {url} = ConfigUtils.getDefaults().geoFenceGeoServerInstance || {}; + return getStylesAndAttributes(layer, workspace, url).do(opts => optionsLoaded(opts)) .catch(() => { setLoading(false); onError({ diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index a48a29f334..69e4b2fd1e 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -78,6 +78,7 @@ export const plugins = { DashboardImport: toModulePlugin('DashboardImport', () => import( /* webpackChunkName: 'plugins/dashboardImport' */'../plugins/DashboardImport')), DetailsPlugin: toModulePlugin('Details', () => import(/* webpackChunkName: 'plugins/details' */ '../plugins/Details')), DrawerMenuPlugin: toModulePlugin('DrawerMenu', () => import(/* webpackChunkName: 'plugins/drawerMenu' */ '../plugins/DrawerMenu')), + DynamicLegendPlugin: toModulePlugin('DynamicLegend', () => import(/* webpackChunkName: 'plugins/DynamicLegend' */ '../plugins/DynamicLegend')), ExpanderPlugin: toModulePlugin('Expander', () => import(/* webpackChunkName: 'plugins/expander' */ '../plugins/Expander')), FilterLayerPlugin: toModulePlugin('FilterLayer', () => import(/* webpackChunkName: 'plugins/filterLayer' */ '../plugins/FilterLayer')), FullScreenPlugin: toModulePlugin('FullScreen', () => import(/* webpackChunkName: 'plugins/fullScreen' */ '../plugins/FullScreen')), @@ -143,7 +144,8 @@ export const plugins = { ZoomInPlugin: toModulePlugin('ZoomIn', () => import(/* webpackChunkName: 'plugins/zoomIn' */ '../plugins/ZoomIn')), ZoomOutPlugin: toModulePlugin('ZoomOut', () => import(/* webpackChunkName: 'plugins/zoomOut' */ '../plugins/ZoomOut')), AddWidgetDashboardPlugin: toModulePlugin('AddWidgetDashboard', () => import(/* webpackChunkName: 'plugins/AddWidgetDashboard' */ '../plugins/AddWidgetDashboard')), - MapConnectionDashboardPlugin: toModulePlugin('MapConnectionDashboard', () => import(/* webpackChunkName: 'plugins/MapConnectionDashboard' */ '../plugins/MapConnectionDashboard')) + MapConnectionDashboardPlugin: toModulePlugin('MapConnectionDashboard', () => import(/* webpackChunkName: 'plugins/MapConnectionDashboard' */ '../plugins/MapConnectionDashboard')), + IPManagerPlugin: toModulePlugin('IPManager', () => import(/* webpackChunkName: 'plugins/IPManager' */ '../plugins/ResourcesCatalog/IPManager')) }; const pluginsDefinition = { diff --git a/web/client/reducers/__tests__/maptemplates-test.js b/web/client/reducers/__tests__/maptemplates-test.js new file mode 100644 index 0000000000..1633e88d31 --- /dev/null +++ b/web/client/reducers/__tests__/maptemplates-test.js @@ -0,0 +1,36 @@ +/* + * Copyright 2025, 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 expect from 'expect'; +import maptemplates from '../maptemplates'; +import { configureMap } from '../../actions/config'; + +describe('mapTemplates reducer', () => { + it('Do not replace templates if "configureMap" action has no mapTemplates as config', () => { + const initialState = { + templates: [{ id: 'template-1', name: 'Template 1' }] + }; + const state = maptemplates(initialState, configureMap({ })); + expect(state.templates).toEqual(initialState.templates); + }); + it('Do not replace templates if "configureMap" action has empty array of mapTemplates as config', () => { + const initialState = { + templates: [{ id: 'template-1', name: 'Template 1' }] + }; + const state = maptemplates(initialState, configureMap({ })); + expect(state.templates).toEqual(initialState.templates); + }); + it('Replace templates when "configureMap" action has provided some mapTemplates as config', () => { + const initialState = { + templates: [{ id: 'template-1' }] + }; + const toUpdateTemplates = [{id: 'updated-template'}]; + const state = maptemplates(initialState, configureMap({ mapTemplates: toUpdateTemplates })); + expect(state.templates).toBe(toUpdateTemplates); + }); +}); diff --git a/web/client/reducers/maptemplates.js b/web/client/reducers/maptemplates.js index 37261207f3..3b0fc2049c 100644 --- a/web/client/reducers/maptemplates.js +++ b/web/client/reducers/maptemplates.js @@ -18,7 +18,22 @@ export default (state = {}, action) => { return {}; } case MAP_CONFIG_LOADED: { - return set('templates', action.config?.mapTemplates, state) ?? []; + // NOTE: This is a dynamic reducer plugin that only functions when the map template plugin is active. + // It does not run during the initial LOAD_MAP_CONFIG phase. + // + // This reducer is triggered in two specific scenarios: + // CASE 1: When the session is cleared to restore the original templates. + // CASE 2: When map templates are replaced — in this case, mapTemplates is not provided, + // so the current list should be preserved instead of being overwritten. + // Case 3: When adding templates, comes well prepared map templates(overridden - original + from user session ) + // Clarification: + // The initial setup of map templates is handled by the `setSessionToDynamicReducers` + // function in the userSession epic. The MAP_CONFIG_LOADED action does *not* initialize map templates on load. + const mapTemplates = action.config?.mapTemplates; + if (mapTemplates === undefined || (Array.isArray(mapTemplates) && mapTemplates.length === 0)) { + return state; + } + return set('templates', mapTemplates, state); } case SET_TEMPLATES: { return set('templates', action.templates, state); diff --git a/web/client/selectors/mapviews.js b/web/client/selectors/mapviews.js index fd2c5b68e7..16a3062fd6 100644 --- a/web/client/selectors/mapviews.js +++ b/web/client/selectors/mapviews.js @@ -6,8 +6,8 @@ * LICENSE file in the root directory of this source tree. */ -import { DEFAULT_GROUP_ID } from "../utils/LayersUtils"; -import { layersSelector } from "./layers"; +import { flattenArrayOfObjects } from "../utils/LayersUtils"; +import { layersSelector, rawGroupsSelector } from "./layers"; export const isMapViewsActive = state => !!state?.mapviews?.active; export const isMapViewsHidden = state => !!state?.mapviews?.hide; @@ -15,7 +15,10 @@ export const getSelectedMapViewId = state => !isMapViewsHidden(state) && isMapVi export const getMapViews = state => { const layers = layersSelector(state); const layersIds = layers.map(layer => layer.id); - const groupsIds = layers.map(layer => layer.group || DEFAULT_GROUP_ID); + const groups = rawGroupsSelector(state); + const groupsIds = flattenArrayOfObjects(groups) + .filter(g => g && g.id) + .map(g => g.id); return state?.mapviews?.views ? state.mapviews.views.map(view => { const viewLayers = view?.layers; const viewGroups = view?.groups; diff --git a/web/client/themes/default/less/map-footer.less b/web/client/themes/default/less/map-footer.less index 97698efca2..1fefd74028 100644 --- a/web/client/themes/default/less/map-footer.less +++ b/web/client/themes/default/less/map-footer.less @@ -77,7 +77,10 @@ .ms-prj-selector { position: relative; z-index: 1100; + .dropdown-toggle-primary { + width: 25px; + height: 25px; box-shadow: none; border-width: 1px; border: transparent; diff --git a/web/client/themes/default/less/mouse-position.less b/web/client/themes/default/less/mouse-position.less index bbb7b4bfbf..de0e7075ed 100644 --- a/web/client/themes/default/less/mouse-position.less +++ b/web/client/themes/default/less/mouse-position.less @@ -113,10 +113,8 @@ button { float: left; height: 25px; - width: 25px !important; + width: 25px; padding: 0; - margin-left: 5px; - margin-right: 5px; } .form-group { @@ -158,4 +156,4 @@ #mapstore-map-footer #mapstore-mouseposition { position: static !important; -} \ No newline at end of file +} diff --git a/web/client/themes/default/less/resources-catalog/_permissions.less b/web/client/themes/default/less/resources-catalog/_permissions.less index 9ccf8ccd37..c53afc7cb4 100644 --- a/web/client/themes/default/less/resources-catalog/_permissions.less +++ b/web/client/themes/default/less/resources-catalog/_permissions.less @@ -20,4 +20,9 @@ width: 100%; } } + .ms-permission-description { + font-size: 11px; + opacity: 0.6; + word-break: break-word; + } } \ No newline at end of file diff --git a/web/client/translations/data.ca-ES.json b/web/client/translations/data.ca-ES.json index e3f1eb7591..d7554f013e 100644 --- a/web/client/translations/data.ca-ES.json +++ b/web/client/translations/data.ca-ES.json @@ -171,7 +171,9 @@ "eyeDomeLighting": "Activa la il\u00b7luminaci\u00f3", "eyeDomeLightingStrength": "For\u00e7a d\u2019il\u00b7luminaci\u00f3", "eyeDomeLightingRadius": "Radi d\u2019il\u00b7luminaci\u00f3" - } + }, + "enableImageryOverlay": "Habilita la superposició de capes d’imatges", + "enableImageryOverlayInfo": "Nota: Habilita les capes d’imatges com WMS, TMS o WMTS perquè es representin sobre la capa de 3D Tiles, mostrades seqüencialment segons el seu ordre definit." }, "modelLayer": { "modelCenterLat": "Latitude Center (DD)", diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 1076f9f5cf..fecd21be2b 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -181,7 +181,9 @@ "eyeDomeLighting": "Beleuchtung aktivieren", "eyeDomeLightingStrength": "Lichtstärke", "eyeDomeLightingRadius": "Beleuchtungsradius" - } + }, + "enableImageryOverlay": "Überlagerung von Bildebenen aktivieren", + "enableImageryOverlayInfo": "Hinweis: Aktiviert Bildebenen wie WMS, TMS oder WMTS, die über der 3D-Tiles-Ebene gerendert und in der festgelegten Reihenfolge angezeigt werden." }, "modelLayer": { "modelCenterLat": "Breitengrad des Zentrums (DD)", @@ -458,7 +460,8 @@ "featuredMaps": "Empfohlene Karten", "groupmanagerTab": "Gruppen", "usermanagerTab": "Benutzer", - "tagsmanagerTab": "Schlagwörter" + "tagsmanagerTab": "Schlagwörter", + "ipmanagerTab": "IP-Bereiche" }, "newMap": "Neue Karte", "newMapEmpty": "Leere Karte", @@ -1545,6 +1548,11 @@ "height": "Höhe" } }, + "dynamiclegend": { + "title": "Legende", + "tooltip": "Kartenlegende anzeigen", + "emptyLegend": "Keine legende zum Anzeigen" + }, "permalink": { "title": "Permalink", "shareLinkTitle": "Permalink generiert", @@ -3536,6 +3544,10 @@ "description": "Ermöglicht das Teilen der Karte auf verschiedene Arten (Link, QR-Code, Einbettung, soziale Netzwerke ...)", "title": "Freigabe-Werkzeug" }, + "DynamicLegend": { + "title": "Legende", + "description": "Kartenlegende anzeigen" + }, "Permalink": { "description": "Ermöglicht das Erstellen eines Permalinks der aktuell angezeigten Ressource", "title": "Permalink" @@ -4452,6 +4464,10 @@ "noContentYetTitle": "Keine Gruppen vorhanden", "noContentYetContent": "Es sind keine Benutzergruppen vorhanden. Klicken Sie auf \"Neue Gruppe\", um eine zu erstellen" }, + "ipmanagerSection": { + "noContentYetTitle": "Noch keine IP-Bereiche registriert", + "noContentYetContent": " " + }, "mapsFilter": "Karten", "dashboardsFilter": "Dashboards", "geostoriesFilter": "Geostories", @@ -4478,6 +4494,7 @@ "editPermission": "Bearbeiten", "ownerPermission": "Eigentümer", "groups": "Gruppen", + "ip": "IP-Bereiche", "filterBy": "Filtern...", "about": "Über", "readMore": "Mehr lesen", @@ -4527,7 +4544,7 @@ "errorDefault": "Netzwerkfehler" }, "resourceIssues": { - "dependencyMissing": "Diese Ressource kann nicht geöffnet werden. Fehlende Berechtigungen für eine zugehörige Ressource." + "dependencyMissing": "Fehlende Berechtigungen für eine zugehörige Ressource." }, "deleteError": { "error403": "Sie dürfen die Ressource nicht löschen", @@ -4564,6 +4581,7 @@ "tagDescription": "Beschreibung", "tagColor": "Farbe", "manageTags": "Tags Verwalten", + "manageIPs": "IP-Bereiche verwalten", "errorLoadingTags": "Es ist nicht möglich, Tags zu laden", "errorUpdatingTag": "Es ist nicht möglich, das Tag zu aktualisieren", "errorTagNameAlreadyExist": "Das Tag, das Sie erstellen möchten, existiert bereits", @@ -4575,6 +4593,42 @@ "filterApplied": "Filter angewendet", "emptyFilterItems": "Keine Elemente zum Anzeigen" }, + "ipManager": { + "newIP": "Neuer IP-Bereich", + "editTitle": "IP-Bereich bearbeiten", + "editTooltip": "IP-Bereich bearbeiten", + "deleteTitle": "IP-Bereich löschen", + "deleteTooltip": "IP-Bereich löschen", + "deleteButton": "Löschen", + "deleteConfirm": "Möchten Sie diesen IP-Bereich wirklich löschen?", + "ipAddress": "IP-Bereich (CIDR-Format)", + "description": "Beschreibung", + "search": "IP-Bereiche durchsuchen...", + "save": "Speichern", + "cancel": "Abbrechen", + "ipsFound": "{count, plural, =0 {0 IP-Bereiche gefunden} =1 {1 IP-Bereich gefunden} other {# IP-Bereiche gefunden}}", + "validation": { + "ipRequired": "IP-Bereich ist erforderlich", + "cidrRequired": "CIDR-Notation erforderlich (z.B. 192.168.1.1/32 oder 192.168.1.0/24)", + "invalidMask": "Subnetzmaske muss zwischen 0 und 32 liegen", + "invalidFormat": "Ungültiges IP-Bereichsformat", + "invalidOctet": "Jedes Oktett muss zwischen 0 und 255 liegen" + }, + "notification": { + "createSuccessTitle": "Erfolg", + "createSuccessMessage": "IP-Bereich erfolgreich erstellt", + "createErrorTitle": "Fehler", + "createErrorMessage": "IP-Bereich konnte nicht erstellt werden", + "updateSuccessTitle": "Erfolg", + "updateSuccessMessage": "IP-Bereich erfolgreich aktualisiert", + "updateErrorTitle": "Fehler", + "updateErrorMessage": "IP-Bereich konnte nicht aktualisiert werden", + "deleteSuccessTitle": "Erfolg", + "deleteSuccessMessage": "IP-Bereich erfolgreich gelöscht", + "deleteErrorTitle": "Fehler", + "deleteErrorMessage": "IP-Bereich konnte nicht gelöscht werden" + } + }, "itinerary": { "title": "Reiseroute", "tooltip": "Reiseroute", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 2a345442eb..05e20be13e 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -181,7 +181,9 @@ "eyeDomeLighting": "Enable lighting", "eyeDomeLightingStrength": "Lighting strength", "eyeDomeLightingRadius": "Lighting radius" - } + }, + "enableImageryOverlay": "Enable imagery layers overlay", + "enableImageryOverlayInfo": "Note: Enables imagery layers such as WMS, TMS, or WMTS to be rendered on top of the 3D Tiles layer, rendered sequentially in their defined order." }, "modelLayer": { "modelCenterLat": "Center's Latitude (DD)", @@ -420,7 +422,8 @@ "featuredMaps": "Featured", "groupmanagerTab": "Groups", "usermanagerTab": "Users", - "tagsmanagerTab": "Tags" + "tagsmanagerTab": "Tags", + "ipmanagerTab": "IP Ranges" }, "newMap": "New map", "newMapEmpty": "Empty map", @@ -1506,6 +1509,11 @@ "height": "height" } }, + "dynamiclegend": { + "title": "Legend", + "tooltip": "Display the map legend", + "emptyLegend": "No legend to display" + }, "permalink": { "title": "Permalink", "shareLinkTitle": "Permalink generated", @@ -3507,6 +3515,10 @@ "description": "Allows to share the map in various ways (link, QR-Code, embed, social networks...)", "title": "Share Tool" }, + "DynamicLegend": { + "title": "Legend", + "description": "Display the map legend" + }, "Permalink": { "description": "Allows to create a permalink of the current resource in view", "title": "Permalink" @@ -4419,6 +4431,10 @@ "noPublicContentTitle": "Featured Resources", "noPublicContentContent": "This catalog doesn't have featured resources." }, + "ipmanagerSection": { + "noContentYetTitle": "No IP ranges registered yet", + "noContentYetContent": " " + }, "groupsSection": { "noContentYetTitle": "No user groups", "noContentYetContent": "There are no user groups. Click on \"New Group\" to create one" @@ -4449,6 +4465,7 @@ "editPermission": "Edit", "ownerPermission": "Owner", "groups": "Groups", + "ip": "IP Ranges", "filterBy": "Filter...", "about": "About", "readMore": "Read more", @@ -4498,7 +4515,7 @@ "errorDefault": "Network error" }, "resourceIssues": { - "dependencyMissing": "This resource can't be open. Missing permissions on an associated resource." + "dependencyMissing": "Missing permissions on an associated resource." }, "deleteError": { "error403": "You are not allowed to delete the resource", @@ -4535,6 +4552,7 @@ "tagDescription": "Description", "tagColor": "Color", "manageTags": "Manage Tags", + "manageIPs": "Manage IP Ranges", "errorLoadingTags": "It is not possible to load tags", "errorUpdatingTag": "It is not possible to update the tag", "errorTagNameAlreadyExist": "The tag you are trying to create already exists", @@ -4546,6 +4564,42 @@ "filterApplied": "Filter applied", "emptyFilterItems": "No items to display" }, + "ipManager": { + "newIP": "New IP range", + "editTitle": "Edit IP range", + "editTooltip": "Edit IP range", + "deleteTitle": "Delete IP range", + "deleteTooltip": "Delete IP range", + "deleteButton": "Delete", + "deleteConfirm": "Are you sure you want to delete this IP range?", + "ipAddress": "IP range (CIDR format)", + "description": "Description", + "search": "Search IP ranges...", + "save": "Save", + "cancel": "Cancel", + "ipsFound": "{count, plural, =0 {0 IP ranges found} =1 {1 IP range found} other {# IP ranges found}}", + "validation": { + "ipRequired": "IP range is required", + "cidrRequired": "CIDR notation required (e.g., 192.168.1.1/32 or 192.168.1.0/24)", + "invalidMask": "Subnet mask must be between 0 and 32", + "invalidFormat": "Invalid IP range format", + "invalidOctet": "Each octet must be between 0 and 255" + }, + "notification": { + "createSuccessTitle": "Success", + "createSuccessMessage": "IP range created successfully", + "createErrorTitle": "Error", + "createErrorMessage": "Failed to create IP range", + "updateSuccessTitle": "Success", + "updateSuccessMessage": "IP range updated successfully", + "updateErrorTitle": "Error", + "updateErrorMessage": "Failed to update IP range", + "deleteSuccessTitle": "Success", + "deleteSuccessMessage": "IP range deleted successfully", + "deleteErrorTitle": "Error", + "deleteErrorMessage": "Failed to delete IP range" + } + }, "itinerary": { "title": "Itinerary", "tooltip": "Itinerary", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index 8e01e7f3ce..21d5876630 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -178,7 +178,9 @@ "eyeDomeLighting": "Habilitar iluminación", "eyeDomeLightingStrength": "Intensidad de iluminación", "eyeDomeLightingRadius": "Radio de iluminación" - } + }, + "enableImageryOverlay": "Habilitar la superposición de capas de imágenes", + "enableImageryOverlayInfo": "Nota: Habilita las capas de imágenes como WMS, TMS o WMTS para que se representen sobre la capa de 3D Tiles, mostradas secuencialmente según su orden definido." }, "modelLayer": { "modelCenterLat": "Latitud del centro (DD)", @@ -420,7 +422,8 @@ "featuredMaps": "Mapas Destacados", "groupmanagerTab": "Grupos", "usermanagerTab": "Usuarios", - "tagsmanagerTab": "Etiquetas" + "tagsmanagerTab": "Etiquetas", + "ipmanagerTab": "Rangos de IP" }, "newMap": "Nuevo mapa", "newMapEmpty": "Mapa vacío", @@ -1506,6 +1509,11 @@ "height": "altura" } }, + "dynamiclegend": { + "title": "Leyenda", + "tooltip": "Mostrar la leyenda del mapa", + "emptyLegend": "Ninguna leyenda para mostrar" + }, "permalink": { "title": "Permalink", "shareLinkTitle": "Enlace permanente generado", @@ -3497,6 +3505,10 @@ "description": "Permite compartir el mapa de varias maneras (enlace, código QR, incrustar, redes sociales ...)", "title": "Compartir herramienta" }, + "DynamicLegend": { + "title": "Leyenda", + "description": "Mostrar la leyenda del mapa" + }, "Permalink": { "description": "Permite crear un enlace permanente del recurso actual a la vista", "title": "Enlace permanente" @@ -4413,6 +4425,10 @@ "noContentYetTitle": "No hay grupos", "noContentYetContent": "No hay grupos de usuarios. Haz clic en \"Nuevo grupo\" para crear uno" }, + "ipmanagerSection": { + "noContentYetTitle": "Aún no hay rangos de IP registrados", + "noContentYetContent": " " + }, "mapsFilter": "Mapas", "dashboardsFilter": "Paneles de control", "geostoriesFilter": "Geostories", @@ -4439,6 +4455,7 @@ "editPermission": "Editar", "ownerPermission": "Propietario", "groups": "Grupos", + "ip": "Rangos de IP", "filterBy": "Filtrar...", "about": "Acerca de", "readMore": "Leer más", @@ -4488,7 +4505,7 @@ "errorDefault": "Error de red" }, "resourceIssues": { - "dependencyMissing": "Este recurso no se puede abrir. Faltan permisos en un recurso asociado." + "dependencyMissing": "Faltan permisos en un recurso asociado." }, "deleteError": { "error403": "No tiene permiso para eliminar el recurso", @@ -4525,6 +4542,7 @@ "tagDescription": "Descripción", "tagColor": "Color", "manageTags": "Administrar Etiquetas", + "manageIPs": "Administrar Rangos de IP", "errorLoadingTags": "No es posible cargar etiquetas", "errorUpdatingTag": "No es posible actualizar la etiqueta", "errorTagNameAlreadyExist": "La etiqueta que estás intentando crear ya existe", @@ -4536,6 +4554,42 @@ "filterApplied": "Filtro aplicado", "emptyFilterItems": "No hay elementos para mostrar" }, + "ipManager": { + "newIP": "Nuevo rango de IP", + "editTitle": "Editar rango de IP", + "editTooltip": "Editar rango de IP", + "deleteTitle": "Eliminar rango de IP", + "deleteTooltip": "Eliminar rango de IP", + "deleteButton": "Eliminar", + "deleteConfirm": "¿Está seguro de que desea eliminar este rango de IP?", + "ipAddress": "Rango de IP (formato CIDR)", + "description": "Descripción", + "search": "Buscar rangos de IP...", + "save": "Guardar", + "cancel": "Cancelar", + "ipsFound": "{count, plural, =0 {0 rangos de IP encontrados} =1 {1 rango de IP encontrado} other {# rangos de IP encontrados}}", + "validation": { + "ipRequired": "El rango de IP es obligatorio", + "cidrRequired": "Se requiere notación CIDR (ej., 192.168.1.1/32 o 192.168.1.0/24)", + "invalidMask": "La máscara de subred debe estar entre 0 y 32", + "invalidFormat": "Formato de rango de IP inválido", + "invalidOctet": "Cada octeto debe estar entre 0 y 255" + }, + "notification": { + "createSuccessTitle": "Éxito", + "createSuccessMessage": "Rango de IP creado correctamente", + "createErrorTitle": "Error", + "createErrorMessage": "Error al crear el rango de IP", + "updateSuccessTitle": "Éxito", + "updateSuccessMessage": "Rango de IP actualizado correctamente", + "updateErrorTitle": "Error", + "updateErrorMessage": "Error al actualizar el rango de IP", + "deleteSuccessTitle": "Éxito", + "deleteSuccessMessage": "Rango de IP eliminado correctamente", + "deleteErrorTitle": "Error", + "deleteErrorMessage": "Error al eliminar el rango de IP" + } + }, "itinerary": { "title": "Itinerario", "tooltip": "Itinerario", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 024b59a9ba..1f5ea96006 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -181,7 +181,9 @@ "eyeDomeLighting": "Activer l'éclairage", "eyeDomeLightingStrength": "Force d'éclairage", "eyeDomeLightingRadius": "Rayon d'éclairage" - } + }, + "enableImageryOverlay": "Activer la superposition des couches d’images", + "enableImageryOverlayInfo": "Remarque : Active les couches d’images telles que WMS, TMS ou WMTS pour qu’elles soient rendues au-dessus de la couche 3D Tiles et affichées séquentiellement selon leur ordre défini." }, "modelLayer": { "modelCenterLat": "Latitude du centre (DD)", @@ -420,7 +422,8 @@ "featuredMaps": "Cartes à la une", "groupmanagerTab": "Groupes", "usermanagerTab": "Utilisateurs", - "tagsmanagerTab": "Balises" + "tagsmanagerTab": "Balises", + "ipmanagerTab": "Plages IP" }, "newMap": "Nouvelle carte", "newMapEmpty": "Carte vide", @@ -1507,6 +1510,11 @@ "height": "la taille" } }, + "dynamiclegend": { + "title": "Légende", + "tooltip": "Affiche la légende de la carte", + "emptyLegend": "Aucune légende à afficher" + }, "permalink": { "title": "Permalink", "shareLinkTitle": "Permalien généré", @@ -3498,6 +3506,10 @@ "description": "Permet de partager la carte de diverses manières (lien, QR-Code, embarqué, réseaux sociaux ...)", "title": "Outil de partage" }, + "DynamicLegend": { + "title": "Légende", + "description": "Affiche la légende de la carte" + }, "Permalink": { "description": "Permet de créer un permalien de la ressource courante en vue", "title": "Lien permanent" @@ -4414,6 +4426,10 @@ "noContentYetTitle": "Aucun groupe trouvé", "noContentYetContent": "Il n'y a pas de groupes d'utilisateurs. Cliquez sur « Nouveau groupe » pour en créer un." }, + "ipmanagerSection": { + "noContentYetTitle": "Aucune plage IP enregistrée pour le moment", + "noContentYetContent": " " + }, "mapsFilter": "Cartes", "dashboardsFilter": "Tableaux de bord", "geostoriesFilter": "Géostories", @@ -4440,6 +4456,7 @@ "editPermission": "Modifier", "ownerPermission": "Propriétaire", "groups": "Groupes", + "ip": "Plages IP", "filterBy": "Filtrer...", "about": "À propos", "readMore": "Lire la suite", @@ -4489,7 +4506,7 @@ "errorDefault": "Erreur réseau" }, "resourceIssues": { - "dependencyMissing": "Cette ressource ne peut pas être ouverte. Autorisations manquantes sur une ressource associée." + "dependencyMissing": "Autorisations manquantes sur une ressource associée." }, "deleteError": { "error403": "Vous n'êtes pas autorisé à supprimer la ressource", @@ -4526,6 +4543,7 @@ "tagDescription": "Description", "tagColor": "Couleur", "manageTags": "Gérer les Tags", + "manageIPs": "Gérer les Plages IP", "errorLoadingTags": "Il n'est pas possible de charger des balises", "errorUpdatingTag": "Il n'est pas possible de mettre à jour la balise", "errorTagNameAlreadyExist": "La balise que vous essayez de créer existe déjà", @@ -4537,6 +4555,42 @@ "filterApplied": "Filtre appliqué", "emptyFilterItems": "Aucun élément à afficher" }, + "ipManager": { + "newIP": "Nouvelle Plage IP", + "editTitle": "Modifier la plage IP", + "editTooltip": "Modifier la plage IP", + "deleteTitle": "Supprimer la plage IP", + "deleteTooltip": "Supprimer la plage IP", + "deleteButton": "Supprimer", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette plage IP ?", + "ipAddress": "Plage IP (format CIDR)", + "description": "Description", + "search": "Rechercher des plages IP...", + "save": "Enregistrer", + "cancel": "Annuler", + "ipsFound": "{count, plural, =0 {0 plage IP trouvée} =1 {1 plage IP trouvée} other {# plages IP trouvées}}", + "validation": { + "ipRequired": "La plage IP est obligatoire", + "cidrRequired": "Notation CIDR requise (ex., 192.168.1.1/32 ou 192.168.1.0/24)", + "invalidMask": "Le masque de sous-réseau doit être entre 0 et 32", + "invalidFormat": "Format de plage IP invalide", + "invalidOctet": "Chaque octet doit être entre 0 et 255" + }, + "notification": { + "createSuccessTitle": "Succès", + "createSuccessMessage": "Plage IP créée avec succès", + "createErrorTitle": "Erreur", + "createErrorMessage": "Échec de la création de la plage IP", + "updateSuccessTitle": "Succès", + "updateSuccessMessage": "Plage IP mise à jour avec succès", + "updateErrorTitle": "Erreur", + "updateErrorMessage": "Échec de la mise à jour de la plage IP", + "deleteSuccessTitle": "Succès", + "deleteSuccessMessage": "Plage IP supprimée avec succès", + "deleteErrorTitle": "Erreur", + "deleteErrorMessage": "Échec de la suppression de la plage IP" + } + }, "itinerary": { "title": "Itinéraire", "tooltip": "Itinéraire", @@ -4589,7 +4643,7 @@ "direction": "Direction", "reset": "Réinitialiser", "run": "Exécuter", - "buckets": "Seaux", + "buckets": "Sauts", "exportAsGeoJSON": "Exporter en GeoJSON", "addAsLayer": "Ajouter comme couche", "deleteResult": "Supprimer le résultat", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 7449ed4f90..9e1d9b38ee 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -181,7 +181,9 @@ "eyeDomeLighting": "Abilita illuminazione", "eyeDomeLightingStrength": "Intensità luminosa", "eyeDomeLightingRadius": "Raggio di illuminazione" - } + }, + "enableImageryOverlay": "Abilita la sovrapposizione dei livelli di immagini", + "enableImageryOverlayInfo": "Nota: Abilita i livelli di immagini come WMS, TMS o WMTS per essere renderizzati sopra il livello 3D Tiles, visualizzati in sequenza secondo l’ordine definito." }, "modelLayer": { "modelCenterLat": "Latitudine del centro (DD)", @@ -420,7 +422,8 @@ "featuredMaps": "In Evidenza", "groupmanagerTab": "Gruppi", "usermanagerTab": "Utenti", - "tagsmanagerTab": "Tags" + "tagsmanagerTab": "Tags", + "ipmanagerTab": "Intervalli IP" }, "newMap": "Nuova Mappa", "newMapEmpty": "Mappa vuota", @@ -1506,6 +1509,11 @@ "height": "altezza" } }, + "dynamiclegend": { + "title": "Legenda", + "tooltip": "Visualizza la legenda della mappa", + "emptyLegend": "Nessuna legenda da visualizzare" + }, "permalink": { "title": "Permalink", "shareLinkTitle": "Permalink generato", @@ -3499,6 +3507,10 @@ "description": "Permette di condividere la mappa in diversi modi (link, QR-Code, embed, social network ...)", "title": "Strumento di condivisione" }, + "DynamicLegend": { + "title": "Legenda", + "description": "Visualizza la legenda della mappa" + }, "Permalink": { "description": "Permette di creare un permalink della risorsa attualmente in vista", "title": "Permalink" @@ -4415,6 +4427,10 @@ "noContentYetTitle": "Non ci sono gruppi", "noContentYetContent": "Non ci sono gruppi utenti. Clicca su \"Nuovo Gruppo\" per crearne uno" }, + "ipmanagerSection": { + "noContentYetTitle": "Nessun intervallo IP registrato ancora", + "noContentYetContent": " " + }, "mapsFilter": "Mappe", "dashboardsFilter": "Dashboard", "geostoriesFilter": "Geostories", @@ -4441,6 +4457,7 @@ "editPermission": "Modifica", "ownerPermission": "Proprietario", "groups": "Gruppi", + "ip": "Intervalli IP", "filterBy": "Filtra...", "about": "Informazioni", "readMore": "Leggi di più", @@ -4490,7 +4507,7 @@ "errorDefault": "Errore di rete" }, "resourceIssues": { - "dependencyMissing": "Questa risorsa non può essere aperta. Autorizzazioni mancanti su una risorsa associata." + "dependencyMissing": "Autorizzazioni mancanti su una risorsa associata." }, "deleteError": { "error403": "Non sei autorizzato a eliminare la risorsa", @@ -4527,6 +4544,7 @@ "tagDescription": "Descrizione", "tagColor": "Colore", "manageTags": "Gestisci Tag", + "manageIPs": "Gestisci Intervalli IP", "errorLoadingTags": "Impossibile caricare i tag", "errorUpdatingTag": "Impossibile aggiornare il tag", "errorTagNameAlreadyExist": "Il tag che stai tentando di creare esiste già", @@ -4538,6 +4556,42 @@ "filterApplied": "Filtro applicato", "emptyFilterItems": "Nessun elemento da visualizzare" }, + "ipManager": { + "newIP": "Nuovo intervallo IP", + "editTitle": "Modifica intervallo IP", + "editTooltip": "Modifica intervallo IP", + "deleteTitle": "Elimina intervallo IP", + "deleteTooltip": "Elimina intervallo IP", + "deleteButton": "Elimina", + "deleteConfirm": "Sei sicuro di voler eliminare questo intervallo IP?", + "ipAddress": "Intervallo IP (formato CIDR)", + "description": "Descrizione", + "search": "Cerca intervalli IP...", + "save": "Salva", + "cancel": "Annulla", + "ipsFound": "{count, plural, =0 {0 intervalli IP trovati} =1 {1 intervallo IP trovato} other {# intervalli IP trovati}}", + "validation": { + "ipRequired": "L'intervallo IP è obbligatorio", + "cidrRequired": "Notazione CIDR richiesta (es., 192.168.1.1/32 o 192.168.1.0/24)", + "invalidMask": "La maschera di sottorete deve essere compresa tra 0 e 32", + "invalidFormat": "Formato intervallo IP non valido", + "invalidOctet": "Ogni ottetto deve essere compreso tra 0 e 255" + }, + "notification": { + "createSuccessTitle": "Successo", + "createSuccessMessage": "Intervallo IP creato con successo", + "createErrorTitle": "Errore", + "createErrorMessage": "Impossibile creare l'intervallo IP", + "updateSuccessTitle": "Successo", + "updateSuccessMessage": "Intervallo IP aggiornato con successo", + "updateErrorTitle": "Errore", + "updateErrorMessage": "Impossibile aggiornare l'intervallo IP", + "deleteSuccessTitle": "Successo", + "deleteSuccessMessage": "Intervallo IP eliminato con successo", + "deleteErrorTitle": "Errore", + "deleteErrorMessage": "Impossibile eliminare l'intervallo IP" + } + }, "itinerary": { "title": "Itinerario", "tooltip": "Itinerario", diff --git a/web/client/translations/data.nl-NL.json b/web/client/translations/data.nl-NL.json index 9add1f0744..b3c01230f9 100644 --- a/web/client/translations/data.nl-NL.json +++ b/web/client/translations/data.nl-NL.json @@ -181,7 +181,9 @@ "eyeDomeLighting": "Activeer belichting", "eyeDomeLightingStrength": "Belichtingssterkte", "eyeDomeLightingRadius": "Belichtingsradius" - } + }, + "enableImageryOverlay": "Overlay van beeldlagen inschakelen", + "enableImageryOverlayInfo": "Opmerking: Schakelt beeldlagen zoals WMS, TMS of WMTS in om boven de 3D Tiles-laag te worden weergegeven, in de opgegeven volgorde." }, "modelLayer": { "modelCenterLat": "Breedtegraad centrum (DD)", diff --git a/web/client/translations/data.sv-SE.json b/web/client/translations/data.sv-SE.json index 4971e85b30..7be537da26 100644 --- a/web/client/translations/data.sv-SE.json +++ b/web/client/translations/data.sv-SE.json @@ -181,7 +181,9 @@ "eyeDomeLighting": "Aktivera belysning", "eyeDomeLightingStrength": "Belysningsstyrka", "eyeDomeLightingRadius": "Belysningsradie" - } + }, + "enableImageryOverlay": "Aktivera överlagring av bildlager", + "enableImageryOverlayInfo": "Observera: Aktiverar bildlager som WMS, TMS eller WMTS så att de renderas ovanpå 3D Tiles-lagret och visas sekventiellt enligt deras definierade ordning." }, "modelLayer": { "modelCenterLat": "Centrumets latitud (DD)", diff --git a/web/client/utils/GeostoreUtils.js b/web/client/utils/GeostoreUtils.js index 31a152496e..912f73c266 100644 --- a/web/client/utils/GeostoreUtils.js +++ b/web/client/utils/GeostoreUtils.js @@ -48,9 +48,6 @@ const resourceTypes = { MAP: { icon: { glyph: '1-map' }, formatViewerPath: (resource, context) => { - if (hasInaccessibleContext(resource, context)) { - return null; - } if (context?.name) { return `/context/${context.name}/${resource.id}`; } diff --git a/web/client/utils/IPValidationUtils.js b/web/client/utils/IPValidationUtils.js new file mode 100644 index 0000000000..85daa9e790 --- /dev/null +++ b/web/client/utils/IPValidationUtils.js @@ -0,0 +1,60 @@ +/* + * Copyright 2025, 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. + */ + +/** + * Validates an IP address in CIDR notation (required format) + * @param {string} ipAddress - The IP address in CIDR notation (e.g., "192.168.1.1/32" or "192.168.1.0/24") + * @returns {object} - Returns { isValid: boolean, error: string|null } where error is a translation key + */ +export function validateIPAddress(ipAddress) { + if (!ipAddress || typeof ipAddress !== 'string') { + return { isValid: false, error: 'ipManager.validation.ipRequired' }; + } + + // Trim whitespace + const trimmedIP = ipAddress.trim(); + + if (!trimmedIP) { + return { isValid: false, error: 'ipManager.validation.ipRequired' }; + } + + // CIDR notation is required (IP/mask) + const parts = trimmedIP.split('/'); + + // Must have exactly 2 parts: IP and mask + if (parts.length !== 2) { + return { isValid: false, error: 'ipManager.validation.cidrRequired' }; + } + + // Validate CIDR notation: IP/mask + const ip = parts[0].trim(); + const maskStr = parts[1].trim(); + const mask = parseInt(maskStr, 10); + + // Validate mask + if (maskStr === '' || isNaN(mask) || mask < 0 || mask > 32) { + return { isValid: false, error: 'ipManager.validation.invalidMask' }; + } + + // Validate IP address format + const ipParts = ip.split('.'); + if (ipParts.length !== 4) { + return { isValid: false, error: 'ipManager.validation.invalidFormat' }; + } + + for (let i = 0; i < 4; i++) { + const octetStr = ipParts[i].trim(); + const octet = parseInt(octetStr, 10); + if (octetStr === '' || isNaN(octet) || octet < 0 || octet > 255) { + return { isValid: false, error: 'ipManager.validation.invalidOctet' }; + } + } + + return { isValid: true, error: null }; +} + diff --git a/web/client/utils/LayersUtils.js b/web/client/utils/LayersUtils.js index e1f465eaa2..705a8be3dd 100644 --- a/web/client/utils/LayersUtils.js +++ b/web/client/utils/LayersUtils.js @@ -726,7 +726,8 @@ export const saveLayer = (layer) => { !isNil(layer.forceProxy) ? { forceProxy: layer.forceProxy } : {}, !isNil(layer.disableFeaturesEditing) ? { disableFeaturesEditing: layer.disableFeaturesEditing } : {}, layer.pointCloudShading ? { pointCloudShading: layer.pointCloudShading } : {}, - !isNil(layer.sourceMetadata) ? { sourceMetadata: layer.sourceMetadata } : {}); + !isNil(layer.sourceMetadata) ? { sourceMetadata: layer.sourceMetadata } : {}, + layer?.enableImageryOverlay !== undefined ? { enableImageryOverlay: layer.enableImageryOverlay } : {}); }; /** diff --git a/web/client/utils/__tests__/IPValidationUtils-test.js b/web/client/utils/__tests__/IPValidationUtils-test.js new file mode 100644 index 0000000000..a74372a7ee --- /dev/null +++ b/web/client/utils/__tests__/IPValidationUtils-test.js @@ -0,0 +1,100 @@ +/* + * Copyright 2025, 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 expect from 'expect'; + +import { validateIPAddress } from '../IPValidationUtils'; + +describe('IPValidationUtils test', () => { + describe('validateIPAddress - Valid CIDR notation', () => { + it('should accept valid CIDR formats', () => { + const validCIDRs = [ + '192.168.1.1/32', + '192.168.1.0/24', + '192.168.0.0/16', + '0.0.0.0/0', + ' 192.168.1.0/24 ', + '192.168.1.0 / 24' + ]; + + validCIDRs.forEach(cidr => { + const result = validateIPAddress(cidr); + expect(result.isValid).toBe(true); + expect(result.error).toBe(null); + }); + }); + }); + + describe('validateIPAddress - Invalid: Missing CIDR notation', () => { + it('should reject missing or invalid CIDR notation', () => { + const testCases = [ + { input: '192.168.1.1', expectedError: 'ipManager.validation.cidrRequired' }, + { input: '', expectedError: 'ipManager.validation.ipRequired' }, + { input: null, expectedError: 'ipManager.validation.ipRequired' }, + { input: undefined, expectedError: 'ipManager.validation.ipRequired' } + ]; + + testCases.forEach(({ input, expectedError }) => { + const result = validateIPAddress(input); + expect(result.isValid).toBe(false); + expect(result.error).toBe(expectedError); + }); + }); + }); + + describe('validateIPAddress - Invalid: Bad IP octets', () => { + it('should reject invalid IP octets', () => { + const testCases = [ + { input: '192.168.1.256/32', expectedError: 'ipManager.validation.invalidOctet' }, + { input: '192.168.-1.1/32', expectedError: 'ipManager.validation.invalidOctet' }, + { input: '192.168.1.1.1/32', expectedError: 'ipManager.validation.invalidFormat' }, + { input: '192.168.1/24', expectedError: 'ipManager.validation.invalidFormat' }, + { input: 'abc.def.ghi.jkl/32', expectedError: 'ipManager.validation.invalidOctet' }, + { input: '192..1.1/32', expectedError: 'ipManager.validation.invalidOctet' } + ]; + + testCases.forEach(({ input, expectedError }) => { + const result = validateIPAddress(input); + expect(result.isValid).toBe(false); + expect(result.error).toBe(expectedError); + }); + }); + }); + + describe('validateIPAddress - Invalid: Bad subnet masks', () => { + it('should reject invalid subnet masks', () => { + const invalidMasks = [ + '192.168.1.0/33', + '192.168.1.0/-1', + '192.168.1.0/', + '192.168.1.0/abc' + ]; + + invalidMasks.forEach(input => { + const result = validateIPAddress(input); + expect(result.isValid).toBe(false); + expect(result.error).toBe('ipManager.validation.invalidMask'); + }); + }); + }); + + describe('validateIPAddress - Invalid: Malformed CIDR', () => { + it('should reject malformed CIDR notation', () => { + const testCases = [ + { input: '192.168.1.0/24/16', expectedError: 'ipManager.validation.cidrRequired' }, + { input: '192.168.1.0/', expectedError: 'ipManager.validation.invalidMask' } + ]; + + testCases.forEach(({ input, expectedError }) => { + const result = validateIPAddress(input); + expect(result.isValid).toBe(false); + expect(result.error).toBe(expectedError); + }); + }); + }); +}); + diff --git a/web/client/utils/cesium/WMSUtils.js b/web/client/utils/cesium/WMSUtils.js index f2463aafcd..d9171afaa0 100644 --- a/web/client/utils/cesium/WMSUtils.js +++ b/web/client/utils/cesium/WMSUtils.js @@ -20,7 +20,7 @@ function getQueryString(parameters) { return Object.keys(parameters).map((key) => key + '=' + encodeURIComponent(parameters[key])).join('&'); } -const PARAM_OPTIONS = ["layers", "styles", "style", "format", "transparent", "version", "tiled", "opacity", "zindex", "srs", "singletile", "_v_", "filterobj" ]; +const PARAM_OPTIONS = ["layers", "styles", "style", "format", "transparent", "version", "tiled", "zindex", "srs", "singletile", "_v_", "filterobj" ]; function splitUrl(originalUrl) {