diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index be22b5ba2d3..da7e99a4484 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -8,17 +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 + - '[geonode]-[0-9].[0-9].x' # stable branches for GeoNode. E.g. geonode-4.4.x 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 jobs: test-front-end: runs-on: ubuntu-latest + strategy: + matrix: + node-version: ['20.x', '22.x', '24.x'] + fail-fast: false steps: - name: "checking out" uses: actions/checkout@v3 @@ -26,7 +32,7 @@ jobs: - name: "setting up npm" uses: actions/setup-node@v3 with: - node-version: '20.x' + node-version: ${{ matrix.node-version }} ############ # CACHING @@ -61,13 +67,19 @@ jobs: - name: Unit Tests run: npm test -- --reporters mocha,coverage,coveralls - - name: Coveralls + - name: Send coverage to Coveralls (parallel) uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} + parallel: true + flag-name: run-${{ join(matrix.*, '-') }} test-back-end: runs-on: ubuntu-latest + strategy: + matrix: + java-version: ['11.x', '17.x', '21.x'] + fail-fast: false steps: - name: "checking out" uses: actions/checkout@v3 @@ -79,7 +91,7 @@ jobs: uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: '11.x' + java-version: ${{ matrix.java-version }} - name: "cache maven dependencies" uses: actions/cache@v4 @@ -92,9 +104,28 @@ jobs: # JAVA CHECKS ############## - name: java - run: mvn --batch-mode --update-snapshots verify -Pprintingbundle,binary + run: mvn --batch-mode --update-snapshots verify -Pprintingbundle build: runs-on: ubuntu-latest + strategy: + matrix: + node-version: ['20.x', '22.x', '24.x'] + java-version: ['11.x', '17.x', '21.x'] + # Reduce the combinations to reduce the total number of jobs + exclude: + - node-version: '20.x' + java-version: '17.x' + - node-version: '20.x' + java-version: '21.x' + - node-version: '22.x' + java-version: '11.x' + - node-version: '22.x' + java-version: '21.x' + - node-version: '24.x' + java-version: '11.x' + - node-version: '24.x' + java-version: '17.x' + fail-fast: false steps: - name: "checking out" uses: actions/checkout@v3 @@ -106,12 +137,12 @@ jobs: uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: '11.x' + java-version: ${{ matrix.java-version }} - name: "setting up npm" uses: actions/setup-node@v2 with: - node-version: '20.x' + node-version: ${{ matrix.node-version }} ############ # CACHING @@ -151,7 +182,12 @@ jobs: run: mvn --batch-mode --update-snapshots verify build-publish: runs-on: ubuntu-latest - if: ${{ github.event_name == 'push' && github.repository == 'geosolutions-it/MapStore2' }} + if: | + github.event_name == 'push' && + github.repository == 'geosolutions-it/MapStore2' && + ( + github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/20') + ) needs: [test-front-end, test-back-end, build] steps: - name: "checking out" @@ -180,6 +216,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 @@ -187,6 +227,3 @@ jobs: env: MAVEN_USERNAME: ${{ secrets.GS_MAVEN_USERNAME }} MAVEN_PASSWORD: ${{ secrets.GS_MAVEN_PASSWORD }} - - - diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 00000000000..392f49e46d0 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,51 @@ +name: Backport merged pull request +on: + pull_request_target: + types: [closed, labeled] +permissions: + contents: write # so it can comment + pull-requests: write # so it can create pull requests +jobs: + backport: + name: Backport pull request + runs-on: ubuntu-latest + # Don't run on closed unmerged pull requests + if: > + github.event.pull_request.merged + && ( + github.event.action == 'closed' + || ( + github.event.action == 'labeled' + && contains(github.event.label.name, 'backport') + ) + ) + steps: + - uses: actions/checkout@v4 + with: + # Token for git actions, e.g. git push see https://github.com/korthout/backport-action/issues/379 + token: ${{ secrets.BACKPORT_ACTION_PAT }} + - name: Get issues + id: get-issues + uses: mondeja/pr-linked-issues-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.BACKPORT_ACTION_PAT }} + - name: Create backport pull requests + uses: korthout/backport-action@v3 + with: + # Token for git actions, see https://github.com/korthout/backport-action/issues/379 + github_token: ${{ secrets.BACKPORT_ACTION_PAT }} + auto_merge_method: squash + copy_assignees: true + copy_milestone: true + copy_requested_reviewers: true + add_labels: Backport + pull_description: |- + # Description + Backport of #${pull_number} to `${target_branch}`. + + Fixes #${{ steps.get-issues.outputs.issues }} + + experimental: > + { + "conflict_resolution": "draft_commit_conflicts" + } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ddd87c6cac..4e3f2f5f5fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,7 @@ on: # 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 + - '[geonode]-[0-9].[0-9].x' # stable branches for GeoNode. E.g. geonode-4.4.x jobs: build-publish: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0caeac5212b..6d958fa5d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## [2025.02.00](https://github.com/geosolutions-it/MapStore2/tree/v2025.02.00) (2025-12-10) + +- **[Full Changelog](https://github.com/geosolutions-it/MapStore2/compare/v2025.01.02...v2025.02.00)** +- **[Implemented enhancements](https://github.com/geosolutions-it/MapStore2/issues?q=is%3Aissue+milestone%3A%222025.02.00%22+is%3Aclosed+label%3Aenhancement)** +- **[Fixed bugs](https://github.com/geosolutions-it/MapStore2/issues?q=is%3Aissue+milestone%3A%222025.02.00%22+is%3Aclosed+label%3Abug)** +- **[Closed issues](https://github.com/geosolutions-it/MapStore2/issues?q=is%3Aissue+milestone%3A%222025.02.00%22+is%3Aclosed)** + ## [2025.01.02](https://github.com/geosolutions-it/MapStore2/tree/v2025.01.02) (2025-10-8) - **[Full Changelog](https://github.com/geosolutions-it/MapStore2/compare/v2025.01.01...v2025.01.02)** diff --git a/Dockerfile b/Dockerfile index 5c532df0dfe..e080b7dd86a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,51 @@ -FROM tomcat:9-jdk11 AS mother +FROM tomcat:9-jdk17 AS mother LABEL maintainer="Alessandro Parma" -ARG MAPSTORE_WEBAPP_SRC="https://github.com/geosolutions-it/MapStore2/releases/latest/download/mapstore.war" -ADD "${MAPSTORE_WEBAPP_SRC}" "/mapstore/" - -COPY ./docker/* /mapstore/docker/ +ARG MAPSTORE_WEBAPP_SRC="" +WORKDIR /tmp/build-context +COPY . . +RUN set -eux; \ + mkdir -p /mapstore; \ + WAR_SRC="${MAPSTORE_WEBAPP_SRC}"; \ + if [ -z "${WAR_SRC}" ]; then \ + for candidate in \ + "./product/target/mapstore.war" \ + "./web/target/mapstore.war"; do \ + if [ -f "${candidate}" ]; then \ + WAR_SRC="${candidate}"; \ + break; \ + fi; \ + done; \ + fi; \ + if [ -z "${WAR_SRC}" ]; then \ + echo "Unable to locate mapstore.war. Build the project or pass MAPSTORE_WEBAPP_SRC." >&2; \ + exit 1; \ + fi; \ + case "${WAR_SRC}" in \ + http://*|https://*) \ + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install --yes curl ca-certificates; \ + curl -fsSL "${WAR_SRC}" -o /mapstore/mapstore.war; \ + apt-get purge --yes --auto-remove curl ca-certificates; \ + rm -rf /var/lib/apt/lists/*; \ + ;; \ + /*|./*) \ + cp "${WAR_SRC}" /mapstore/mapstore.war; \ + ;; \ + *) \ + if [ -f "${WAR_SRC}" ]; then \ + cp "${WAR_SRC}" /mapstore/mapstore.war; \ + else \ + echo "Invalid MAPSTORE_WEBAPP_SRC value: ${WAR_SRC}" >&2; \ + exit 1; \ + fi; \ + ;; \ + esac; \ + + mkdir -p /mapstore/docker; \ + cp -r ./docker/. /mapstore/docker/ WORKDIR /mapstore -FROM tomcat:9-jdk11 +FROM tomcat:9-jdk17 ARG UID=1001 ARG GID=1001 ARG UNAME=tomcat @@ -28,7 +67,6 @@ COPY --from=mother "/mapstore/mapstore.war" "${MAPSTORE_WEBAPP_DST}/mapstore.war COPY --from=mother "/mapstore/docker" "${CATALINA_BASE}/docker/" COPY binary/tomcat/conf/server.xml "${CATALINA_BASE}/conf/" -RUN sed -i -e 's/8082/8080/g' ${CATALINA_BASE}/conf/server.xml RUN mkdir -p ${DATA_DIR} diff --git a/README.md b/README.md index 33b94b250ae..76503f9032e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ MapStore's architecture is designed for modularity and extensibility, allowing d For more information check the MapStore documentation! +Also check out the MapStore project entry page available online at [mapstore.io](https://mapstore.io/) + ## Documentation You can find more documentation about how to build, install or develop with MapStore on the documentation site. @@ -30,7 +32,7 @@ We have the following instances: 3. a STABLE instance, which can be accessed here, that gets deployed on demand after each release. As a user you need to be aware of STABLE and DEV, QA is used internally before a release; for 1 Week it will diverge from STABLE as it is actually anticipating the next stable. -So, if you want to test latest features use DEV, if you are not that brave use STABLE. You might forget that QA exists unless you are parte of the developers team. +So, if you want to test latest features use DEV, if you are not that brave use STABLE. You might forget that QA exists unless you are part of the developers team. ## Download @@ -59,6 +61,20 @@ Then you can access MapStore using the following URL: Use the default credentials (admin / admin) to login and start creating your maps! +### * Build your own image + +If you need to customize MapStore (e.g., use your own build or custom plugins), you can build an image using the provided Dockerfile instead of relying on the prebuilt image. + +The Dockerfile supports the build-time argument `MAPSTORE_WEBAPP_SRC`, which specifies either the URL or the local path of an already-built WAR file to include in the image. + +```shell +docker build \ + --build-arg MAPSTORE_WEBAPP_SRC= \ + -t . +``` + +If this argument is not provided, the build will automatically detect the WAR file from either `./product/target` (standard MapStore) or `./web/target` (custom MapStore), depending on the project structure. + ### * Run the Mapstore with PostGIS through docker-compose in the local environment - To test a different release of MapStore, you should change the `MAPSTORE_WEBAPP_SRC` build argument in the docker-compose file. diff --git a/binary/bin-war/pom.xml b/binary/bin-war/pom.xml index 362345d95a8..5d31ce8659d 100644 --- a/binary/bin-war/pom.xml +++ b/binary/bin-war/pom.xml @@ -3,12 +3,12 @@ it.geosolutions.mapstore mapstore-binary - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore mapstore-bin-war war - 1.10-SNAPSHOT + 1.11-SNAPSHOT MapStore 2 Release Module WAR Creates the war for the binary package, adding customization (e.g. h2 database) http://www.geo-solutions.it @@ -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 d3ef17718ed..2c5ec5233cc 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/bin/mapstore2-startup.bat b/binary/bin/mapstore2-startup.bat index 14384905fd9..8c9201b5f71 100644 --- a/binary/bin/mapstore2-startup.bat +++ b/binary/bin/mapstore2-startup.bat @@ -47,7 +47,7 @@ rem goto end echo Please wait while loading MapStore2... echo. call "%EXECUTABLE%" start %CMD_LINE_ARGS% - echo Point your browser to: http://localhost:8082/mapstore + echo Point your browser to: http://localhost:8080/mapstore echo. echo Enjoy MapStore2! goto end diff --git a/binary/bin/mapstore2-startup.sh b/binary/bin/mapstore2-startup.sh index c28adb3da2e..00bddc458f9 100644 --- a/binary/bin/mapstore2-startup.sh +++ b/binary/bin/mapstore2-startup.sh @@ -35,6 +35,6 @@ fi sh "$CATALINA_HOME"/bin/"$EXECUTABLE" start "$@" echo "Waiting for Tomcat start and MapStore2 deploy..." sleep 4 -echo "Point your browser to: http://localhost:8082/mapstore" +echo "Point your browser to: http://localhost:8080/mapstore" sleep 1 echo "Enjoy MapStore2!" diff --git a/binary/pom.xml b/binary/pom.xml index 2e31c26c963..eb175c7c48d 100644 --- a/binary/pom.xml +++ b/binary/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-root - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore mapstore-binary @@ -13,7 +13,7 @@ http://www.geo-solutions.it UTF-8 - 9.0.108 + 9.0.110 ${mapstore2.version} @@ -116,7 +116,7 @@ - + @@ -142,27 +142,6 @@ - - - maven-assembly-plugin - 2.1 - - - bin.xml - - mapstore2-${binary.number} - ${project.build.directory} - - - - make-assembly - install - - single - - - - diff --git a/binary/tomcat/conf/server.xml b/binary/tomcat/conf/server.xml index 2f79995f19e..ab0fc6565bd 100644 --- a/binary/tomcat/conf/server.xml +++ b/binary/tomcat/conf/server.xml @@ -66,7 +66,7 @@ APR (HTTP/AJP) Connector: /docs/apr.html Define a non-SSL/TLS HTTP/1.1 Connector on port 8080 --> - { - const globPath = path.join(__dirname, "..", "web", "client", "themes", "*"); - var files = glob.sync(globPath, {mark: true}); - return files.filter((f) => f.lastIndexOf('/') === f.length - 1).reduce((res, curr) => { - var finalRes = res || {}; - var themeName = path.basename(curr, path.extname(curr)); - finalRes["themes/" + themeName] = path.join(__dirname, "..", "web", "client", "themes", `${themeName}`, "theme.less"); - return finalRes; - }, {}); + const entries = {}; + const dirPath = path.join(__dirname, "..", "web", "client", "themes"); + readdirSync(dirPath, { withFileTypes: true }).filter(entry => entry.isDirectory()).forEach(entry => { + entries[`themes/${entry.name}`] = path.join(dirPath, entry.name, "theme.less"); + }); + return entries; })(); module.exports = { themeEntries, diff --git a/createProject.js b/createProject.js index 4f3b0bab732..26fe3f9245a 100644 --- a/createProject.js +++ b/createProject.js @@ -7,6 +7,7 @@ let outFolder = process.argv[7]; const project = require('./utility/projects/projectLib'); const denodeify = require('denodeify'); const readline = require('readline-promise').default; +const copyFile = denodeify(require('fs').copyFile); const paramsDesc = [{ label: 'Project Type (standard): ', @@ -87,6 +88,17 @@ function doWork(params) { }) .then(() => { process.stdout.write('copied static files\n'); + return copyFile('./Dockerfile', params.outFolder + '/Dockerfile'); + }) + .then(() => { + process.stdout.write('Dockerfile copied\n'); + return mkdirp(params.outFolder + '/binary/tomcat/conf'); + }) + .then(() => { + return copyFile('./binary/tomcat/conf/server.xml', params.outFolder + '/binary/tomcat/conf/server.xml'); + }) + .then(() => { + process.stdout.write('server.xml copied\n'); return project.copyTemplates('docker', params.outFolder + "/docker", options); }) .then(() => { diff --git a/docker-compose.yml b/docker-compose.yml index 66c059f3d08..8b2cccdd906 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: dockerfile: Dockerfile args: OVR: "geostore-datasource-ovr.properties" - MAPSTORE_WEBAPP_SRC: "https://github.com/geosolutions-it/MapStore2/releases/latest/download/mapstore.war" + MAPSTORE_WEBAPP_SRC: "" container_name: mapstore command: [ "wait-for-postgres", "postgres", "5432", "postgres", "postgres", "catalina.sh", "run" ] depends_on: diff --git a/docs/developer-guide/context-editor-config.md b/docs/developer-guide/context-editor-config.md index 7b64293eaaf..dbab5ff3d32 100644 --- a/docs/developer-guide/context-editor-config.md +++ b/docs/developer-guide/context-editor-config.md @@ -10,6 +10,7 @@ The configuration file has this shape: { "name": "Map", "mandatory": true, // <-- mandatory should not be shown in editor OR not movable and directly added to the right list. + "version": "1.2.3", }, { "name": "Notifications", "mandatory": true, // <-- mandatory should not be shown in editor OR not movable and directly added to the right list. @@ -52,6 +53,7 @@ Each entry of `plugins` array is an object that describes the plugin, it's depen These are the properties allowed for the plugin entry object: * `name`: `{string}` the name (ID) of the plugin +* `version`: `{string}` the version of the plugn * `title`: `{string}` the title string OR messageId (from localization file) * `description`: `{string}`: the description string OR messageId (from localization file) * `docUrl`: `{string}`: the plugin/extension specific documentation url diff --git a/docs/developer-guide/integrations/geoserver.md b/docs/developer-guide/integrations/geoserver.md index 68cf8a9215a..7898d941b6a 100644 --- a/docs/developer-guide/integrations/geoserver.md +++ b/docs/developer-guide/integrations/geoserver.md @@ -158,25 +158,30 @@ The last step is to configure MapStore to use the authkey with the configured in ```javascript //... -"useAuthenticationRules": true, - "authenticationRules": [{ - "urlPattern": ".*geostore.*", - "method": "bearer" - }, { +"requestsConfigurationRules": [ + { + "urlPattern": ".*rest/geostore.*", + "headers": { + "Authorization": "Bearer ${securityToken}" + } + }, + { "urlPattern": "\\/geoserver/.*", - "authkeyParamName": "authkey", - "method": "authkey" - }], + "params": { + "authkey": "${securityToken}" + } + } +], //... ``` -- Verify that "useAuthenticationRules" is set to `true` -- `authenticationRules` array should contain 2 rules: - - The first rule should already be present, and defines the authentication method used internally in mapstore +- Note: The new `requestsConfigurationRules` system is always active when rules are present, no flag needed +- `requestsConfigurationRules` array should contain 2 rules: + - The first rule should already be present, and defines the authentication method used internally in mapstore (Bearer token) - The second rule (the one you need to add) should be added and defines how to authenticate to GeoServer: - `urlPattern`: is a regular expression that identifies the request url where to apply the rule - - `method`: set it to `authkey` to use the authentication filter you just created in Geoserver. - - `authkeyParamName`: is the name of the authkey parameter defined in GeoServer (set to `authkey` by default) + - `params`: use query parameters for authkey authentication + - `authkey`: the name of the parameter (must match the one in GeoServer configuration, default is `authkey`) ### Advantages of user integration diff --git a/docs/developer-guide/local-config.md b/docs/developer-guide/local-config.md index 46a7f4a7df3..ab72185d31d 100644 --- a/docs/developer-guide/local-config.md +++ b/docs/developer-guide/local-config.md @@ -20,14 +20,15 @@ This is the main structure: "printUrl": "/geoserver-test/pdf/info.json", // a string or an object for the proxy URL. "proxyUrl": { - // When autoDetectCORS is not present or false, the application will use the proxy for all the requests except the ones in the useCORS array. + // When autoDetectCORS is not present it is true by default // if autoDetectCORS=true, the application will try the CORS request first, than will try to use the proxy if the request fails. + // In case it is false the application will use the proxy for all the requests except the ones in the useCORS array. // note: this parameter is actually not supported by Cesium, that will always use the proxy or the CORS request when in useCORS array. "autoDetectCORS": false, // if it is an object, the url entry holds the url to the proxy "url": "/MapStore2/proxy/?url=", - // useCORS array contains a list of services that support CORS and so do not need a proxy. - // if autoDetectCORS is true, this array will be ignored (except for Cesium) + // useCORS array contains a list of services that support CORS and so do not need a proxy in case of autoDetectCORS = false + // if autoDetectCORS is missing or true, this array will be ignored (except for Cesium) "useCORS": ["http://nominatim.openstreetmap.org", "https://nominatim.openstreetmap.org"] }, // JSON file where uploaded extensions are configured @@ -44,17 +45,30 @@ This is the main structure: // path to the translation files directory (if different from default) "translationsPath", // if true, every ajax and mapping request will be authenticated with the configurations if match a rule (default: true) - "useAuthenticationRules": true - // the authentication rules to match - "authenticationRules": [ - { // every rule has a `urlPattern` regex to match - "urlPattern": ".*geostore.*", - // and a authentication `method` to use (basic, authkey, browserWithCredentials, header) - "method": "basic" - }, { - "urlPattern": "\\/geoserver.*", - "method": "authkey" - }], + // the request configuration rules to match + "requestsConfigurationRules": [ + { // every rule has a `urlPattern` regex to match + "urlPattern": ".*geostore.*", + // headers to add to matching requests + "headers": { + "Authorization": "Bearer ${securityToken}" + } + }, { + "urlPattern": "\\/geoserver/.*", + // parameters to add to matching requests + "params": { + "authkey": "${securityToken}" + } + }, { + "urlPattern": ".*azure-blob.*", + // expiration timestamp (optional, Unix timestamp in seconds) + "expires": 1735689600, + // parameters can be used for SAS tokens + "params": { + "sv": "2024-11-04", + "sig": "${sasToken}" + } + }], // flag for postponing mapstore 2 load time after theme "loadAfterTheme": false, // if defined, WMS layer styles localization will be added @@ -149,23 +163,59 @@ For configuring plugins, see the [Configuring Plugins Section](plugins-documenta - `initialState`: is an object that will initialize the state with some default values and this WILL OVERRIDE the initialState imposed by plugins & reducers. - `projectionDefs`: is an array of objects that contain definitions for Coordinate Reference Systems - `gridFiles`: is an object that contains definitions for grid files used in coordinate transformations -- `useAuthenticationRules`: if this flag is set to true, the `authenticationRules` will be used to authenticate every ajax and mapping request. If the flag is set to false, the `authenticationRules` will be ignored. -- `authenticationRules`: is an array of objects that contain rules to match for authentication. Each rule has a `urlPattern` regex to match and a `method` to use (`basic`, `authkey`, `header`, `browserWithCredentials`). If the URL of a request matches the `urlPattern` of a rule, the `method` will be used to authenticate the request. The `method` can be: - - `basic` will use the basic authentication method getting the credentials from the user that logged in (adding the header `Authorization` `Basic ` to the request). ***Note**: this method is not implemented for image tile requests (e.g. layers) but only for ajax requests.* - - `authkey` will use the authkey method getting the credentials from the user that logged in. The token of the current MapStore session will be used as the authkey value, so this works only with the geoserver integration. - - `bearer` will use the header `Authorization` `Bearer ` getting the credentials from the user that logged in. The token of the current MapStore session will be used as the bearer value, so this works only with the geoserver integration. - - `header` will use the header method getting the credentials from the user that logged in. You can add an `headers` object containing the static headers to this rule to specify witch headers to use. e.g. - - `browserWithCredentials` will add the `withCredentials` parameter to ajax requests, so the browser will send the cookies and the authentication headers to the server. This method is useful when you have a proxy that needs to authenticate the user. ***Note**: this method is not implemented for image tile requests (e.g. layers) but only for ajax requests.* - - ```json +- `useAuthenticationRules` (deprecated): if this flag is set to true, legacy `authenticationRules` will be used. The new `requestsConfigurationRules` system does not require this flag and is always active when rules are present. +- `requestsConfigurationRules`: is an array of objects that contain rules to match for request configuration. Each rule has a `urlPattern` regex to match and either `headers`, `params`, or `withCredentials` configuration. If the URL of a request matches the `urlPattern` of a rule, the configuration will be applied to the request. + + **Available variable for template substitution (ES6 template syntax `${variable}`):** + - `${securityToken}` - The current MapStore session token (automatically replaced) + + **Configuration options:** + - `headers` - Object containing HTTP headers to add to matching requests. Example: + + ```json { - "urlPattern": ".*geostore.*", - "method": "header", - "headers": { - "X-Auth-Token": "mytoken" - } + "urlPattern": ".*geostore.*", + "headers": { + "Authorization": "Bearer ${securityToken}" + } } ``` + + - `params` - Object containing query parameters to add to matching requests. Example: + + ```json + { + "urlPattern": "\\/geoserver/.*", + "params": { + "authkey": "${securityToken}" + } + } + ``` + + - `withCredentials` - Boolean to enable sending credentials with requests (useful with proxies): + + ```json + { + "urlPattern": ".*internal-api.*", + "withCredentials": true + } + ``` + + - `expires` - Optional Unix timestamp (in seconds) for automatic rule expiration. Example: + + ```json + { + "urlPattern": ".*azure-blob.*", + "expires": 1735689600, + "params": { + "sv": "2024-11-04", + "sig": "token" + } + } + ``` + +!!! note "Backward Compatibility" + The old `useAuthenticationRules` and `authenticationRules` configuration still works and will be automatically converted to the new format. However, the new format is recommended for better flexibility and features like expiration support. ### initialState configuration diff --git a/docs/developer-guide/mapstore-migration-guide.md b/docs/developer-guide/mapstore-migration-guide.md index 5b32f042783..93708fa1e1f 100644 --- a/docs/developer-guide/mapstore-migration-guide.md +++ b/docs/developer-guide/mapstore-migration-guide.md @@ -20,8 +20,89 @@ This is a list of things to check if you want to update from a previous version - Optionally check also accessory files like `.eslinrc`, if you want to keep aligned with lint standards. - Follow the instructions below, in order, from your version to the one you want to update to. +## Migration from 2025.02.02 to 2026.01.00 + +### Replace authenticationRules with requestsConfigurationRules + +As part of improving the authentication rules to make dynamic request configurations, we have deprecated the use of `authenticationRules` in favor of the new request rule configuration `requestsConfigurationRules`. The new system provides a more flexible way to configure request authentication and parameters. + +### Configuration Changes + +#### Old Configuration (authenticationRules) + +```json +{ + "useAuthenticationRules": true, + "authenticationRules": [ + { + "urlPattern": ".*rest/geostore.*", + "method": "bearer" + }, + { + "urlPattern": ".*rest/config.*", + "method": "bearer" + } + ] +} +``` + +#### New Configuration (requestsConfigurationRules) + +```json +{ + "requestsConfigurationRules": [ + { + "urlPattern": ".*rest/geostore.*", + "headers": { + "Authorization": "Bearer ${securityToken}" + } + }, + { + "urlPattern": ".*rest/config.*", + "headers": { + "Authorization": "Bearer ${securityToken}" + } + } + ] +} +``` + +**Note**: The `${securityToken}` placeholder is automatically replaced at runtime with the actual security token from the security context + +#### Method Mapping + +| Old Method | New Configuration | +|------------|------------------| +| `bearer` | `headers: { "Authorization": "Bearer ${securityToken}" }` | +| `authkey` | `params: { "authkey": "${securityToken}" }` | +| `basic` | `headers: { "Authorization": "${authHeader}" }` | +| `header` | `headers: { ... }` | +| `browserWithCredentials` | `withCredentials: true` | + ## 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. @@ -96,6 +177,37 @@ In your project, you should update the `print-lib.version` property from version + 2.3.3 ``` +### Update `web.xml` with cache control + +MapStore 2025.02.00 introduces an improvement in cache management to prevent internal proxies and browsers from caching certain files, ensuring that updates are correctly applied. + +To enable this improvement, the `web.xml` file (usually located in `java/web/`) has been updated. +If your custom project includes its own web.xml, make sure to update it by adding the following lines. + +```xml + + + noCacheFilter + it.geosolutions.mapstore.filters.NoCacheFilter + + + noCacheFilter + / + + + noCacheFilter + *.html + + + noCacheFilter + *.json + + + noCacheFilter + *.txt + +``` + ### Removal of terrain from cfg.additionalLayers property using the new background selector All contexts containing configuration for a `terrain` layer inside the `cfg.additionalLayers` property of the `Map` plugin should be updated as follow: diff --git a/docs/developer-guide/requirements.md b/docs/developer-guide/requirements.md index b4dab0d98b1..6281f96941e 100644 --- a/docs/developer-guide/requirements.md +++ b/docs/developer-guide/requirements.md @@ -6,32 +6,29 @@ In this section you can have a glance of the minimum and recommended versions of You can download a java web container like *Apache Tomcat* from and *Java JRE* -| Tool | Link | Minimum | Recommended | Maximum | -|--------|----------------------------------------------------|---------|-------------|---------------| -| Java | [link](https://www.java.com/it/download/) | 81 | 11 | 112 | -| Tomcat | [link](https://tomcat.apache.org/download-80.cgi) | 8.5 | 9 | 92 | +| Tool | Link | Minimum | Recommended | Maximum | +|--------|----------------------------------------------------|-----------------|-------------|---------------| +| Java | [link](https://jdk.java.net/archive/) | 111 | 17 | 21 | +| Tomcat | [link](https://tomcat.apache.org/download-90.cgi) | 8.5 | 9 | 92 | ## Debug / Build These tools needs to be installed (other than **Java** in versions above above): -| Tool | Link | Minimum | Recommended | Maximum | -|-----------------------|------------------------------------------------------------|---------|-------------|---------------------| -| npm | [link](https://www.npmjs.com/get-npm) | 8 | 10 | | -| NodeJS | [link](https://nodejs.org/en/) | 20 | 20 | 203 | -| Java (JDK) | [link](https://www.java.com/en/download/help/develop.html) | 8 | 9 | 112 | -| Maven | [link](https://maven.apache.org/download.cgi) | 3.1.0 | 3.6 | | -| python4 | [link](https://www.python.org/downloads/) | 2.7.9 | 3.7 | | +| Tool | Link | Minimum | Recommended | Maximum | +|-----------------------|------------------------------------------------------------|---------|-------------|-----------------| +| npm | [link](https://www.npmjs.com/get-npm) | 8 | 10 | | +| NodeJS | [link](https://nodejs.org/en/) | 20 | 20 | 243 | +| Java (JDK) | [link](https://jdk.java.net/archive/) | 11 | 17 | 21 | +| Maven | [link](https://maven.apache.org/download.cgi) | 3.1.0 | 3.6 | | +| python4 | [link](https://www.python.org/downloads/) | 2.7.9 | 3.7 | | !!! notes Here some notes about some requirements and reasons for max version indicated, for future improvements and maintenance : - 1 Java 8 is the minimum version required for running MapStore, but it is not compatible in case you want to use the print module. In this case, you need to use Java 11. - - 2 About Java and Tomcat maximum versions: - - For execution, MapStore is well tested on Java v11. - - Build with success with v11, only smoke tests passing on v13, errors with v16.(Details on issue [#6935](https://github.com/geosolutions-it/MapStore2/issues/6935)) - - Running with Tomcat 10 causes this issue [#7524](https://github.com/geosolutions-it/MapStore2/issues/7524). - - 3 See issue [#11577](https://github.com/geosolutions-it/MapStore2/issues/11577) for details about this limit (for now only for documentation build). + - 2 Running with Tomcat 10 causes this issue [#7524](https://github.com/geosolutions-it/MapStore2/issues/7524). + - 3 Latest version tested. - 4 Python is only needed for building documentation. ## Running in Production @@ -49,4 +46,4 @@ In production a PostgreSQL database is recommended: | Tool | Link | Minimum | Recommended | Maximum | |----------|----------------------------------------------------|---------|-------------|------------| -| Postgres | [link](https://www.postgresql.org/) | 13 | 16 | 17 | +| Postgres | [link](https://www.postgresql.org/) | 13 | 16 | 18 | diff --git a/docs/quick-start.md b/docs/quick-start.md index eedc6e291fb..ed10e954855 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -18,7 +18,7 @@ Windows: `mapstore2_startup.bat` Linux: `./mapstore2_startup.sh` -Point your browser to: [http://localhost:8082/mapstore](http://localhost:8082/mapstore) +Point your browser to: [http://localhost:8080/mapstore](http://localhost:8080/mapstore) To stop MapStore simply do: diff --git a/docs/user-guide/catalog.md b/docs/user-guide/catalog.md index c4f9d65ce0d..e43e6604c63 100644 --- a/docs/user-guide/catalog.md +++ b/docs/user-guide/catalog.md @@ -52,13 +52,17 @@ The general settings are three mandatory fields that each Remote Service needs t -In particular: +In particular, the user can set: -* **Url**: the URL of the remote source service +* The **Url** of the remote source service -* **Type**: the type of the remote source service (between *WMS*, *WFS*, *CSW*, *TMS*, *WMTS* and *3D Tiles*) +* The **Type** of the remote source service chooseing between *WMS*, *WFS*, *CSW*, *TMS*, *WMTS*, *3D Tiles*, *IFC Model* and *ArcGIS* -* **Title**: the title to assign to the catalog. This text will be used in the service selection dropdown menu for this service. +* The **Title** to assign to the catalog. This text will be used in the service selection dropdown menu for this service. + +MapStore also provides the possibility specify credentials for sources requesting them for authorizing requested layer tiles. Once the button is clicked, the service credentials can be entered in a popup. This is for supporting Basic Auth and ensures that the *Basic Authentication* header is automatically included in the OGC requests for layers belonging to that catalog source. + + ### Advanced settings diff --git a/docs/user-guide/filtering-layers.md b/docs/user-guide/filtering-layers.md index 4e93f90b7e9..665a2e24ad2 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/basic-auth-button.jpg b/docs/user-guide/img/button/basic-auth-button.jpg new file mode 100644 index 00000000000..956e1c0ce34 Binary files /dev/null and b/docs/user-guide/img/button/basic-auth-button.jpg differ diff --git a/docs/user-guide/img/button/coordinates-button.jpg b/docs/user-guide/img/button/coordinates-button.jpg new file mode 100644 index 00000000000..7a1937c44a0 Binary files /dev/null and b/docs/user-guide/img/button/coordinates-button.jpg differ diff --git a/docs/user-guide/img/button/isochrone-button.jpg b/docs/user-guide/img/button/isochrone-button.jpg new file mode 100644 index 00000000000..04b46fac49e Binary files /dev/null and b/docs/user-guide/img/button/isochrone-button.jpg differ diff --git a/docs/user-guide/img/button/itinerary-button.jpg b/docs/user-guide/img/button/itinerary-button.jpg new file mode 100644 index 00000000000..a8d3ae76138 Binary files /dev/null and b/docs/user-guide/img/button/itinerary-button.jpg differ diff --git a/docs/user-guide/img/button/new_ip-ranges_button.jpg b/docs/user-guide/img/button/new_ip-ranges_button.jpg new file mode 100644 index 00000000000..6df6a7ce189 Binary files /dev/null and b/docs/user-guide/img/button/new_ip-ranges_button.jpg differ diff --git a/docs/user-guide/img/button/new_tag_button.jpg b/docs/user-guide/img/button/new_tag_button.jpg index 515eed21475..5ce350c17e6 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/button/run-blue-button.jpg b/docs/user-guide/img/button/run-blue-button.jpg new file mode 100644 index 00000000000..d67a1f776da Binary files /dev/null and b/docs/user-guide/img/button/run-blue-button.jpg differ diff --git a/docs/user-guide/img/button/three-dots-button.jpg b/docs/user-guide/img/button/three-dots-button.jpg index 0404cc9fc02..24eb5b5fe1f 100644 Binary files a/docs/user-guide/img/button/three-dots-button.jpg and b/docs/user-guide/img/button/three-dots-button.jpg differ diff --git a/docs/user-guide/img/catalog/basic-authentication.mp4 b/docs/user-guide/img/catalog/basic-authentication.mp4 new file mode 100644 index 00000000000..b5cae666494 Binary files /dev/null and b/docs/user-guide/img/catalog/basic-authentication.mp4 differ diff --git a/docs/user-guide/img/catalog/general_settings.jpg b/docs/user-guide/img/catalog/general_settings.jpg index faad3710cf6..0ea8cbdeaad 100644 Binary files a/docs/user-guide/img/catalog/general_settings.jpg and b/docs/user-guide/img/catalog/general_settings.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 00000000000..105e939b314 Binary files /dev/null and b/docs/user-guide/img/filtering-layers/cascading-search-filter.jpg differ diff --git a/docs/user-guide/img/ip-ranges/ip-ranges-manager.jpg b/docs/user-guide/img/ip-ranges/ip-ranges-manager.jpg new file mode 100644 index 00000000000..34fc904c46e Binary files /dev/null and b/docs/user-guide/img/ip-ranges/ip-ranges-manager.jpg differ diff --git a/docs/user-guide/img/ip-ranges/ip-ranges-panel.jpg b/docs/user-guide/img/ip-ranges/ip-ranges-panel.jpg new file mode 100644 index 00000000000..bca64c4d632 Binary files /dev/null and b/docs/user-guide/img/ip-ranges/ip-ranges-panel.jpg differ diff --git a/docs/user-guide/img/ip-ranges/new_ip-ranges.jpg b/docs/user-guide/img/ip-ranges/new_ip-ranges.jpg new file mode 100644 index 00000000000..30fa954a8ea Binary files /dev/null and b/docs/user-guide/img/ip-ranges/new_ip-ranges.jpg differ diff --git a/docs/user-guide/img/ip-ranges/search_ip-ranges.jpg b/docs/user-guide/img/ip-ranges/search_ip-ranges.jpg new file mode 100644 index 00000000000..b2c09c46447 Binary files /dev/null and b/docs/user-guide/img/ip-ranges/search_ip-ranges.jpg differ diff --git a/docs/user-guide/img/isochrone/add-isochrone-point.mp4 b/docs/user-guide/img/isochrone/add-isochrone-point.mp4 new file mode 100644 index 00000000000..26d5f34252d Binary files /dev/null and b/docs/user-guide/img/isochrone/add-isochrone-point.mp4 differ diff --git a/docs/user-guide/img/isochrone/isochrone-options.jpg b/docs/user-guide/img/isochrone/isochrone-options.jpg new file mode 100644 index 00000000000..886b394f3f6 Binary files /dev/null and b/docs/user-guide/img/isochrone/isochrone-options.jpg differ diff --git a/docs/user-guide/img/isochrone/isochrone-panel.jpg b/docs/user-guide/img/isochrone/isochrone-panel.jpg new file mode 100644 index 00000000000..2bba5592b17 Binary files /dev/null and b/docs/user-guide/img/isochrone/isochrone-panel.jpg differ diff --git a/docs/user-guide/img/isochrone/isochrone-result.mp4 b/docs/user-guide/img/isochrone/isochrone-result.mp4 new file mode 100644 index 00000000000..3c8aaadd5e7 Binary files /dev/null and b/docs/user-guide/img/isochrone/isochrone-result.mp4 differ diff --git a/docs/user-guide/img/itinerary/add-destination.mp4 b/docs/user-guide/img/itinerary/add-destination.mp4 new file mode 100644 index 00000000000..b8656cdc4c9 Binary files /dev/null and b/docs/user-guide/img/itinerary/add-destination.mp4 differ diff --git a/docs/user-guide/img/itinerary/create.itinerary.mp4 b/docs/user-guide/img/itinerary/create.itinerary.mp4 new file mode 100644 index 00000000000..8fcfde980be Binary files /dev/null and b/docs/user-guide/img/itinerary/create.itinerary.mp4 differ diff --git a/docs/user-guide/img/itinerary/itinerary-options.jpg b/docs/user-guide/img/itinerary/itinerary-options.jpg new file mode 100644 index 00000000000..0523a9efb72 Binary files /dev/null and b/docs/user-guide/img/itinerary/itinerary-options.jpg differ diff --git a/docs/user-guide/img/itinerary/itinerary-panel.jpg b/docs/user-guide/img/itinerary/itinerary-panel.jpg new file mode 100644 index 00000000000..d7ea230e6ff Binary files /dev/null and b/docs/user-guide/img/itinerary/itinerary-panel.jpg differ diff --git a/docs/user-guide/img/itinerary/route-itinerary.jpg b/docs/user-guide/img/itinerary/route-itinerary.jpg new file mode 100644 index 00000000000..b00d09737f6 Binary files /dev/null and b/docs/user-guide/img/itinerary/route-itinerary.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 80882b38972..b0b4d027e54 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 00000000000..4cf0a736a24 Binary files /dev/null and b/docs/user-guide/img/layer-settings/imagery-layers.jpg differ diff --git a/docs/user-guide/img/managing-users-and-groups/manage-groups.jpg b/docs/user-guide/img/managing-users-and-groups/manage-groups.jpg new file mode 100644 index 00000000000..1754f0c582d Binary files /dev/null and b/docs/user-guide/img/managing-users-and-groups/manage-groups.jpg differ diff --git a/docs/user-guide/img/managing-users-and-groups/manager-page.jp.png b/docs/user-guide/img/managing-users-and-groups/manager-page.jp.png new file mode 100644 index 00000000000..ba5c5b2821c Binary files /dev/null and b/docs/user-guide/img/managing-users-and-groups/manager-page.jp.png differ diff --git a/docs/user-guide/img/managing-users-and-groups/manager.jpg b/docs/user-guide/img/managing-users-and-groups/manager.jpg index ec476e83f88..f66401e0128 100644 Binary files a/docs/user-guide/img/managing-users-and-groups/manager.jpg and b/docs/user-guide/img/managing-users-and-groups/manager.jpg differ diff --git a/docs/user-guide/img/resource-properties/ip_rule_added.jpg b/docs/user-guide/img/resource-properties/ip_rule_added.jpg new file mode 100644 index 00000000000..20621196a5c Binary files /dev/null and b/docs/user-guide/img/resource-properties/ip_rule_added.jpg differ diff --git a/docs/user-guide/img/resource-properties/rule_added.jpg b/docs/user-guide/img/resource-properties/rule_added.jpg index c72bacb3da5..5993a87d00d 100644 Binary files a/docs/user-guide/img/resource-properties/rule_added.jpg and b/docs/user-guide/img/resource-properties/rule_added.jpg differ diff --git a/docs/user-guide/img/rule-manager/add-gs.jpg b/docs/user-guide/img/rule-manager/add-gs.jpg new file mode 100644 index 00000000000..be24eac3934 Binary files /dev/null and b/docs/user-guide/img/rule-manager/add-gs.jpg differ diff --git a/docs/user-guide/img/rule-manager/add-rule.jpg b/docs/user-guide/img/rule-manager/add-rule.jpg new file mode 100644 index 00000000000..62661a446b3 Binary files /dev/null and b/docs/user-guide/img/rule-manager/add-rule.jpg differ diff --git a/docs/user-guide/img/rule-manager/attribute-table-tab.jpg b/docs/user-guide/img/rule-manager/attribute-table-tab.jpg new file mode 100644 index 00000000000..d0848418ba3 Binary files /dev/null and b/docs/user-guide/img/rule-manager/attribute-table-tab.jpg differ diff --git a/docs/user-guide/img/rule-manager/change-priority.jpg b/docs/user-guide/img/rule-manager/change-priority.jpg new file mode 100644 index 00000000000..7591e81e013 Binary files /dev/null and b/docs/user-guide/img/rule-manager/change-priority.jpg differ diff --git a/docs/user-guide/img/rule-manager/filter-tab.jpg b/docs/user-guide/img/rule-manager/filter-tab.jpg new file mode 100644 index 00000000000..831cb2c33a7 Binary files /dev/null and b/docs/user-guide/img/rule-manager/filter-tab.jpg differ diff --git a/docs/user-guide/img/rule-manager/gs-manager-panel.jpg b/docs/user-guide/img/rule-manager/gs-manager-panel.jpg new file mode 100644 index 00000000000..e1ff81eaaa9 Binary files /dev/null and b/docs/user-guide/img/rule-manager/gs-manager-panel.jpg differ diff --git a/docs/user-guide/img/rule-manager/manage-layer-options.jpg b/docs/user-guide/img/rule-manager/manage-layer-options.jpg new file mode 100644 index 00000000000..cf5cddb3cc6 Binary files /dev/null and b/docs/user-guide/img/rule-manager/manage-layer-options.jpg differ diff --git a/docs/user-guide/img/rule-manager/rule-manager-panel.jpg b/docs/user-guide/img/rule-manager/rule-manager-panel.jpg new file mode 100644 index 00000000000..ece441556f5 Binary files /dev/null and b/docs/user-guide/img/rule-manager/rule-manager-panel.jpg differ diff --git a/docs/user-guide/img/rule-manager/rule-manager.jpg b/docs/user-guide/img/rule-manager/rule-manager.jpg new file mode 100644 index 00000000000..bccede418f1 Binary files /dev/null and b/docs/user-guide/img/rule-manager/rule-manager.jpg differ diff --git a/docs/user-guide/img/rule-manager/search-rule-checkbox.mp4 b/docs/user-guide/img/rule-manager/search-rule-checkbox.mp4 new file mode 100644 index 00000000000..6cc1cdd8e87 Binary files /dev/null and b/docs/user-guide/img/rule-manager/search-rule-checkbox.mp4 differ diff --git a/docs/user-guide/img/rule-manager/search-rule.mp4 b/docs/user-guide/img/rule-manager/search-rule.mp4 new file mode 100644 index 00000000000..1c00dd1be29 Binary files /dev/null and b/docs/user-guide/img/rule-manager/search-rule.mp4 differ diff --git a/docs/user-guide/img/rule-manager/style-tab.jpg b/docs/user-guide/img/rule-manager/style-tab.jpg new file mode 100644 index 00000000000..8270c82cf12 Binary files /dev/null and b/docs/user-guide/img/rule-manager/style-tab.jpg differ diff --git a/docs/user-guide/img/tags/new_tag.jpg b/docs/user-guide/img/tags/new_tag.jpg index 11b5cace34e..1be72fd4584 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 9a878ad3683..b23d3b4895d 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 916d8d2651c..3d58574ddb7 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 00000000000..b6f9c58d71f 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 00000000000..ecc698a1cb1 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 00000000000..47bb7694608 Binary files /dev/null and b/docs/user-guide/img/widgets/trace_null_value.jpg differ diff --git a/docs/user-guide/ip-ranges.md b/docs/user-guide/ip-ranges.md new file mode 100644 index 00000000000..5e62d12ac36 --- /dev/null +++ b/docs/user-guide/ip-ranges.md @@ -0,0 +1,28 @@ +# Managing IP Ranges + +******************* + +In [MapStore](https://mapstore.geosolutionsgroup.com/mapstore/#/), the **IP Ranges Manager** is an administrative tool designed to manage IP Ranges used as an access control method for resources. Administrative users can assign one or more existing IP ranges to a resource through the [Permission Rules](resources-properties.md#permission-rules) properties, allowing access based on specific IP addresses. + +As an admin user, it is possible to manage **IP Ranges** by selecting **Manage IP Ranges** option from the button on the [Homepage](home-page.md#home-page): + + + +The *Manager* page opens on **IP Ranges** tab, allowing the admin user to: + + + +* Create a **New IP Ranges** through the button and customize it by adding a *IP Range (CIDR format)* and a *Description*. + + + +* **Search** for a tag using the search bar + + + +* **Edit** a tag through the button next to each tag in the list. + +* **Remove** a tag through the button next to each tag in the list. + +!!! Warning + When both *Groups* and *IP Range* permission rules are defined for a MapStore resource, **the group rules have priority**. For example, if a user belongs to a group with *Edit* permissions on a resource but is also within an IP range that has only *View* permissions, the user will still have *Edit* rights for that resource. diff --git a/docs/user-guide/isochrone.md b/docs/user-guide/isochrone.md new file mode 100644 index 00000000000..5c7ca594613 --- /dev/null +++ b/docs/user-guide/isochrone.md @@ -0,0 +1,47 @@ +# Isochrone Tool + +******************* + +The **Isochrone** tool allows the user to compute and visualize isochrones and isodistances in [MapStore](https://mapstore.geosolutionsgroup.com/mapstore/#/) using different supported routing providers, such as [GraphHopper Isochrones API](https://docs.graphhopper.com/openapi/isochrones). + +!!! note + The **Isochrone** plugin is not enabled by default. However, it can be configured within [application contexts](application-context.md#configure-plugins) using the desired provider. + +By clicking the **Isochrone** button available in the [Side Toolbar](mapstore-toolbars.md#side-toolbar), the tool is activated, allowing the user to select a point and display the computed isochrones or isodistances on the map. + + + +The user can select the starting point in two ways: + +* Typing the address into the text box +* Selecting a point on the map using the button + + + +The *Isochrone* can be further customized using the following options: + + + +* Choose the **Travel Mode**, either *walking* or *driving* +* Choose the `Distance` in the dropdown menu for *Isodistance* or `Time` for *Isochrone* as the basis for the **Range** +* Choose the **Direction** between `Departure` or `Arrival` +* Define the number of intervals within the chosen *Range* using the **Buckets** option +* Select the **Colors** for the *Buckets* using the *Color Picker* + +Once all options are set, the user can click the button to visualize the computed isochrones or isodistances at the bottom of the panel and on the map. + + + +For each result, the latitude and longitude of the point and the corresponding distance/time are highlighted. The user can also: + +* Enable/disable layer visibility by using the checkbox on the right side + +* Tune the result transparency in map by scrolling the opacity slider + +* **Use run parameters** via the dropdown menu accessed through the button. This option allows the user to reuse the results of a previous run to clone the parameters for a new run + +* **Export as GeoJSON** using the same dropdown menu + +* **Add as layer** using the same dropdown menu. This option allows the user to add the selected run result as a vector layer to the TOC + +* **Delete result** using the same dropdown menu diff --git a/docs/user-guide/itinerary.md b/docs/user-guide/itinerary.md new file mode 100644 index 00000000000..3194fdc3efd --- /dev/null +++ b/docs/user-guide/itinerary.md @@ -0,0 +1,43 @@ +# Itinerary Tool + +******************* + +The **Itinerary** tool allows the user to compute and visualize routes in [MapStore](https://mapstore.geosolutionsgroup.com/mapstore/#/) using different supported routing providers, such as [GraphHopper](https://www.graphhopper.com/). + +!!! note + The **Itinerary** plugin is not enabled by default. However, it can be configured within [application contexts](application-context.md#configure-plugins) using the desired provider. + +By clicking the **Itinerary** button available in the [Side Toolbar](mapstore-toolbars.md#side-toolbar), the tool is activated, allowing the user to select destination points and compute the route, which is then displayed on the map + + + +The user can select the starting point and the destination in two ways: + +* Typing the address into the text box +* Selecting a point on the map using the button + + + +The user can also add an additional destination using the button. + + + +The itinerary can be further customized using the following options: + + + +* Choose the **Travel Mode**, either *walking* or *driving* +* Enable **Optimize Route** to reorder the available routes from fastest to slowest +* Select one or more options to avoid: *Motorways & Highways*, *Trunk Roads & Major Arterials*, *Ferry Crossings*, *Tunnels & Underground Passages*, and/or *Bridges & Elevated Roads* + +Once at least two points are defined, the available routes are listed at the bottom of the panel and displayed on the map. + + + +For each listed route, the main path and the estimated travel time are highlighted. The user can also: + +* View the full itinerary using the button + +* **Export as GeoJSON** using the dropdown menu available through the button + +* **Add as layer** using the same dropdown menu accessible from the button diff --git a/docs/user-guide/layer-settings.md b/docs/user-guide/layer-settings.md index d798be4e9dc..fcb2c0bc70e 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/managing-users-and-groups.md b/docs/user-guide/managing-users-and-groups.md index 450f652f42d..b0bf886fb32 100644 --- a/docs/user-guide/managing-users-and-groups.md +++ b/docs/user-guide/managing-users-and-groups.md @@ -26,8 +26,10 @@ Once logged in as Admin, it becomes possible to manage users and groups, and the -Selecting **Manage Accounts** options, the *Account Manager* opens: +Selecting **Manage Accounts** options, the [Manage Users](managing-users.md#managing-users) tab opens: -In this page it is possible to switch between [Manage Users](managing-users.md#managing-users) or [Manage Groups](managing-groups.md#managing-groups) tab. +Selecting **Manage Groups** options, , the [Manage Groups](managing-groups.md#managing-groups) tab opens: + + diff --git a/docs/user-guide/resources-properties.md b/docs/user-guide/resources-properties.md index 2ec984158c1..c8df6080362 100644 --- a/docs/user-guide/resources-properties.md +++ b/docs/user-guide/resources-properties.md @@ -44,25 +44,36 @@ Admin users can also see who created and modified the resource. An example in th ## Permission rules -From the **Permissions** tab, the users with the necessary permissions can set one or more permission rules in order to allow a group to access the resource. In particular it is possible to choose between a particular group of authenticated users or the *everyone* group that includes all authenticated users but also anonymous users (more information about different user types can be found in [Homepage](home-page.md#home-page) section).
-Moreover it is possible to choose between two different ways with which the selected group can approach the resource: +From the **Permissions** tab, users with the necessary permissions can define one or more rules to grant access to a resource for a specific group or IP range. In particular, it is possible to: + +* Select a specific group of authenticated users, or the *everyone* group, which includes all authenticated users as well as anonymous users (for more information about different user types, see the [Homepage](home-page.md#home-page) section). + +* Grant access to a specific IP range. + +Moreover, it is possible to choose between two different permission types with which the selected group/s or IP range/s can access the resource: * *View* the resource and save a copy -* *Edit* the resource and re-save it +* *Edit* the resource and save it -In order to add a rule, the user can click the **Add Permissions** button that open a **Groups** pop-up in witch the user can add to the *Permissions Groups* list a group through the button. +In order to add a rule, the user can click the **Add Permissions** button that open a permissions pop-up in witch the user can: + +* From the **Groups** tab, select a group from the list and add it using the button -Once the selected group is on the *Permissions Groups* list, the user can choose between *View* and *Edit* permission option. +* From the **IP Ranges** tab, select an IP range from the list and add it using the button + + + +Once the rule is on the permissions list, the user can choose between *View* and *Edit* permission option. Once a rule is set, the user can always remove it from the list through the **Remove** button or save the changes made using the **Save** button.
!!! note - How to manage users and groups is a topic present in the [Managing Users](managing-users.md#managing-users) and [Managing Groups](managing-groups.md#managing-groups) sections. + Instructions on managing users, groups or IP ranges can be found in the [Managing Users](managing-users.md#managing-users), [Managing Groups](managing-groups.md#managing-groups) and [Managing IP Ranges](ip-ranges.md) sections. ## Details diff --git a/docs/user-guide/rule-manager.md b/docs/user-guide/rule-manager.md new file mode 100644 index 00000000000..5ebfd4f85c1 --- /dev/null +++ b/docs/user-guide/rule-manager.md @@ -0,0 +1,118 @@ +# Managing Access Rules + +******************* + +In [MapStore](https://mapstore.geosolutionsgroup.com/mapstore/#/), the **Rules Manager** is an administrative tool designed to manage [GeoFence](https://docs.geoserver.org/main/en/user/extensions/geofence/index.html) authorization rules on data published in GeoServer by providing a security control method for restricting access to [GeoServer](https://geoserver.org/) *Workspaces*, *Layers* and/or *Services*. Admin users can create and assign one or more authorization rules to allow or deny access to a specific *User*, *Group of users*, *IP Range* and more, with a high level of granularity. + +As an admin user, it is possible to manage **Rules** for different **GeoServer Instances** by selecting the **Managing Access Rules** option from the button on the [Homepage](home-page.md#home-page): + + + +!!! Warning + The Rules Manager must be installed following the instructions of the [rules manager page setup](https://mapstore.geosolutionsgroup.com/mapstore/docs/api/framework#pages.RulesManager) to be available in the MapStore UI. + +## Manage Rules + +The *Rules Manager* page opens on the **Rules** tab, where the admin user can view all the available rules: + + + +To **Add a Rule**, the user can click the button. A panel will open, allowing the user to create a new rule by providing the following information: + + + +* Select the **GS Instance**: choose from the available GeoServer instances + +!!! Warning + The *GS Instance* option is available if more than one instance is configured, see the [GeoServer Instances](rule-manager.md#geoserver-instances) section. To make the Rule Manager work, it is necessary to have the [MapStore/GeoServer user integration](../developer-guide/integrations/geoserver.md) properly set up in one of the available methods (see also the Users integration section such as [LDAP/AD](../developer-guide/integrations/users/ldap.md), [OIDC](../developer-guide/integrations/users/openId.md) or [Keycloak](../developer-guide/integrations/users/keycloak.md)). + +* Select the **Role**: choose from the user groups available in MapStore. + +* Select the **User**: choose from the users registered in MapStore. + +* Add **IP ranges**: specify the IP ranges to grant access to the service, workspace, and/or layer. + +* Select the **Service**: choose between`WFS` or `WMS` + +* Select the **Request**: choose from `DescribeLayer`, `GetCapabilities`, `GetFeatureInfo`, `GetLegendGraphic`, `GetMap` and `GetStyles` + +* Select the **Workspace**: choose from the workspaces available on the selected GeoServer instance. + +* Select the **Layer**: choose from the layers available on the selected GeoServer instance. + +* Add the **Validity Period**: specify the start and end dates for access to the service, workspace, and/or layer. The rule thus defined is be applied at run time only within the date period defined. + +* Select the **Access** type: choose between `ALLOW` o `DENY` + +Once all the rule information has been configured, it can be saved by clicking the button. The rule will be displayed in the rules list according to the assigned priority. + +The *Priority* of a rule can be modified in two ways: + +* Drag and drop the rule directly within the list + +* Edit via panel: select the rule and click on button. The Priority option will appear in the panel and can be changed by entering the new value in the dedicated box. + + + +!!! Warning + As a general behavior, rules are evaluated according to their priority (from top to bottom of the list): the first one matching the condition will be applied (see also the GeoFence official [documentation page](https://github.com/geoserver/geofence/wiki/Rule-matching) online). + +From the *Rule* panel, by adding the *WorkSpace* and the *Layer*, the user can also manage the auth rule with a finest granularity (the *Rule Details*) by specifying if it should be applied to a specific **Style**, to layer features matching a predefined **Filters** and/or to layer **Attributes**. + +!!! Warning + The *Rules Details* are currently supported when MapStore is connected to a standalone GeoFence. For a GeoFence embedded in GeoServer the support is still missing due to different REST APIs involved. + + + +### Managing Layer Access Style + +By selecting the **Style** tab, the user can choose which styles of the layer can be displayed within the rule. The user can select between the *Default Style* and the *Styles Available* for that layer in GeoServer by using the button. + + + +### Managing Layer Access Filters + +By selecting the **Filters** tab, the user can apply access rules to the layer by adding one of the following filters: + + + +* **CQL Filter Read Rules**: allows defining a CQL filter to control which features or records of the layer can be read. + +* **CQL Filter Write Rules**: allows defining a CQL filter to control which features or records of the layer can be edited. + +* **Area of Interest**: allows filtering the layer by selecting a specific area using the MapStore viewer: only layer features within the defined areas are affected by the permission rule. + +### Managing Layer Access Attribute Table + +In the **Attribute Table** tab, the list of attributes of the layer is displayed. For each field, the user can select the access mode, choosing between: `NAME`, `READ ONLY`, and `READ WRITE`. + + + +### Filter Rules + +It is possible to filter the rules for each category by selecting the available options from the corresponding dropdown menu. + + + +Additionally, for each category, it is possible to display only the rules that explicitly include the selected option by clicking the corresponding checkbox. + + + +## GeoServer Instances + +From the *Rules Manager* page, it is also possible to configure more than one *GeoServer Instance* on which to create access rules. To do this, the administrator can click on the **GS Instances** tab. Here, the admin user can view all the configured GeoServer instances: + + + +!!! Warning + Multiple GeoServer instances can be configured only with a standalone GeoFence running in background. In case a GeoFence embedded in GeoServer is used, only one single instance can be supported by the MapStore Rule Manager. + +To **Add a GeoServer Instance**, the user can click the button. A panel will open, allowing the user to add a new instance by providing the following information: + + + +* Enter a **GS Name** and a **GS Description** + +* Add the **GS URL** + +* Enter the GeoServer instance **Username** and the **Password** diff --git a/docs/user-guide/tags.md b/docs/user-guide/tags.md index ae79beaf896..8a44414d435 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 fbd9a02db3f..b8220b31992 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/pom.xml b/java/pom.xml index ed92c28664f..e6da6d89cd0 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-root - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore diff --git a/java/printing/pom.xml b/java/printing/pom.xml index 740e49324f7..ad91fd23caa 100644 --- a/java/printing/pom.xml +++ b/java/printing/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-java - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore diff --git a/java/services/pom.xml b/java/services/pom.xml index a75312b32b3..49800b67bf3 100644 --- a/java/services/pom.xml +++ b/java/services/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-java - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore @@ -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/services/src/main/java/it/geosolutions/mapstore/filters/NoCacheFilter.java b/java/services/src/main/java/it/geosolutions/mapstore/filters/NoCacheFilter.java new file mode 100644 index 00000000000..d59f85e34a4 --- /dev/null +++ b/java/services/src/main/java/it/geosolutions/mapstore/filters/NoCacheFilter.java @@ -0,0 +1,25 @@ +package it.geosolutions.mapstore.filters; +import javax.servlet.*; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class NoCacheFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); // HTTP 1.1 + httpResponse.setHeader("Pragma", "no-cache"); // HTTP 1.0 + httpResponse.setDateHeader("Expires", 0); // Proxies + + chain.doFilter(request, response); + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { } + + @Override + public void destroy() { } +} diff --git a/java/services/src/test/java/it/geosolutions/mapstore/filters/NoCacheFilterTest.java b/java/services/src/test/java/it/geosolutions/mapstore/filters/NoCacheFilterTest.java new file mode 100644 index 00000000000..6807524b1b6 --- /dev/null +++ b/java/services/src/test/java/it/geosolutions/mapstore/filters/NoCacheFilterTest.java @@ -0,0 +1,63 @@ +package it.geosolutions.mapstore.filters; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletResponse; + +import static org.mockito.Mockito.*; + +import static org.junit.Assert.fail; + +public class NoCacheFilterTest { + + private NoCacheFilter filter; + + @Before + public void setUp() { + filter = new NoCacheFilter(); + } + + @Test + public void testDoFilterSetsNoCacheHeaders() throws IOException, ServletException { + ServletRequest request = mock(ServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(response).setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + verify(response).setHeader("Pragma", "no-cache"); + verify(response).setDateHeader("Expires", 0L); + + verify(chain).doFilter(request, response); + } + + @Test + public void testDoFilterSetsHeadersWhenChainThrowsException() throws IOException { + ServletRequest request = mock(ServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + try { + doThrow(new ServletException("chain failure")).when(chain).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + filter.doFilter(request, response, chain); + fail("Expected ServletException to be thrown"); + } catch (ServletException e) { + // verify headers were set before the exception from the chain + verify(response).setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + verify(response).setHeader("Pragma", "no-cache"); + verify(response).setDateHeader("Expires", 0L); + + try { + verify(chain).doFilter(request, response); + } catch (ServletException ignored) { + // verification may declare ServletException; ignore since we're already handling it + } + } + } +} diff --git a/java/web/pom.xml b/java/web/pom.xml index 957260f4193..0a9ef32de3b 100644 --- a/java/web/pom.xml +++ b/java/web/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-java - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore @@ -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/java/web/src/main/webapp/WEB-INF/web.xml b/java/web/src/main/webapp/WEB-INF/web.xml index e915e2c1c3b..5da51060fce 100644 --- a/java/web/src/main/webapp/WEB-INF/web.xml +++ b/java/web/src/main/webapp/WEB-INF/web.xml @@ -44,7 +44,6 @@ /* - springSecurityFilterChain @@ -72,6 +71,27 @@ CompressionFilter *.js + + + noCacheFilter + it.geosolutions.mapstore.filters.NoCacheFilter + + + noCacheFilter + / + + + noCacheFilter + *.html + + + noCacheFilter + *.json + + + noCacheFilter + *.txt + diff --git a/mkdocs.yml b/mkdocs.yml index 622ecf43afd..3672e1f959c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,8 @@ nav: - Managing Users: 'user-guide/managing-users.md' - Managing Groups: 'user-guide/managing-groups.md' - Managing Tags: 'user-guide/tags.md' + - Managing IP Ranges: 'user-guide/ip-ranges.md' + - Managing Access Rules: 'user-guide/rule-manager.md' - Managing Resources: - Resources Properties: 'user-guide/resources-properties.md' - Resources Top Toolbar: 'user-guide/top-bar.md' @@ -94,6 +96,8 @@ nav: - Street View: 'user-guide/street-view.md' - Longitudinal Profile: 'user-guide/longitudinal-profile.md' - GeoProcessing Tool: 'user-guide/geoprocessing-tool.md' + - Itinerary Tool: 'user-guide/itinerary.md' + - Isochrone Tool: 'user-guide/isochrone.md' - Navigation Toolbar: 'user-guide/navigation-toolbar.md' - Background Selector: 'user-guide/background.md' - Timeline: 'user-guide/timeline.md' diff --git a/package.json b/package.json index c382cee8b16..5eb40f65465 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapstore2", - "version": "0.11.0", + "version": "0.12.0", "description": "MapStore 2", "repository": "https://github.com/geosolutions-it/MapStore2", "main": "index.js", @@ -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", @@ -64,7 +63,6 @@ "eslint-plugin-react": "3.16.1", "expect": "1.20.1", "file-loader": "2.0.0", - "glob": "7.1.1", "html-loader": "2.0.0", "html-webpack-plugin": "5.2.0", "karma": "6.4.0", @@ -133,6 +131,7 @@ "@turf/point-on-surface": "4.1.0", "@turf/polygon-to-linestring": "4.1.0", "@znemz/cesium-navigation": "4.0.0", + "ajv": "8.17.1", "assert": "2.0.0", "axios": "0.30.2", "@babel/standalone": "7.23.9", @@ -280,9 +279,9 @@ "doc": "npm run jsdoc:build", "cleandoc": "npm run jsdoc:clean", "doctest": "npm run jsdoc:test", - "jsdoc:build": "npm run jsdoc:check && docma -c build/docma-config.json --dest web/docs", + "jsdoc:build": "NODE_OPTIONS=\"--no-deprecation\" npm run jsdoc:check && docma -c build/docma-config.json --dest web/docs", "jsdoc:clean": "premove web/docs && premove web/client/mapstore/docs", - "jsdoc:test": "docma -c build/docma-config.json --dest web/client/mapstore/docs && echo documentation is accessible from the mapstore/docs path when running npm start", + "jsdoc:test": "NODE_OPTIONS=\"--no-deprecation\" docma -c build/docma-config.json --dest web/client/mapstore/docs && echo documentation is accessible from the mapstore/docs path when running npm start", "jsdoc:check": "node ./utility/doc/jsDocConfigCheck.js", "jsdoc:update": "node ./utility/doc/jsDocConfigUpdate.js", "doc:build": "mkdocs build", diff --git a/pom.xml b/pom.xml index ab8a530e1ad..a445328ab84 100644 --- a/pom.xml +++ b/pom.xml @@ -1,13 +1,10 @@ - + 4.0.0 it.geosolutions.mapstore mapstore-root pom - 1.10-SNAPSHOT + 1.11-SNAPSHOT MapStore Root @@ -47,7 +44,7 @@ 1.10.2 - 2.4-SNAPSHOT + 2.5-SNAPSHOT 2.3.4 1.6-SNAPSHOT @@ -397,10 +394,10 @@
- + geosolutions - ftp://maven.geo-solutions.it/ + sftp://maven.geo-solutions.it/ @@ -467,8 +464,8 @@ org.apache.maven.wagon - wagon-ftp - 1.0-beta-2 + wagon-ssh + 3.5.3 diff --git a/product/pom.xml b/product/pom.xml index ec3ce9eb018..1f4ceef8486 100644 --- a/product/pom.xml +++ b/product/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-root - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore mapstore-product diff --git a/project/standard/templates/pom.xml b/project/standard/templates/pom.xml index 43e6947a92d..cd05d07a20e 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 + 2.5-SNAPSHOT 1.6-SNAPSHOT - 2.3.3 + 2.3.4 diff --git a/project/standard/templates/web/src/main/webapp/WEB-INF/web.xml b/project/standard/templates/web/src/main/webapp/WEB-INF/web.xml index 5eab3ee83b2..03b4a380c06 100644 --- a/project/standard/templates/web/src/main/webapp/WEB-INF/web.xml +++ b/project/standard/templates/web/src/main/webapp/WEB-INF/web.xml @@ -72,6 +72,27 @@ *.js + + + noCacheFilter + it.geosolutions.mapstore.filters.NoCacheFilter + + + noCacheFilter + / + + + noCacheFilter + *.html + + + noCacheFilter + *.json + + + noCacheFilter + *.txt + diff --git a/web/client/actions/security.js b/web/client/actions/security.js index 25522cef465..1c696350d70 100644 --- a/web/client/actions/security.js +++ b/web/client/actions/security.js @@ -14,7 +14,6 @@ import AuthenticationAPI from '../api/GeoStoreDAO'; import {setCredentials, getToken, getRefreshToken} from '../utils/SecurityUtils'; import {encodeUTF8} from '../utils/EncodeUtils'; - export const CHECK_LOGGED_USER = 'CHECK_LOGGED_USER'; export const LOGIN_SUBMIT = 'LOGIN_SUBMIT'; export const LOGIN_PROMPT_CLOSED = "LOGIN:LOGIN_PROMPT_CLOSED"; @@ -34,6 +33,11 @@ export const SET_CREDENTIALS = 'SECURITY:SET_CREDENTIALS'; export const CLEAR_SECURITY = 'SECURITY:CLEAR_SECURITY'; export const SET_PROTECTED_SERVICES = 'SECURITY:SET_PROTECTED_SERVICES'; export const REFRESH_SECURITY_LAYERS = 'SECURITY:REFRESH_SECURITY_LAYERS'; + +export const UPDATE_REQUESTS_RULES = 'SECURITY:UPDATE_REQUESTS_RULES'; +export const LOAD_REQUESTS_RULES = 'SECURITY:LOAD_REQUESTS_RULES'; +export const LOAD_REQUESTS_RULES_ERROR = 'SECURITY:LOAD_REQUESTS_RULES_ERROR'; + export function loginSuccess(userDetails, username, password, authProvider) { return { type: LOGIN_SUCCESS, @@ -229,3 +233,36 @@ export function refreshSecurityLayers() { type: REFRESH_SECURITY_LAYERS }; } + +/** + * Updates the request configuration rules + * @param {Array} rules - Array of request configuration rules + * @param {boolean} enabled - Whether request configuration is enabled + */ +export const updateRequestsRules = (rules) => { + return { + type: UPDATE_REQUESTS_RULES, + rules + }; +}; + +/** + * Starts loading request configuration rules + */ +export const loadRequestsRules = (rules) => { + return { + type: LOAD_REQUESTS_RULES, + rules + }; +}; + +/** + * Error loading request configuration rules + * @param {Error} error - The error that occurred + */ +export const loadRequestsRulesError = (error) => { + return { + type: LOAD_REQUESTS_RULES_ERROR, + error + }; +}; diff --git a/web/client/actions/widgets.js b/web/client/actions/widgets.js index ae76e2c2b92..efcf27eee51 100644 --- a/web/client/actions/widgets.js +++ b/web/client/actions/widgets.js @@ -44,6 +44,9 @@ export const TOGGLE_COLLAPSE_ALL = "WIDGET:TOGGLE_COLLAPSE_ALL"; export const TOGGLE_MAXIMIZE = "WIDGET:TOGGLE_MAXIMIZE"; export const TOGGLE_TRAY = "WIDGET:TOGGLE_TRAY"; +export const REPLACE_LAYOUT_VIEW = "WIDGET:REPLACE_LAYOUT_VIEW"; +export const SET_SELECTED_LAYOUT_VIEW_ID = "WIDGET:SET_SELECTED_LAYOUT_VIEW_ID"; + /** * Intent to create a new Widgets * @param {object} widget The widget template to start with @@ -320,3 +323,28 @@ export const toggleMaximize = (widget, target = DEFAULT_TARGET) => ({ * @param {boolean} value true the tray is present, false if it is not present */ export const toggleTray = value => ({ type: TOGGLE_TRAY, value}); + + +/** + * Add a layouts in the provided target + * @param {object} layouts The layouts to replace + * @param {string} [target=floating] the target container of the layouts + * @return {object} action with type `WIDGETS:REPLACE_LAYOUT_VIEW`, the layouts and the target + */ +export const replaceLayoutView = (layouts, target = DEFAULT_TARGET) => ({ + type: REPLACE_LAYOUT_VIEW, + target, + layouts +}); + +/** + * Set the layouts view ID that is selected + * @param {object} viewId The layout view ID + * @param {string} [target=floating] the target container of the layouts + * @return {object} action with type `WIDGETS:SET_SELECTED_LAYOUT_VIEW_ID`, the layout view ID and the target + */ +export const setSelectedLayoutViewId = (viewId, target = DEFAULT_TARGET) => ({ + type: SET_SELECTED_LAYOUT_VIEW_ID, + target, + viewId +}); diff --git a/web/client/api/ArcGIS.js b/web/client/api/ArcGIS.js index 5f0a5b042a4..ff3e994863a 100644 --- a/web/client/api/ArcGIS.js +++ b/web/client/api/ArcGIS.js @@ -6,7 +6,6 @@ * LICENSE file in the root directory of this source tree. */ -import { getAuthorizationBasic } from '../utils/SecurityUtils'; import axios from '../libs/ajax'; import { reprojectBbox } from '../utils/CoordinatesUtils'; import trimEnd from 'lodash/trimEnd'; @@ -89,14 +88,13 @@ export const searchAndPaginate = (records, params) => { }; const getData = (url, params = {}) => { const protectedId = params?.info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); const request = _cache[url] ? () => Promise.resolve(_cache[url]) : () => axios.get(url, { params: { f: 'json' }, - headers + _msAuthSourceId: protectedId }).then(({ data }) => { _cache[url] = data; return data; diff --git a/web/client/api/CSW.js b/web/client/api/CSW.js index 9613fe29cf2..bc4b3859a4d 100644 --- a/web/client/api/CSW.js +++ b/web/client/api/CSW.js @@ -16,7 +16,6 @@ import { extractCrsFromURN, makeBboxFromOWS, makeNumericEPSG, getExtentFromNorma import WMS from "../api/WMS"; import { THREE_D_TILES, getCapabilities } from './ThreeDTiles'; import { getDefaultUrl } from '../utils/URLUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; export const parseUrl = (url) => { const parsed = urlUtil.parse(getDefaultUrl(url), true); @@ -513,12 +512,11 @@ const Api = { getRecords: function(url, startPosition, maxRecords, text, options) { const body = constructXMLBody(startPosition, maxRecords, text, options); const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); return axios.post(parseUrl(url), body, { headers: { - 'Content-Type': 'application/xml', - ...headers - } + 'Content-Type': 'application/xml' + }, + _msAuthSourceId: protectedId }).then((response) => { const { error, _dcRef, result } = parseCSWResponse(response) || {}; if (result) { diff --git a/web/client/api/GeoStoreDAO.js b/web/client/api/GeoStoreDAO.js index 60bcbcf4708..3904afdae00 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/api/TMS.js b/web/client/api/TMS.js index f80c5948038..9d71c4ee9c3 100644 --- a/web/client/api/TMS.js +++ b/web/client/api/TMS.js @@ -7,7 +7,6 @@ */ import xml2js from 'xml2js'; import axios from '../libs/ajax'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; /** * Common requests to TMS services. @@ -21,8 +20,7 @@ import { getAuthorizationBasic } from '../utils/SecurityUtils'; */ export const getTileMap = (url, options) => { const protectedId = options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(url, {headers}) + return axios.get(url, {_msAuthSourceId: protectedId}) .then(response => { return new Promise((resolve) => { xml2js.parseString(response.data, { explicitArray: false }, (ignore, result) => resolve(result)); diff --git a/web/client/api/ThreeDTiles.js b/web/client/api/ThreeDTiles.js index a0b5ffc1830..467d5f26169 100644 --- a/web/client/api/ThreeDTiles.js +++ b/web/client/api/ThreeDTiles.js @@ -10,7 +10,6 @@ import axios from '../libs/ajax'; import { convertRadianToDegrees } from '../utils/CoordinatesUtils'; import { METERS_PER_UNIT } from '../utils/MapUtils'; import { logError } from '../utils/DebugUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; // converts the boundingVolume of the root tileset to a valid layer bbox function tilesetToBoundingBox(Cesium, tileset) { @@ -140,8 +139,7 @@ function extractCapabilities(tileset) { */ export const getCapabilities = (url, info) => { const protectedId = info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(url, {headers}) + return axios.get(url, {_msAuthSourceId: protectedId}) .then(({ data }) => { return extractCapabilities(data).then((properties) => ({ tileset: data, ...properties })); }).catch((e) => { diff --git a/web/client/api/WFS.js b/web/client/api/WFS.js index 250d2a1aebc..6370be114cf 100644 --- a/web/client/api/WFS.js +++ b/web/client/api/WFS.js @@ -14,7 +14,6 @@ import {toOGCFilterParts} from '../utils/FilterUtils'; import { getDefaultUrl } from '../utils/URLUtils'; import { castArray } from 'lodash'; import { isValidGetFeatureInfoFormat } from '../utils/WMSUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const capabilitiesCache = {}; @@ -140,8 +139,7 @@ export const getCapabilities = function(url, info) { return Promise.resolve(cached.data); } const protectedId = info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(getCapabilitiesURL(url, {headers})) + return axios.get(getCapabilitiesURL(url), {_msAuthSourceId: protectedId}) .then((response) => { let json; xml2js.parseString(response.data, { explicitArray: false, stripPrefix: true }, (ignore, result) => { diff --git a/web/client/api/WMS.js b/web/client/api/WMS.js index b6b9def38a1..faf4998ac89 100644 --- a/web/client/api/WMS.js +++ b/web/client/api/WMS.js @@ -13,7 +13,6 @@ import axios from '../libs/ajax'; import { getConfigProp } from '../utils/ConfigUtils'; import { getWMSBoundingBox } from '../utils/CoordinatesUtils'; import { isValidGetMapFormat, isValidGetFeatureInfoFormat } from '../utils/WMSUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const capabilitiesCache = {}; export const WMS_GET_CAPABILITIES_VERSION = '1.3.0'; @@ -160,12 +159,12 @@ export const getDimensions = (layer) => { * - `Capability`: capability object that contains layers and requests formats * - `Service`: service information object */ -export const getCapabilities = (url, headers = {}) => { +export const getCapabilities = (url, {headers, params, _msAuthSourceId} = {}) => { return axios.get(parseUrl(url, { service: "WMS", version: WMS_GET_CAPABILITIES_VERSION, request: "GetCapabilities" - }), {headers}).then((response) => { + }), {headers, params, _msAuthSourceId}).then((response) => { let json; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { json = result; @@ -204,8 +203,7 @@ export const getRecords = (url, startPosition, maxRecords, text, options) => { }); } const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return getCapabilities(url, headers) + return getCapabilities(url, {_msAuthSourceId: protectedId}) .then((json) => { capabilitiesCache[url] = { timestamp: new Date().getTime(), @@ -215,13 +213,12 @@ export const getRecords = (url, startPosition, maxRecords, text, options) => { }); }; export const describeLayers = (url, layers, security) => { - const headers = getAuthorizationBasic(security?.sourceId); return axios.get(parseUrl(url, { service: "WMS", version: WMS_DESCRIBE_LAYER_VERSION, layers: layers, request: "DescribeLayer" - }), {headers}).then((response) => { + }), {_msAuthSourceId: security?.sourceId}).then((response) => { let descriptions; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { descriptions = result && result.WMS_DescribeLayerResponse && result.WMS_DescribeLayerResponse.LayerDescription; diff --git a/web/client/api/WMTS.js b/web/client/api/WMTS.js index a59c3d74f05..ce05b95ccd1 100644 --- a/web/client/api/WMTS.js +++ b/web/client/api/WMTS.js @@ -24,7 +24,6 @@ import { getDefaultStyleIdentifier, getDefaultFormat } from '../utils/WMTSUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; export const parseUrl = (url) => { const parsed = urlUtil.parse(getDefaultUrl(url), true); @@ -82,8 +81,7 @@ const Api = { }); } const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(parseUrl(url), {headers}).then((response) => { + return axios.get(parseUrl(url), {_msAuthSourceId: protectedId}).then((response) => { let json; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { json = result; @@ -106,8 +104,7 @@ const Api = { }); } const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(parseUrl(url), {headers}).then((response) => { + return axios.get(parseUrl(url), {_msAuthSourceId: protectedId}).then((response) => { let json; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { json = result; diff --git a/web/client/api/catalog/TMS_1_0_0.js b/web/client/api/catalog/TMS_1_0_0.js index adb57e95ad2..e16fecf0d40 100644 --- a/web/client/api/catalog/TMS_1_0_0.js +++ b/web/client/api/catalog/TMS_1_0_0.js @@ -9,7 +9,7 @@ import ConfigUtils from '../../utils/ConfigUtils'; import xml2js from 'xml2js'; import axios from '../../libs/ajax'; import { get, castArray } from 'lodash'; -import { cleanAuthParamsFromURL, getAuthorizationBasic } from '../../utils/SecurityUtils'; +import { cleanAuthParamsFromURL } from '../../utils/SecurityUtils'; import { guessFormat } from '../../utils/TMSUtils'; const capabilitiesCache = {}; @@ -55,8 +55,7 @@ export const getRecords = (url, startPosition, maxRecords, text, info) => { }); } const protectedId = info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(url, {headers} ).then((response) => { + return axios.get(url, {_msAuthSourceId: protectedId}).then((response) => { let json; xml2js.parseString(response.data, { explicitArray: false }, (ignore, result) => { json = { ...result, url }; diff --git a/web/client/components/I18N/IntlNumberFormControl.jsx b/web/client/components/I18N/IntlNumberFormControl.jsx index 8dfea3fd98c..729ff162d00 100644 --- a/web/client/components/I18N/IntlNumberFormControl.jsx +++ b/web/client/components/I18N/IntlNumberFormControl.jsx @@ -137,7 +137,7 @@ class IntlNumberFormControl extends React.Component { parse = value => { let formatValue = value; // eslint-disable-next-line use-isnan - if (formatValue !== NaN && formatValue !== "NaN") { // Allow locale string to parse + if (formatValue !== '' && formatValue !== NaN && formatValue !== "NaN") { // Allow locale string to parse const locale = this.context && this.context.intl && this.context.intl.locale || "en-US"; const format = new Intl.NumberFormat(locale); const parts = format.formatToParts(12345.6); @@ -164,7 +164,7 @@ class IntlNumberFormControl extends React.Component { }; format = val => { - if (!isNaN(val) && val !== "NaN") { + if (val !== '' && !isNaN(val) && val !== "NaN") { const locale = this.context && this.context.intl && this.context.intl.locale || "en-US"; const formatter = new Intl.NumberFormat(locale, {minimumFractionDigits: 0, maximumFractionDigits: 20}); return formatter.format(val); diff --git a/web/client/components/I18N/__tests__/IntlNumberFormatControl-test.jsx b/web/client/components/I18N/__tests__/IntlNumberFormatControl-test.jsx index 5075959141a..3ee2b0f0615 100644 --- a/web/client/components/I18N/__tests__/IntlNumberFormatControl-test.jsx +++ b/web/client/components/I18N/__tests__/IntlNumberFormatControl-test.jsx @@ -63,18 +63,18 @@ describe('IntlNumberFormControl', () => { expect(elements[0].value).toBe("1.899,01"); }); - it('checks if the component renders value in IT locale', () => { + it('checks if the component renders value in IT locale for ten thousands', () => { // see https://unicode-org.atlassian.net/browse/CLDR-18213 const intl = {locale: "it-IT"}; let formProps = { name: "name", - value: 1899.01 + value: 12899.01 }; const InputIntl = intlNumberFormControlWithContext(intl); const cmp = ReactDOM.render( , document.getElementById("container")); expect(cmp).toExist(); const elements = document.querySelectorAll('input'); - expect(elements[0].value).toBe("1.899,01"); + expect(elements[0].value).toBe("12.899,01"); }); it('checks if the component renders value in FR locale', () => { diff --git a/web/client/components/TOC/TOCItemsSettings.jsx b/web/client/components/TOC/TOCItemsSettings.jsx index bc7b9668758..f43c4a4dc88 100644 --- a/web/client/components/TOC/TOCItemsSettings.jsx +++ b/web/client/components/TOC/TOCItemsSettings.jsx @@ -51,7 +51,8 @@ const TOCItemSettings = (props) => { position = 'left', tabs = [], tabsConfig = {}, - isLocalizedLayerStylesEnabled = false + isLocalizedLayerStylesEnabled = false, + hideCloseButton = false } = props; @@ -83,6 +84,7 @@ const TOCItemSettings = (props) => { dock={dock} draggable={draggable} position={position} + hideCloseButton={hideCloseButton} header={[ diff --git a/web/client/components/TOC/__tests__/TOCItemsSettings-test.jsx b/web/client/components/TOC/__tests__/TOCItemsSettings-test.jsx index 753d1d45189..132feb99459 100644 --- a/web/client/components/TOC/__tests__/TOCItemsSettings-test.jsx +++ b/web/client/components/TOC/__tests__/TOCItemsSettings-test.jsx @@ -194,4 +194,23 @@ describe("test TOCItemsSettings", () => { const customToolbar = document.querySelector('.custom-toolbar'); expect(customToolbar).toExist(); }); + + it('test hideCloseButton configuration', () => { + ReactDOM.render(
+ } + ]} + />, document.getElementById("container")); + const closeButton = document.querySelector('.ms-close'); + expect(closeButton).toNotExist(); + }); }); diff --git a/web/client/components/TOC/fragments/settings/ThreeDTilesSettings.jsx b/web/client/components/TOC/fragments/settings/ThreeDTilesSettings.jsx index 3f5aa9f99f5..5042c05c91b 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 27553553d6b..8353825d9d7 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/charts/WidgetChart.jsx b/web/client/components/charts/WidgetChart.jsx index 46a8997db82..fa9a47ef35b 100644 --- a/web/client/components/charts/WidgetChart.jsx +++ b/web/client/components/charts/WidgetChart.jsx @@ -293,16 +293,20 @@ const chartDataTypes = { }; }, line: ({ + id, data: dataProp, options, formula, // refers always to y - style, + style: styleProperty, name: traceName, yAxisOpts, xAxisOpts, tickPrefix: tickPrefixProp, format: formatProp, - tickSuffix: tickSuffixProp + tickSuffix: tickSuffixProp, + sortBy = 'groupBy', + classifyGeoJSON, + classificationDataKey }) => { const tickPrefix = tickPrefixProp || yAxisOpts?.tickPrefix; const format = formatProp || yAxisOpts?.format; @@ -310,12 +314,61 @@ const chartDataTypes = { const xDataKey = options?.groupByAttributes; const yDataKey = getAggregationAttributeDataKey(options); const data = formula ? processDataProperties(formula, yDataKey, dataProp) : dataProp; + const { + mode, + msMode, + msClassification, + ...style + } = styleProperty || {}; + + if (msMode === 'classification') { + const { sortByKey, classifiedData, classes } = generateClassifiedData({ + type: 'line', + sortBy, + data, + options, + msClassification, + classifyGeoJSON + }); + return classes.map(({ color, label: name }, idx) => { + const filteredData = classifiedData + .filter((entry) => entry.index === idx) + .sort((a, b) => a.properties[sortByKey] > b.properties[sortByKey] ? 1 : -1); + if (filteredData.length === 0) { + return null; + } + const text = traceName || classificationDataKey; + return { + mode: mode || 'lines', + legendgroup: `${id}-${classificationDataKey}`, + x: filteredData.map(({ properties }) => properties[xDataKey]), + y: filteredData.map(({ properties }) => properties[yDataKey]), + name, + legendgrouptitle: { text }, + hovertemplate: `${tickPrefix ?? ""}%{y:${format ?? 'd'}}${tickSuffix ?? ""}`, + line: { + ...(style?.line || {}), + color + }, + ...(style?.marker && { + marker: { + ...style.marker, + color, + ...(style.marker.line && { line: style.marker.line }) + } + }), + ...(xAxisOpts.xaxis && { xaxis: xAxisOpts.xaxis }), + ...(yAxisOpts.yaxis && { yaxis: yAxisOpts.yaxis }) + }; + }).filter(chart => chart !== null); + } + const name = traceName || yDataKey; const sortedData = [...data].sort((a, b) => a[xDataKey] > b[xDataKey] ? 1 : -1); const x = sortedData.map(d => d[xDataKey]); const y = sortedData.map(d => d[yDataKey]); return { - mode: 'lines', // default mode should be lines + mode: mode || 'lines', // default mode should be lines ...style, name, hovertemplate: `${tickPrefix ?? ""}%{y:${format ?? 'd'}}${tickSuffix ?? ""}`, // uses the format if passed, otherwise shows the full number. diff --git a/web/client/components/charts/__tests__/WidgetChart-test.js b/web/client/components/charts/__tests__/WidgetChart-test.js index 3c227614d3f..86d91fb1184 100644 --- a/web/client/components/charts/__tests__/WidgetChart-test.js +++ b/web/client/components/charts/__tests__/WidgetChart-test.js @@ -1014,4 +1014,176 @@ describe('Widget Chart: data conversions ', () => { expect(layout.xaxis.tickangle).toEqual('auto'); }); }); + + describe('Line chart with classification', () => { + const renderLineChartForClassification = ({ + dataset = DATASET_2, + options = { + classificationAttributeType: 'string' + }, + autoColorOptions = { + classification: LABELLED_CLASSIFICATION + }, + classifications = CLASSIFICATIONS + }) => { + const autoColorOptionsParams = { + defaultCustomColor: "#00ff00", + defaultClassLabel: "Default", + name: 'global.colors.custom', + ...autoColorOptions + }; + return toPlotly({ + type: 'line', + autoColorOptions: autoColorOptionsParams, + classifications, + options, + classifyGeoJSONSync, + ...dataset + }); + }; + describe('color coded/custom classified Line chart with absolute values', () => { + it('custom classified colors - using custom labels and colors only', () => { + const { data, layout } = renderLineChartForClassification({ + autoColorOptions: { classification: LABELLED_CLASSIFICATION } + }); + expect(data.length).toBe(2); + data.forEach((trace, i) => { + expect(trace.mode).toBe('lines'); + trace.y.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_2.data[i][j][SPLIT_DATASET_2.series[0].dataKey])); + trace.x.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_2.data[i][j][SPLIT_DATASET_2.xAxis.dataKey])); + const classLabel = LABELLED_CLASSIFICATION + .find(({ value }) => value === SPLIT_DATASET_2.data[i][0][CLASSIFICATIONS.dataKey])?.title; + expect(trace.name).toBe(classLabel); + const classColor = LABELLED_CLASSIFICATION + .find(({ value }) => value === SPLIT_DATASET_2.data[i][0][CLASSIFICATIONS.dataKey])?.color; + expect(trace.line.color).toBe(classColor); + }); + expect(layout.margin).toEqual({ t: 8, b: 8, l: 8, r: 8, pad: 4, autoexpand: true }); + }); + it('custom classified colors - using default labels and colors', () => { + const classification = UNLABELLED_CLASSIFICATION_3; + const { data, layout } = renderLineChartForClassification({ + dataset: DATASET_3, + autoColorOptions: { classification } + }); + expect(data.length).toBe(3); + data.forEach((trace, i) => { + expect(trace.mode).toBe('lines'); + trace.y.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_3.data[i][j][SPLIT_DATASET_3.series[0].dataKey])); + trace.x.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_3.data[i][j][SPLIT_DATASET_3.xAxis.dataKey])); + const classLabel = classification + .find(({ value }) => value === SPLIT_DATASET_3.data[i][0][CLASSIFICATIONS.dataKey])?.value ?? 'Default'; + expect(trace.name).toBe(classLabel); + const classColor = classification + .find(({ value }) => value === SPLIT_DATASET_3.data[i][0][CLASSIFICATIONS.dataKey])?.color ?? '#00ff00'; + expect(trace.line.color).toBe(classColor); + }); + expect(layout.margin).toEqual({ t: 8, b: 8, l: 8, r: 8, pad: 4, autoexpand: true }); + }); + it('custom classified colors - using default labels and colors, wrong order', () => { + const classification = UNLABELLED_CLASSIFICATION_5_ORDERED; + const { data, layout } = renderLineChartForClassification({ + dataset: DATASET_5_UNORDERED, + autoColorOptions: { classification } + }); + expect(data.length).toBe(3); + data.forEach((trace, i) => { + expect(trace.mode).toBe('lines'); + trace.y.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_5_ORDERED.data[i][j][SPLIT_DATASET_5_ORDERED.series[0].dataKey])); + trace.x.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_5_ORDERED.data[i][j][SPLIT_DATASET_5_ORDERED.xAxis.dataKey])); + const classLabel = classification + .find(({ value }) => value === SPLIT_DATASET_5_ORDERED.data[i][0][CLASSIFICATIONS.dataKey])?.value ?? 'Default'; + expect(trace.name).toBe(classLabel); + const classColor = classification + .find(({ value }) => value === SPLIT_DATASET_5_ORDERED.data[i][0][CLASSIFICATIONS.dataKey])?.color ?? '#00ff00'; + expect(trace.line.color).toBe(classColor); + }); + expect(layout.margin).toEqual({ t: 8, b: 8, l: 8, r: 8, pad: 4, autoexpand: true }); + }); + it('custom classified colors - using templatized labels and custom colors only - line charts', () => { + const { data, layout } = renderLineChartForClassification({ + autoColorOptions: { classification: TEMPLATE_LABELS_CLASSIFICATION } + }); + expect(data.length).toBe(2); + data.forEach((trace, i) => { + expect(trace.mode).toBe('lines'); + trace.y.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_2.data[i][j][SPLIT_DATASET_2.series[0].dataKey])); + trace.x.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_2.data[i][j][SPLIT_DATASET_2.xAxis.dataKey])); + const classLabel = TEMPLATE_LABELS_CLASSIFICATION + .find(({ value }) => value === SPLIT_DATASET_2.data[i][0][CLASSIFICATIONS.dataKey])?.title + ?.replace('${legendValue}', SPLIT_DATASET_2.series[0].dataKey); + expect(trace.name).toBe(classLabel); + const classColor = TEMPLATE_LABELS_CLASSIFICATION + .find(({ value }) => value === SPLIT_DATASET_2.data[i][0][CLASSIFICATIONS.dataKey])?.color; + expect(trace.line.color).toBe(classColor); + }); + expect(layout.margin).toEqual({ t: 8, b: 8, l: 8, r: 8, pad: 4, autoexpand: true }); + }); + }); + describe('color coded/custom classified Line chart with range values', () => { + it('custom classified colors - using custom labels and colors only', () => { + const { data, layout } = renderLineChartForClassification({ + dataset: DATASET_4, + options: { classificationAttributeType: 'number' }, + autoColorOptions: { rangeClassification: LABELLED_RANGE_CLASSIFICATION }, + classifications: RANGE_CLASSIFICATIONS + }); + expect(data.length).toBe(2); + data.forEach((trace, i) => { + expect(trace.mode).toBe('lines'); + trace.y.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_4.data[i][j][SPLIT_DATASET_4.series[0].dataKey])); + trace.x.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_4.data[i][j][SPLIT_DATASET_4.xAxis.dataKey])); + const classLabel = LABELLED_RANGE_CLASSIFICATION + .find(({ min, max }) => trace.y[0] >= min && trace.y[0] < max)?.title; + expect(trace.name).toBe(classLabel); + const classColor = LABELLED_RANGE_CLASSIFICATION + .find(({ min, max }) => trace.y[0] >= min && trace.y[0] < max)?.color; + expect(trace.line.color).toBe(classColor); + }); + expect(layout.margin).toEqual({ t: 8, b: 8, l: 8, r: 8, pad: 4, autoexpand: true }); + }); + it('custom classified colors - using default labels and colors', () => { + const { data, layout } = renderLineChartForClassification({ + dataset: DATASET_4, + options: { classificationAttributeType: 'number' }, + autoColorOptions: { rangeClassification: UNLABELLED_RANGE_CLASSIFICATION }, + classifications: RANGE_CLASSIFICATIONS + }); + expect(data.length).toBe(2); + data.forEach((trace, i) => { + expect(trace.mode).toBe('lines'); + trace.y.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_4.data[i][j][SPLIT_DATASET_4.series[0].dataKey])); + trace.x.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_4.data[i][j][SPLIT_DATASET_4.xAxis.dataKey])); + const labelMinValue = UNLABELLED_RANGE_CLASSIFICATION[i].min; + const labelMaxValue = UNLABELLED_RANGE_CLASSIFICATION[i].max; + const classLabel = `>= ${labelMinValue}
<${i === (data.length - 1) ? '=' : ''} ${labelMaxValue}`; + expect(trace.name).toBe(classLabel); + const classColor = UNLABELLED_RANGE_CLASSIFICATION[i].color; + expect(trace.line.color).toBe(classColor); + }); + expect(layout.margin).toEqual({ t: 8, b: 8, l: 8, r: 8, pad: 4, autoexpand: true }); + }); + it('custom classified colors - using templatized labels and custom colors only - line charts', () => { + const { data, layout } = renderLineChartForClassification({ + dataset: DATASET_4, + options: { classificationAttributeType: 'number' }, + autoColorOptions: { rangeClassification: TEMPLATE_LABELS_RANGE_CLASSIFICATION }, + classifications: RANGE_CLASSIFICATIONS + }); + expect(data.length).toBe(2); + data.forEach((trace, i) => { + expect(trace.mode).toBe('lines'); + trace.y.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_4.data[i][j][SPLIT_DATASET_4.series[0].dataKey])); + trace.x.forEach((v, j) => expect(v).toBe(SPLIT_DATASET_4.data[i][j][SPLIT_DATASET_4.xAxis.dataKey])); + const labelValues = LABELLED_RANGE_CLASSIFICATION[i].title; + const classAttributeLabel = RANGE_CLASSIFICATIONS.dataKey; + const classLabel = `${classAttributeLabel} - ${labelValues}`; + expect(trace.name).toBe(classLabel); + const classColor = TEMPLATE_LABELS_RANGE_CLASSIFICATION[i].color; + expect(trace.line.color).toBe(classColor); + }); + expect(layout.margin).toEqual({ t: 8, b: 8, l: 8, r: 8, pad: 4, autoexpand: true }); + }); + }); + }); }); diff --git a/web/client/components/contextcreator/ConfigurePluginsStep.jsx b/web/client/components/contextcreator/ConfigurePluginsStep.jsx index 93e28bcb167..504eeaa0ccd 100644 --- a/web/client/components/contextcreator/ConfigurePluginsStep.jsx +++ b/web/client/components/contextcreator/ConfigurePluginsStep.jsx @@ -83,6 +83,18 @@ const getAvailableTools = (plugin, onShowDialog, hideUploadExtension) => { }]; }; +const formatPluginTitle = (plugin) => { + var version = ''; + if (plugin.version && plugin.version !== 'undefined') { + version = ' (' + plugin.version + ')'; + } + return (plugin.title || plugin.label || plugin.name) + version; +}; + +const formatPluginDescription = (plugin) => { + return plugin.description || 'plugin name: ' + plugin.name; +}; + /** * Converts plugin objects to Transform items * @param {string} editedPlugin currently edited plugin @@ -128,9 +140,9 @@ const pluginsToItems = ({ const isMandatory = plugin.forcedMandatory || plugin.mandatory; return { id: plugin.name, - title: plugin.title || plugin.label || plugin.name, + title: formatPluginTitle(plugin), cardSize: 'sm', - description: plugin.description || 'plugin name: ' + plugin.name, + description: formatPluginDescription(plugin), showDescriptionTooltip, descriptionTooltipDelay, mandatory: isMandatory, diff --git a/web/client/components/dashboard/ConfigureView.jsx b/web/client/components/dashboard/ConfigureView.jsx new file mode 100644 index 00000000000..a14719fc26d --- /dev/null +++ b/web/client/components/dashboard/ConfigureView.jsx @@ -0,0 +1,68 @@ +import React, { useEffect, useState } from 'react'; +import Dialog from '../misc/Dialog'; +import { Button, ControlLabel, FormControl, FormGroup, Glyphicon } from 'react-bootstrap'; +import Message from '../I18N/Message'; +import ColorSelector from '../style/ColorSelector'; +import Portal from '../misc/Portal'; + +const ConfigureView = ({ active, onToggle, name, color, onSave }) => { + const [setting, setSetting] = useState({ name: null, color: null }); + useEffect(() => { + setSetting({ name, color }); + }, [name, color]); + + return active && ( + + + + + + +
+ + + { + const { value } = event.target || {}; + setSetting(prev => ({ ...prev, name: value })); + }} + /> + + + +
+ colorVal && setSetting(prev =>({ + ...prev, + color: colorVal + }))} + /> +
+
+
+
+ + +
+
+
+ ); +}; + +export default ConfigureView; diff --git a/web/client/components/dashboard/Dashboard.jsx b/web/client/components/dashboard/Dashboard.jsx index 5bd0024e547..143400a1d03 100644 --- a/web/client/components/dashboard/Dashboard.jsx +++ b/web/client/components/dashboard/Dashboard.jsx @@ -5,15 +5,15 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ - import React from 'react'; -import { compose, defaultProps, pure, withProps } from 'recompose'; +import { compose, defaultProps, pure, withProps, withStateHandlers, withHandlers } from 'recompose'; import Message from '../I18N/Message'; import { widthProvider } from '../layout/enhancers/gridLayout'; import emptyState from '../misc/enhancers/emptyState'; import withSelection from '../widgets/view/enhancers/withSelection'; -import WidgetsView from '../widgets/view/WidgetsView'; +import WidgetViewWrapper from './WidgetViewWrapper'; +import uuidv1 from 'uuid/v1'; const WIDGET_MOBILE_RIGHT_SPACE = 18; @@ -59,15 +59,64 @@ export default compose( ...maximizedProps }); }), + withStateHandlers( + ({ }) => ({ + viewConfigurationActive: false + }), + { + setViewConfigurationActive: () => (viewConfigurationActive) => ({ viewConfigurationActive }) + } + ), emptyState( - ({widgets = []} = {}) => widgets.length === 0, - ({loading}) => ({ - glyph: "dashboard", - title: loading ? : - }) + ({layouts = []} = {}) => (!layouts || layouts.length === 0), + ({loading, layouts = [], onLayoutViewReplace}) => { + const _layout = [{ + id: uuidv1(), + name: "Main view", + color: null + }]; + if (!layouts || layouts.length === 0) { + // Replace the layout view with default value + onLayoutViewReplace(_layout); + } + return { + glyph: "dashboard", + title: loading ? : + }; + } ), defaultProps({ isWidgetSelectable: () => true }), + // Intercept onLayoutChange to inspect and modify data + withHandlers({ + onLayoutChange: props => (layout, allLayouts) => { + const currentLayouts = Array.isArray(props.layouts) ? props.layouts : [props.layouts]; + + // This is updating an existing layout - allLayouts contains breakpoint data + const updatedLayouts = currentLayouts.map(l => { + if (l?.id && l?.id === props.selectedLayoutId) { + // allLayouts contains the grid data for all breakpoints (md, xxs, etc.) + // Merge this with the existing tabbed layout properties + return { + ...l, + ...allLayouts, + id: l.id, + name: l.name, + color: l.color + }; + } + return l; + }); + + // Call the original onLayoutChange if it exists + // Pass the updated tabbed layouts + if (props.onLayoutChange) { + props.onLayoutChange(layout, updatedLayouts); + } + + return { layout, allLayouts: updatedLayouts }; + } + }), withSelection -)(WidgetsView); +)(WidgetViewWrapper); diff --git a/web/client/components/dashboard/ViewSwitcher.jsx b/web/client/components/dashboard/ViewSwitcher.jsx new file mode 100644 index 00000000000..e3d442a0065 --- /dev/null +++ b/web/client/components/dashboard/ViewSwitcher.jsx @@ -0,0 +1,161 @@ +import React, { useRef, useState } from 'react'; +import { Button, Dropdown, Glyphicon, MenuItem } from 'react-bootstrap'; +import Message from '../I18N/Message'; +import withConfirm from '../misc/withConfirm'; +import FlexBox from '../layout/FlexBox'; +import useCheckScroll from './hooks/useCheckScroll'; + +const WithConfirmButton = withConfirm(MenuItem); + +const View = ({ handleSelect, isSelected, id, color, name, onRemove, onMove, canDelete, onConfigure, canMoveLeft, canMoveRight, canEdit }) => { + const [position, setPosition] = useState({ left: 0, bottom: 0, right: 0 }); + const toggleBtnRef = useRef(null); + + const handleToggleClick = () => { + handleSelect(id); + if (toggleBtnRef.current) { + const rect = toggleBtnRef.current.getBoundingClientRect(); + // align to right if there is space, otherwise align to left + if (window.innerWidth - rect.right < 200) { + setPosition({ + right: window.innerWidth - rect.right - 4, + bottom: window.innerHeight - rect.top + 10, + left: 'auto' + }); + } else { + setPosition({ + left: rect.left - 4, + bottom: window.innerHeight - rect.top + 10, + right: 'auto' + }); + } + } + }; + + return ( + + + {canEdit && ( + +
+ +
+
+ )} + + } + confirmContent={} + onClick={() => onRemove(id)} + disabled={!canDelete} + > + + + + { + onConfigure(); + }} + > + + + + { + onMove(id, 'right'); + }} + disabled={!canMoveRight} + > + + + + { + onMove(id, 'left'); + }} + disabled={!canMoveLeft} + > + + + + +
); +}; + +const ViewSwitcher = ({ layouts = [], selectedLayoutId, onSelect, onAdd, onRemove, onMove, onConfigure, canEdit }) => { + const handleSelect = (id) => { + onSelect?.(id); + }; + + const [scrollRef, showButtons, isLeftDisabled, isRightDisabled, scroll] = useCheckScroll({ data: layouts }); + + return ( + + {canEdit && ( + + )} + + {/* Layouts Tabs */} + + {layouts.map((layout, idx) => { + const id = layout.id || idx + 1; + return ( + 1} + canMoveRight={idx !== layouts.length - 1} + canMoveLeft={idx !== 0} + canEdit={canEdit} + /> + ); + })} + + {showButtons && ( + + + + + )} + + ); +}; + +export default ViewSwitcher; diff --git a/web/client/components/dashboard/WidgetViewWrapper.jsx b/web/client/components/dashboard/WidgetViewWrapper.jsx new file mode 100644 index 00000000000..35c3abd7a22 --- /dev/null +++ b/web/client/components/dashboard/WidgetViewWrapper.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import WidgetsView from '../widgets/view/WidgetsView'; +import ViewSwitcher from './ViewSwitcher'; +import uuidv1 from 'uuid/v1'; +import { getNextAvailableName } from '../../utils/WidgetsUtils'; +import ConfigureView from './ConfigureView'; +import FlexBox from '../layout/FlexBox'; + +const WidgetViewWrapper = props => { + const { + layouts = [], + onLayoutViewReplace, + selectedLayoutId, + onLayoutViewSelected, + viewConfigurationActive, + setViewConfigurationActive, + widgets = [], + onWidgetsReplace, + canEdit + } = props; + + const getSelectedLayout = () => { + if (Array.isArray(layouts)) { + return layouts.find(l => l?.id === selectedLayoutId) || {}; + } + // fallback for old object format + return layouts; + }; + + const handleSelectLayout = (id) => { + onLayoutViewSelected(id); + }; + + // strip out "properties" before passing + const selectedLayout = getSelectedLayout(); + const { id, name, color, order, ...layoutForWidgets } = selectedLayout; + + const filteredProps = {...props}; + if (props.widgets) { + filteredProps.widgets = props.widgets.filter(widget => widget.layoutId === selectedLayoutId); + } + + const handleAddLayout = () => { + const newLayout = { + id: uuidv1(), + name: getNextAvailableName(layouts), + color: null, + md: [], + xxs: [] + }; + const finalLayout = [...layouts, newLayout]; + onLayoutViewReplace?.(finalLayout); + onLayoutViewSelected(newLayout.id); + setViewConfigurationActive(true); + }; + + const handleRemoveLayout = (layoutId) => { + const updatedLayouts = layouts.filter(layout => layout.id !== layoutId); + onLayoutViewReplace(updatedLayouts); + onLayoutViewSelected(updatedLayouts?.[updatedLayouts.length - 1]?.id); + + const updatedWidgets = widgets.filter(w => w.layoutId !== layoutId); + onWidgetsReplace(updatedWidgets); + }; + + const handleMoveLayout = (layoutId, direction) => { + const index = layouts.findIndex(layout => layout.id === layoutId); + if (index === -1) return; // Layout not found + + // Clone the array to avoid mutating state directly + const updatedLayouts = [...layouts]; + + if (direction === "left" && index > 0) { + // Swap with the previous layout + [updatedLayouts[index - 1], updatedLayouts[index]] = [updatedLayouts[index], updatedLayouts[index - 1]]; + } else if (direction === "right" && index < updatedLayouts.length - 1) { + // Swap with the next layout + [updatedLayouts[index], updatedLayouts[index + 1]] = [updatedLayouts[index + 1], updatedLayouts[index]]; + } + onLayoutViewReplace(updatedLayouts); + }; + + const handleToggle = () => setViewConfigurationActive(false); + + const handleSave = (data) => { + const updatedLayouts = layouts.map(layout => layout.id === id + ? { ...layout, name: data.name, color: data.color } + : layout + ); + onLayoutViewReplace(updatedLayouts); + setViewConfigurationActive(false); + }; + + const layoutViews = Array.isArray(layouts) ? layouts : [layouts]; + + return ( + + + + + {(canEdit || layoutViews.length > 1) && ( + setViewConfigurationActive(true)} + canEdit={canEdit} + /> + )} + + + ); +}; + +export default WidgetViewWrapper; diff --git a/web/client/components/dashboard/__tests__/Dashboard-test.jsx b/web/client/components/dashboard/__tests__/Dashboard-test.jsx index 72ca9b92f46..b3a9e1ec406 100644 --- a/web/client/components/dashboard/__tests__/Dashboard-test.jsx +++ b/web/client/components/dashboard/__tests__/Dashboard-test.jsx @@ -28,6 +28,10 @@ const testWidget = { } }; +const layouts = [ + {id: '1', name: 'Layout 1', color: null, md: [], xxs: []} +]; + describe('WidgetsView component', () => { beforeEach((done) => { document.body.innerHTML = '
'; @@ -39,13 +43,13 @@ describe('WidgetsView component', () => { setTimeout(done); }); it('DashBoard empty', () => { - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render(, document.getElementById("container")); const container = document.getElementById('container'); const el = container.querySelector('.widget-card-on-map'); expect(el).toNotExist(); }); it('DashBoard empty', () => { - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render(, document.getElementById("container")); const container = document.getElementById('container'); const el = container.querySelector('.mapstore-widget-card'); expect(el).toExist(); @@ -53,7 +57,7 @@ describe('WidgetsView component', () => { it('DashBoard with width=460', () => { const WIDGET_MOBILE_RIGHT_SPACE = 18; const width = 460; - let cmp = ReactDOM.render(, document.getElementById("container")); + let cmp = ReactDOM.render(, document.getElementById("container")); expect(cmp).toBeTruthy(); const innerLayout = ReactTestUtils.findRenderedComponentWithType(cmp, Responsive); expect(innerLayout).toExist(); @@ -61,7 +65,7 @@ describe('WidgetsView component', () => { }); it('DashBoard with width=640', () => { const width = 640; - const cmp = ReactDOM.render(, document.getElementById("container")); + const cmp = ReactDOM.render(, document.getElementById("container")); expect(cmp).toExist(); const innerLayout = ReactTestUtils.findRenderedComponentWithType(cmp, Responsive); expect(innerLayout).toExist(); diff --git a/web/client/components/dashboard/hooks/useCheckScroll.js b/web/client/components/dashboard/hooks/useCheckScroll.js new file mode 100644 index 00000000000..01cc0d5c4a4 --- /dev/null +++ b/web/client/components/dashboard/hooks/useCheckScroll.js @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from "react"; + +export default function useCheckScroll({ data }) { + const scrollRef = useRef(null); + const [isLeftDisabled, setIsLeftDisabled] = useState(true); + const [isRightDisabled, setIsRightDisabled] = useState(true); + const [showButtons, setShowButtons] = useState(false); + + useEffect(() => { + const handleScroll = () => { + const el = scrollRef.current; + if (!el) return; + + const { scrollLeft, scrollWidth, clientWidth } = el; + + // Show buttons only if scrolling is possible + const canScroll = scrollWidth > clientWidth + 1; + setShowButtons(canScroll); + + // Disable states for buttons + setIsLeftDisabled(scrollLeft <= 0); + setIsRightDisabled(scrollLeft + clientWidth >= scrollWidth - 1); + }; + + const el = scrollRef.current; + if (el) { + handleScroll(); // initial + el.addEventListener("scroll", handleScroll); + window.addEventListener("resize", handleScroll); + } + + return () => { + el?.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", handleScroll); + }; + }, [data]); + + const scroll = (direction) => { + const el = scrollRef.current; + if (!el) return; + const scrollAmount = el.clientWidth * 0.8; + el.scrollBy({ + left: direction === "left" ? -scrollAmount : scrollAmount, + behavior: "smooth" + }); + }; + + return [scrollRef, showButtons, isLeftDisabled, isRightDisabled, scroll]; +} diff --git a/web/client/components/data/featuregrid/FeatureGrid.jsx b/web/client/components/data/featuregrid/FeatureGrid.jsx index 19168b7e20a..083ae38ece3 100644 --- a/web/client/components/data/featuregrid/FeatureGrid.jsx +++ b/web/client/components/data/featuregrid/FeatureGrid.jsx @@ -80,7 +80,18 @@ class FeatureGrid extends React.PureComponent { this.props.changes[id].hasOwnProperty(key); }, isProperty: (k) => k === "geometry" || isProperty(k, this.props.describeFeatureType), - isValid: (val, key) => this.props.describeFeatureType ? isValidValueForPropertyName(val, key, this.props.describeFeatureType) : true + isValid: (val, key, rowId) => { + const { errors = [], changed } = (this.props?.validationErrors?.[rowId] || {}); + // Extract field name from instancePath or dataPath (e.g., "/fid" -> "fid") + const error = errors.find((err) => { + const path = err.instancePath || err.dataPath || ''; + return path.replace(/^[./]/, '') === key; + }); + if (error) { + return { valid: false, message: error?.message, changed }; + } + return { valid: this.props.describeFeatureType ? isValidValueForPropertyName(val, key, this.props.describeFeatureType) : false }; + } }; } render() { diff --git a/web/client/components/data/featuregrid/editors/EnumerateEditor.jsx b/web/client/components/data/featuregrid/editors/EnumerateEditor.jsx new file mode 100644 index 00000000000..eafed1640f8 --- /dev/null +++ b/web/client/components/data/featuregrid/editors/EnumerateEditor.jsx @@ -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 React from 'react'; +import PropTypes from 'prop-types'; +import { Combobox } from 'react-widgets'; +import AttributeEditor from './AttributeEditor'; +import { isNil } from 'lodash'; + +const EnumerateEditorItem = (props) => { + const { value, label } = props.item || {}; + return value === null ? : label; +}; +/** + * Editor of the FeatureGrid, that allows to enumerate options for current property + * @memberof components.data.featuregrid.editors + * @name EnumerateEditor + * @class + */ +export default class EnumerateEditor extends AttributeEditor { + static propTypes = { + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.null + ]), + schema: PropTypes.object, + column: PropTypes.object, + onTemporaryChanges: PropTypes.func + }; + + static defaultProps = { + column: {} + }; + + constructor(props) { + super(props); + this.state = { selected: this.getOption(props.value) }; + } + + getOption = (value) => { + return { value, label: isNil(value) ? '' : `${value}` }; + } + + getValue = () => { + return { + [this.props.column.key]: this.state?.selected?.value + }; + } + + render() { + const options = (this.props?.schema?.enum || []); + const isValid = options.includes(this.state?.selected?.value); + return ( +
+ { + this.setState({ selected: selected ? selected : this.getOption(null) }); + }} + /> +
+ ); + } +} diff --git a/web/client/components/data/featuregrid/editors/NumberEditor.jsx b/web/client/components/data/featuregrid/editors/NumberEditor.jsx index 4e9c506faef..8a98625b19f 100644 --- a/web/client/components/data/featuregrid/editors/NumberEditor.jsx +++ b/web/client/components/data/featuregrid/editors/NumberEditor.jsx @@ -8,7 +8,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {isNumber} from 'lodash'; +import { isNumber, castArray } from 'lodash'; import IntlNumberFormControl from '../../../I18N/IntlNumberFormControl'; import { editors } from 'react-data-grid'; @@ -29,7 +29,9 @@ export default class NumberEditor extends editors.SimpleTextEditor { static propTypes = { value: PropTypes.oneOfType([ PropTypes.string, - PropTypes.number]), + PropTypes.number, + PropTypes.null + ]), inputProps: PropTypes.object, dataType: PropTypes.string, minValue: PropTypes.number, @@ -45,12 +47,14 @@ export default class NumberEditor extends editors.SimpleTextEditor { constructor(props) { super(props); - - this.state = {inputText: props.value?.toString?.() ?? ''}; + const value = props.value?.toString?.() ?? ''; + this.state = { + inputText: value, + isValid: this.validateTextValue(value), + validated: true + }; } - state = {inputText: ''}; - componentDidMount() { this.props.onTemporaryChanges?.(true); } @@ -62,9 +66,9 @@ export default class NumberEditor extends editors.SimpleTextEditor { getValue() { try { - const numberValue = parsers[this.props.dataType](this.state.inputText); + const numberValue = this.state.inputText === '' ? null : parsers[this.props.dataType](this.state.inputText); return { - [this.props.column.key]: this.validateNumberValue(numberValue) ? numberValue : this.props.value + [this.props.column.key]: numberValue }; } catch (e) { return { @@ -73,16 +77,21 @@ export default class NumberEditor extends editors.SimpleTextEditor { } } + getMinValue() { + return this.props?.column?.schema?.minimum ?? this.props.minValue; + } + + getMaxValue() { + return this.props?.column?.schema?.maximum ?? this.props.maxValue; + } + render() { - return (); + />
); } validateTextValue = (value) => { + if (value === '') { + return castArray(this.props?.column?.schema?.type || []).includes('null'); + } if (!parsers[this.props.dataType]) { return false; } - try { const numberValue = parsers[this.props.dataType](value); @@ -112,9 +123,11 @@ export default class NumberEditor extends editors.SimpleTextEditor { }; validateNumberValue = (value) => { + const minValue = this.getMinValue(); + const maxValue = this.getMaxValue(); return isNumber(value) && !isNaN(value) && - (!isNumber(this.props.minValue) || this.props.minValue <= value) && - (!isNumber(this.props.maxValue) || this.props.maxValue >= value); + (!isNumber(minValue) || minValue <= value) && + (!isNumber(maxValue) || maxValue >= value); }; } diff --git a/web/client/components/data/featuregrid/editors/__tests__/EnumerateEditor-test.jsx b/web/client/components/data/featuregrid/editors/__tests__/EnumerateEditor-test.jsx new file mode 100644 index 00000000000..d4198094736 --- /dev/null +++ b/web/client/components/data/featuregrid/editors/__tests__/EnumerateEditor-test.jsx @@ -0,0 +1,341 @@ +/* + * 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 React from 'react'; +import ReactDOM from 'react-dom'; + +import EnumerateEditor from '../EnumerateEditor'; + +let testColumn = { + key: 'columnKey' +}; + +describe('FeatureGrid EnumerateEditor component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should render with valid value from enum', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('option1'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toNotInclude('invalid'); + }); + + it('should render with invalid value not in enum', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('invalidOption'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle null value', () => { + const schema = { + 'enum': ['option1', 'option2', null] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(null); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // null is in enum, so should be valid + expect(editorDiv.className).toNotInclude('invalid'); + }); + + it('should handle null value when not in enum', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(null); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // null is not in enum, so should be invalid + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle number values in enum', () => { + const schema = { + 'enum': [1, 2, 3] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(2); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toNotInclude('invalid'); + }); + + it('should handle empty enum array', () => { + const schema = { + 'enum': [] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('anyValue'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // Empty enum means no valid options, so any value is invalid + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle missing schema', () => { + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('value'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // No enum means no valid options + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle undefined value', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(undefined); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // undefined is not in enum, so should be invalid + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should call getOption correctly for null value', () => { + const schema = { + 'enum': ['option1', null] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const option = cmp.getOption(null); + expect(option.value).toBe(null); + expect(option.label).toBe(''); + }); + + it('should call getOption correctly for string value', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const option = cmp.getOption('test'); + expect(option.value).toBe('test'); + expect(option.label).toBe('test'); + }); + + it('should call getOption correctly for number value', () => { + const schema = { + 'enum': [1, 2, 3] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const option = cmp.getOption(42); + expect(option.value).toBe(42); + expect(option.label).toBe('42'); + }); + + it('should handle onTemporaryChanges callback', (done) => { + const onTemporaryChanges = () => done(); + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + it('should handle column without key', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const result = cmp.getValue(); + expect(result).toBeTruthy(); + expect(result.undefined).toBe('option1'); + }); + + it('should initialize state with value prop', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp.state.selected.value).toBe('option1'); + }); + + it('should initialize state with different value when remounted', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + let cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp.state.selected.value).toBe('option1'); + + // Unmount to create a fresh instance + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + + // Remount with different value + cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp.state.selected.value).toBe('option2'); + }); + + it('should handle mixed enum types (string and number)', () => { + const schema = { + 'enum': ['option1', 2, 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(2); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toNotInclude('invalid'); + }); +}); + diff --git a/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx b/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx index 685659f18d1..01d72359d6b 100644 --- a/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx +++ b/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx @@ -61,8 +61,9 @@ describe('FeatureGrid NumberEditor/IntegerEditor component', () => { expect(inputElement.value).toBe('1.1'); TestUtils.Simulate.change(inputElement, {target: {value: '1.6'}}); - expect(cmp.getValue().columnKey).toBe(1.1); + expect(cmp.getValue().columnKey).toBe(1.6); expect(cmp.state.isValid).toBe(false); + expect(cmp.state.validated).toBe(true); }); it('Number Editor passed validation', () => { const cmp = ReactDOM.render( { + return !!props?.schema?.enum?.length; +}; + +// Create number editor (int or number type) +const createNumberEditor = (dataType) => (props) => { + return shouldUseEnumeratorComponent(props) + ? + : ; +}; + +// Create string editor +const createStringEditor = (props) => { + if (shouldUseEnumeratorComponent(props)) { + return ; + } + if (props.autocompleteEnabled) { + return ; + } + return ; +}; const types = { - "defaultEditor": (props) => , - "int": (props) => , - "number": (props) => , - "string": (props) => props.autocompleteEnabled ? - : - , - "boolean": (props) => , + "defaultEditor": (props) => , + "int": createNumberEditor("int"), + "number": createNumberEditor("number"), + "string": createStringEditor, + "boolean": (props) => ( + + ), "date-time": (props) => , - "date": (props) => , + "date": (props) => , "time": (props) => }; + export default (type, props) => types[type] ? types[type](props) : types.defaultEditor(props); diff --git a/web/client/components/data/featuregrid/enhancers/editor.js b/web/client/components/data/featuregrid/enhancers/editor.js index c5a3635344f..734553772e2 100644 --- a/web/client/components/data/featuregrid/enhancers/editor.js +++ b/web/client/components/data/featuregrid/enhancers/editor.js @@ -128,15 +128,18 @@ const featuresToGrid = compose( withPropsOnChange( ["features", "newFeatures", "isFocused", "virtualScroll", "pagination"], props => { - const rowsCount = (props.isFocused || !props.virtualScroll) && props.rows && props.rows.length || (props.pagination && props.pagination.totalFeatures) || 0; + const rowsCount = (props.isFocused || !props.virtualScroll) && props.rows && props.rows.length + || (props.pagination && props.pagination.totalFeatures) + || 0; + const newFeaturesLength = props?.newFeatures?.length || 0; return { - rowsCount + rowsCount: rowsCount + newFeaturesLength }; } ), withHandlers({rowGetter: props => props.virtualScroll && (i => getRowVirtual(i, props.rows, props.pages, props.size)) || (i => getRow(i, props.rows))}), withPropsOnChange( - ["describeFeatureType", "fields", "columnSettings", "tools", "actionOpts", "mode", "isFocused", "sortable"], + ["describeFeatureType", "fields", "columnSettings", "tools", "actionOpts", "mode", "isFocused", "sortable", "featurePropertiesJSONSchema", "primaryKeyAttributes"], props => { const getFilterRendererFunc = ({name}) => { if (props.filterRenderers && props.filterRenderers[name]) { @@ -145,22 +148,23 @@ const featuresToGrid = compose( // return empty component if no filter renderer is defined, to avoid failures return () => null; }; - const result = ({ columns: getToolColumns(props.tools, props.rowGetter, props.describeFeatureType, props.actionOpts, getFilterRendererFunc) - .concat(featureTypeToGridColumns(props.describeFeatureType, props.columnSettings, props.fields, { + .concat(featureTypeToGridColumns(props.describeFeatureType, props.featurePropertiesJSONSchema, props.columnSettings, props.fields, { editable: props.mode === "EDIT", sortable: props.sortable && !props.isFocused, defaultSize: props.defaultSize, - options: props.options?.propertyName + options: props.options?.propertyName, + primaryKeyAttributes: props.primaryKeyAttributes || [] }, { getHeaderRenderer, - getEditor: (desc) => { + getEditor: (desc, filed, schema) => { const generalProps = { onTemporaryChanges: props.gridEvents && props.gridEvents.onTemporaryChanges, autocompleteEnabled: props.autocompleteEnabled, url: props.url, - typeName: props.typeName + typeName: props.typeName, + schema }; const regexProps = {attribute: desc.name, url: props.url, typeName: props.typeName}; const rules = props.customEditorsOptions && props.customEditorsOptions.rules || []; diff --git a/web/client/components/data/featuregrid/renderers/CellRenderer.jsx b/web/client/components/data/featuregrid/renderers/CellRenderer.jsx index d8157f745c4..e3834d06b1e 100644 --- a/web/client/components/data/featuregrid/renderers/CellRenderer.jsx +++ b/web/client/components/data/featuregrid/renderers/CellRenderer.jsx @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Cell } from 'react-data-grid'; +import CellValidationErrorMessage from './CellValidationErrorMessage'; class CellRenderer extends React.Component { static propTypes = { @@ -11,7 +12,8 @@ class CellRenderer extends React.Component { static contextTypes = { isModified: PropTypes.func, isProperty: PropTypes.func, - isValid: PropTypes.func + isValid: PropTypes.func, + cellControls: PropTypes.any }; static defaultProps = { value: null, @@ -23,12 +25,36 @@ class CellRenderer extends React.Component { this.setScrollLeft = (scrollBy) => this.refs.cell.setScrollLeft(scrollBy); } render() { + const value = this.props.rowData.get(this.props.column.key); const isProperty = this.context.isProperty(this.props.column.key); const isModified = (this.props.rowData._new && isProperty) || this.context.isModified(this.props.rowData.id, this.props.column.key); - const isValid = isProperty ? this.context.isValid(this.props.rowData.get(this.props.column.key), this.props.column.key) : true; - const className = (isModified ? ['modified'] : []) - .concat(isValid ? [] : ['invalid']).join(" "); - return ; + const { valid, message, changed } = isProperty + ? this.context.isValid(value, this.props.column.key, this.props.rowData.id) + : { valid: true }; + const isPrimaryKey = this.props.column?.isPrimaryKey; + const className = [ + ...(isModified ? ['modified'] : []), + ...(valid ? [] : ['invalid']), + ...(isPrimaryKey ? ['primary-key'] : []) + ].join(" "); + return ( + + {this.props.cellControls} + + } + /> + ); } } diff --git a/web/client/components/data/featuregrid/renderers/CellValidationErrorMessage.jsx b/web/client/components/data/featuregrid/renderers/CellValidationErrorMessage.jsx new file mode 100644 index 00000000000..87fe7a4ad59 --- /dev/null +++ b/web/client/components/data/featuregrid/renderers/CellValidationErrorMessage.jsx @@ -0,0 +1,63 @@ +/* + * 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 PropTypes from 'prop-types'; +import withTooltip from '../../../misc/enhancers/tooltip'; +import { Glyphicon } from 'react-bootstrap'; +import { isNil } from 'lodash'; +import { getRestrictionsMessageInfo } from '../../../../utils/FeatureGridUtils'; +import Message from '../../../I18N/Message'; + +const GlyphiconIndicator = withTooltip(Glyphicon); + +const CellValidationErrorMessage = ({ + value, + valid, + column, + changed +}) => { + + if (valid || column.key === 'geometry') { + return null; + } + const restrictionsMessageInfo = getRestrictionsMessageInfo(column?.schema, column?.schemaRequired); + const isPrimaryKey = column?.isPrimaryKey; + return ( + <> + {/* when the value is empty we need a placeholder to fill the height of the field */} + {value === '' || isNil(value) ? : null} + + :
+ {(restrictionsMessageInfo?.msgIds || []).map(msgId => +
)} +
+ } + glyph="exclamation-mark" + /> + + ); +}; + +CellValidationErrorMessage.propTypes = { + value: PropTypes.any, + valid: PropTypes.bool, + changed: PropTypes.bool, + column: PropTypes.object +}; + +CellValidationErrorMessage.defaultProps = { + value: null, + column: {} +}; + +export default CellValidationErrorMessage; diff --git a/web/client/components/data/featuregrid/renderers/__tests__/CellValidationErrorMessage-test.jsx b/web/client/components/data/featuregrid/renderers/__tests__/CellValidationErrorMessage-test.jsx new file mode 100644 index 00000000000..94abfb8f82b --- /dev/null +++ b/web/client/components/data/featuregrid/renderers/__tests__/CellValidationErrorMessage-test.jsx @@ -0,0 +1,259 @@ +/* + * 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 ReactTestUtils from 'react-dom/test-utils'; +import expect from 'expect'; + +import CellValidationErrorMessage from '../CellValidationErrorMessage'; + +describe('Tests CellValidationErrorMessage component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should return null when valid is true', () => { + const props = { + value: 'test', + valid: true, + column: { key: 'testColumn' }, + changed: false + }; + const comp = ReactDOM.render(, document.getElementById("container")); + expect(comp).toBe(null); + const container = document.getElementById("container"); + expect(container.querySelector('.ms-cell-validation-indicator')).toNotExist(); + }); + + it('should return null when column.key is geometry', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'geometry' }, + changed: false + }; + const comp = ReactDOM.render(, document.getElementById("container")); + expect(comp).toBe(null); + const container = document.getElementById("container"); + expect(container.querySelector('.ms-cell-validation-indicator')).toNotExist(); + }); + + it('should render validation error indicator when valid is false', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-warning-text'); + }); + + it('should show placeholder span when value is empty string', () => { + const props = { + value: '', + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const placeholder = container.querySelector('span[style*="height: 1em"]'); + expect(placeholder).toBeTruthy(); + expect(placeholder.style.height).toBe('1em'); + expect(placeholder.style.display).toBe('inline-block'); + }); + + it('should show placeholder span when value is null', () => { + const props = { + value: null, + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const placeholder = container.querySelector('span[style*="height: 1em"]'); + expect(placeholder).toBeTruthy(); + }); + + it('should show danger class when changed is true', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn' }, + changed: true + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-danger-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-warning-text'); + }); + + it('should show warning class when changed is false and not primary key', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn', isPrimaryKey: false }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-warning-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-danger-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-info-text'); + }); + + it('should show info class when isPrimaryKey is true', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn', isPrimaryKey: true }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-info-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-warning-text'); + }); + + it('should show danger class when changed is true even if isPrimaryKey is true', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn', isPrimaryKey: true }, + changed: true + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-danger-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-info-text'); + }); + + it('should render with default props', () => { + ReactDOM.render(, document.getElementById("container")); + // When valid is undefined (falsy) and column.key is undefined (not 'geometry'), it should render + // But since column is {} by default, column.key is undefined, so it will render + const container = document.getElementById("container"); + // Component should render because valid is falsy and column.key is not 'geometry' + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); + + it('should handle missing column prop gracefully', () => { + const props = { + value: 'test', + valid: false, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + // Should render because valid is false and column.key is undefined (not 'geometry') + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); + + it('should render glyphicon with exclamation-mark', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('glyphicon'); + expect(indicator.getAttribute('class')).toInclude('glyphicon-exclamation-mark'); + }); + + it('should handle column with schema and schemaRequired', () => { + const props = { + value: 'test', + valid: false, + column: { + key: 'testColumn', + schema: { type: 'string', minLength: 5 }, + schemaRequired: true + }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); + + it('should handle column without schema', () => { + const props = { + value: 'test', + valid: false, + column: { + key: 'testColumn' + }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); + + it('should handle column with range schema (0 to 100)', () => { + const props = { + value: 'test', + valid: false, + column: { + key: 'testColumn', + schema: { + type: 'number', + minimum: 0, + maximum: 100 + }, + schemaRequired: true + }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-warning-text'); + // Trigger tooltip to check validation message + ReactTestUtils.Simulate.mouseOver(indicator); + const tooltip = document.querySelector('.tooltip-inner'); + expect(tooltip).toBeTruthy(); + // Check that the range validation message is displayed + const tooltipText = tooltip.textContent || tooltip.innerText; + // Check for range message ID (always present) and range values if IntlProvider is available + expect(tooltipText).toInclude('featuregrid.restrictions.range'); + // Check for required message as well since schemaRequired is true + expect(tooltipText).toInclude('featuregrid.restrictions.required'); + }); +}); + diff --git a/web/client/components/data/featuregrid/toolbars/Toolbar.jsx b/web/client/components/data/featuregrid/toolbars/Toolbar.jsx index 47a5b15e197..a9571203f14 100644 --- a/web/client/components/data/featuregrid/toolbars/Toolbar.jsx +++ b/web/client/components/data/featuregrid/toolbars/Toolbar.jsx @@ -21,10 +21,13 @@ const getDrawFeatureTooltip = (isDrawing, isSimpleGeom) => { } return isSimpleGeom ? "featuregrid.toolbar.drawGeom" : "featuregrid.toolbar.addGeom"; }; -const getSaveMessageId = ({saving, saved}) => { +const getSaveMessageId = ({ saving, saved, error }) => { if (saving || saved) { return "featuregrid.toolbar.saving"; } + if (error) { + return "featuregrid.toolbar.validationError"; + } return "featuregrid.toolbar.saveChanges"; }; const standardButtons = { @@ -86,15 +89,20 @@ const standardButtons = { visible={mode === "EDIT" && selectedCount > 0 && !hasChanges && !hasNewFeatures} onClick={events.deleteFeatures} glyph="trash-square"/>), - saveFeature: ({saving = false, saved = false, disabled, mode, hasChanges, hasNewFeatures, events = {}}) => (), + saveFeature: ({saving = false, saved = false, disabled, mode, hasChanges, hasNewFeatures, events = {}, validationErrors = {} }) => { + const hasValidationErrors = Object.keys(validationErrors).some(key => validationErrors[key].changed); + return (); + }, cancelEditing: ({disabled, mode, hasChanges, hasNewFeatures, events = {}}) => ( { it("check if the number is rendered in correct language format", () => { // Test with different locales to ensure numbers are formatted correctly const testCases = [ - { locale: "it-IT", value: 1234.56, expected: "1.234,56" }, // Italiano + { locale: "it-IT", value: 10234.56, expected: "10.234,56" }, // Italiano { locale: "en-US", value: 1234.56, expected: "1,234.56" }, // English { locale: "fr-FR", value: 1234.56, expected: /1\s234,56/ }, // Français (regex for space) { locale: "de-DE", value: 1234.56, expected: "1.234,56" }, // Deutsch diff --git a/web/client/components/manager/ipmanager/IPActions.jsx b/web/client/components/manager/ipmanager/IPActions.jsx new file mode 100644 index 00000000000..104ea3600db --- /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 00000000000..e3e2aa00bf0 --- /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 00000000000..eeeda55817c --- /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 87f82b00056..9d6f44df7ab 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 c31f64a8302..6e4ff2d72fc 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 97e69f219bf..7fab2da5aa6 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 df7167e1915..62bba00a1f1 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/ArcGISLayer.js b/web/client/components/map/cesium/plugins/ArcGISLayer.js index ef1d4f8e8c7..ee984b04b40 100644 --- a/web/client/components/map/cesium/plugins/ArcGISLayer.js +++ b/web/client/components/map/cesium/plugins/ArcGISLayer.js @@ -9,8 +9,9 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; import { isImageServerUrl } from '../../../../utils/ArcGISUtils'; -import { getProxiedUrl } from '../../../../utils/ConfigUtils'; - +import isEqual from 'lodash/isEqual'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; +import { getProxyUrl } from "../../../../utils/ProxyUtils"; // this override is needed to apply the selected format // and to detect an ImageServer and to apply the correct exportImage path @@ -54,9 +55,7 @@ class ArcGisMapAndImageServerImageryProvider extends Cesium.ArcGisMapServerImage constructor(options) { super(options); this._format = options.format; - this._resource = new Cesium.Resource({ - url: options.url - }); + this._resource = options.url; this._resource.appendForwardSlash(); } requestImage = function( @@ -73,8 +72,15 @@ class ArcGisMapAndImageServerImageryProvider extends Cesium.ArcGisMapServerImage } const create = (options) => { + const { headers, params } = getRequestConfigurationByUrl(options.url, null, options.security?.sourceId); + const resource = new Cesium.Resource({ + url: options.url, + queryParameters: params, + headers, + proxy: options.forceProxy ? new Cesium.DefaultProxy(getProxyUrl()) : undefined + }); return new ArcGisMapAndImageServerImageryProvider({ - url: options?.forceProxy ? getProxiedUrl() + encodeURIComponent(options.url) : options.url, + url: resource, ...(options.name !== undefined && { layers: `${options.name}` }), format: options.format, // we need to disable this when using layers ids @@ -85,7 +91,7 @@ const create = (options) => { }; const update = (layer, newOptions, oldOptions) => { - if (newOptions.forceProxy !== oldOptions.forceProxy) { + if (newOptions.forceProxy !== oldOptions.forceProxy || !isEqual(oldOptions.security, newOptions.security)) { return create(newOptions); } return null; diff --git a/web/client/components/map/cesium/plugins/ElevationLayer.js b/web/client/components/map/cesium/plugins/ElevationLayer.js index 8a7e8bcf675..2bcf2040dd4 100644 --- a/web/client/components/map/cesium/plugins/ElevationLayer.js +++ b/web/client/components/map/cesium/plugins/ElevationLayer.js @@ -8,7 +8,7 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; - +import isEqual from 'lodash/isEqual'; import { wmsToCesiumOptions } from '../../../../utils/cesium/WMSUtils'; import { addElevationTile, getElevation, getElevationKey, getTileRelativePixel } from '../../../../utils/ElevationUtils'; @@ -144,10 +144,18 @@ const createWMSElevationLayer = (options, map) => { return layer; }; +const create = (options, map) => { + if (options.provider === 'wms') { + return createWMSElevationLayer(options, map); + } + return null; +}; + Layers.registerType('elevation', { - create: (options, map) => { - if (options.provider === 'wms') { - return createWMSElevationLayer(options, map); + create, + update: (layer, newOptions, oldOptions, map) => { + if (!isEqual(oldOptions.security, newOptions.security)) { + return create(newOptions, map); } return null; } diff --git a/web/client/components/map/cesium/plugins/GraticuleLayer.js b/web/client/components/map/cesium/plugins/GraticuleLayer.js index b38a3025bcc..b9d9070e7a9 100644 --- a/web/client/components/map/cesium/plugins/GraticuleLayer.js +++ b/web/client/components/map/cesium/plugins/GraticuleLayer.js @@ -8,7 +8,6 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; - /** * Created by thomas on 27/01/14. // [source 07APR2015: http://pad.geocento.com/AddOns/Graticule.js] diff --git a/web/client/components/map/cesium/plugins/MarkerLayer.js b/web/client/components/map/cesium/plugins/MarkerLayer.js index 1847d39152d..85a94d851c6 100644 --- a/web/client/components/map/cesium/plugins/MarkerLayer.js +++ b/web/client/components/map/cesium/plugins/MarkerLayer.js @@ -9,7 +9,7 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; -import { isEqual } from 'lodash'; +import isEqual from 'lodash/isEqual'; /** * @deprecated diff --git a/web/client/components/map/cesium/plugins/ModelLayer.js b/web/client/components/map/cesium/plugins/ModelLayer.js index d9d578b2518..cd0ee004509 100644 --- a/web/client/components/map/cesium/plugins/ModelLayer.js +++ b/web/client/components/map/cesium/plugins/ModelLayer.js @@ -194,7 +194,7 @@ Layers.registerType('model', { if (primitives && !isEqual(newOptions?.features?.[0], oldOptions?.features?.[0])) { updatePrimitivesMatrix(primitives, newOptions?.features?.[0]); } - if (newOptions?.forceProxy !== oldOptions?.forceProxy) { + if (newOptions?.forceProxy !== oldOptions?.forceProxy || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions, map); } return null; diff --git a/web/client/components/map/cesium/plugins/TerrainLayer.js b/web/client/components/map/cesium/plugins/TerrainLayer.js index b70d9928355..33dd61bfc4e 100644 --- a/web/client/components/map/cesium/plugins/TerrainLayer.js +++ b/web/client/components/map/cesium/plugins/TerrainLayer.js @@ -11,11 +11,16 @@ import * as Cesium from 'cesium'; import GeoServerBILTerrainProvider from '../../../../utils/cesium/GeoServerBILTerrainProvider'; import WMSUtils from '../../../../utils/cesium/WMSUtils'; import { getProxyUrl } from "../../../../utils/ProxyUtils"; +import isEqual from 'lodash/isEqual'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function cesiumOptionsMapping(config) { + const { headers, params } = getRequestConfigurationByUrl(config.url, null, config.security?.sourceId); return { url: new Cesium.Resource({ url: config.url, + headers, + queryParameters: params, proxy: config.forceProxy ? new Cesium.DefaultProxy(getProxyUrl()) : undefined }), options: { @@ -115,7 +120,8 @@ const updateLayer = (layer, newOptions, oldOptions, map) => { || newOptions?.options?.crs !== oldOptions?.options?.crs || newOptions?.version !== oldOptions?.version || newOptions?.name !== oldOptions?.name - || oldOptions.forceProxy !== newOptions.forceProxy) { + || oldOptions.forceProxy !== newOptions.forceProxy + || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions, map); } return null; diff --git a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js index 86b25787828..17f828ed9e6 100644 --- a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js +++ b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js @@ -17,6 +17,7 @@ import tinycolor from 'tinycolor2'; import googleOnWhiteLogo from '../img/google_on_white_hdpi.png'; import googleOnNonWhiteLogo from '../img/google_on_non_white_hdpi.png'; import { createClippingPolygonsFromGeoJSON, applyClippingPolygons } from '../../../../utils/cesium/PrimitivesUtils'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; const cleanStyle = (style, options) => { if (style && options?.pointCloudShading?.attenuation) { @@ -73,6 +74,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 +151,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 +161,61 @@ 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); + const { headers, params } = getRequestConfigurationByUrl(options.url, null, options.security?.sourceId); + // delay creation of tileset when frequents recreation are requested + timeout = setTimeout(() => { + timeout = undefined; + resource = new Cesium.Resource({ + url: options.url, + queryParameters: params, + headers, + 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 +233,12 @@ 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) + || !isEqual(oldOptions.security, newOptions.security) + ) { return createLayer(newOptions, map); } if ( diff --git a/web/client/components/map/cesium/plugins/TileProviderLayer.js b/web/client/components/map/cesium/plugins/TileProviderLayer.js index c0457b842f1..5e40b6e7f60 100644 --- a/web/client/components/map/cesium/plugins/TileProviderLayer.js +++ b/web/client/components/map/cesium/plugins/TileProviderLayer.js @@ -13,6 +13,7 @@ import ConfigUtils from '../../../../utils/ConfigUtils'; import {creditsToAttribution} from '../../../../utils/LayersUtils'; import {getProxyUrl} from '../../../../utils/ProxyUtils'; import isEqual from 'lodash/isEqual'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function splitUrl(originalUrl) { let url = originalUrl; @@ -81,14 +82,20 @@ const create = (options) => { const cr = opt.credits; const credit = cr ? new Cesium.Credit(creditsToAttribution(cr)) : opt.attribution; - return new Cesium.UrlTemplateImageryProvider({ + const { headers, params } = getRequestConfigurationByUrl(options.url, null, options.security?.sourceId); + const resource = new Cesium.Resource({ url: template(url, opt), + queryParameters: params, + headers, + proxy: options?.forceProxy ? new TileProviderProxy(proxyUrl) : new NoProxy() + }); + return new Cesium.UrlTemplateImageryProvider({ + url: resource, enablePickFeatures: false, subdomains: opt.subdomains, maximumLevel: opt.maxZoom, minimumLevel: opt.minZoom, - credit, - proxy: options?.forceProxy ? new TileProviderProxy(proxyUrl) : new NoProxy() + credit }); }; diff --git a/web/client/components/map/cesium/plugins/WMTSLayer.js b/web/client/components/map/cesium/plugins/WMTSLayer.js index 01dbcc4060d..c69da8ff7d9 100644 --- a/web/client/components/map/cesium/plugins/WMTSLayer.js +++ b/web/client/components/map/cesium/plugins/WMTSLayer.js @@ -18,7 +18,7 @@ import { isEqual, isObject, isArray, slice, get, head} from 'lodash'; import urlParser from 'url'; import { isVectorFormat } from '../../../../utils/VectorTileUtils'; -import { getCredentials } from '../../../../utils/SecurityUtils'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function splitUrl(originalUrl) { let url = originalUrl; @@ -118,13 +118,13 @@ function wmtsToCesiumOptions(_options) { const credit = cr ? new Cesium.Credit(creditsToAttribution(cr)) : ''; let headersOpts; - if (options.security) { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; - headersOpts = { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - } - }; + if (options.security && options.url) { + const urlToCheck = isArray(options.url) ? options.url[0] : options.url; + const requestConfig = getRequestConfigurationByUrl(urlToCheck, null, options.security?.sourceId); + + if (requestConfig.headers) { + headersOpts = { headers: requestConfig.headers }; + } } return Object.assign({ // TODO: multi-domain support, if use {s} switches to RESTFul mode diff --git a/web/client/components/map/leaflet/plugins/ElevationLayer.js b/web/client/components/map/leaflet/plugins/ElevationLayer.js index ff5d6dfd3b2..d6d1772c482 100644 --- a/web/client/components/map/leaflet/plugins/ElevationLayer.js +++ b/web/client/components/map/leaflet/plugins/ElevationLayer.js @@ -73,8 +73,8 @@ L.tileLayer.elevationWMS = function(urls, options, nodata, littleEndian, id) { const createWMSElevationLayer = (options) => { const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const layer = L.tileLayer.elevationWMS( urls, { diff --git a/web/client/components/map/leaflet/plugins/VectorLayer.jsx b/web/client/components/map/leaflet/plugins/VectorLayer.jsx index 0ab86451add..4ecdfc640f5 100644 --- a/web/client/components/map/leaflet/plugins/VectorLayer.jsx +++ b/web/client/components/map/leaflet/plugins/VectorLayer.jsx @@ -84,7 +84,7 @@ const updateLayerLegacy = (layer, newOptions, oldOptions) => { if (newOptions.opacity !== oldOptions.opacity) { layer.opacity = newOptions.opacity; } - if (!isEqual(newOptions.style, oldOptions.style)) { + if (!isEqual(newOptions.style, oldOptions.style) || !isEqual(oldOptions.security, newOptions.security)) { return isNewStyle(newOptions) ? createLayer(newOptions) : createLayerLegacy(newOptions); @@ -93,7 +93,7 @@ const updateLayerLegacy = (layer, newOptions, oldOptions) => { }; const updateLayer = (layer, newOptions, oldOptions) => { - if (!isEqual(oldOptions.layerFilter, newOptions.layerFilter)) { + if (!isEqual(oldOptions.layerFilter, newOptions.layerFilter) || !isEqual(oldOptions.security, newOptions.security)) { layer.remove(); return createLayer(newOptions); } diff --git a/web/client/components/map/leaflet/plugins/WFSLayer.jsx b/web/client/components/map/leaflet/plugins/WFSLayer.jsx index cb013240610..15d4491e157 100644 --- a/web/client/components/map/leaflet/plugins/WFSLayer.jsx +++ b/web/client/components/map/leaflet/plugins/WFSLayer.jsx @@ -88,7 +88,7 @@ Layers.registerType('wfs', { return layer; }, update: (layer, newOptions, oldOptions) => { - if (needsReload(oldOptions, newOptions)) { + if (needsReload(oldOptions, newOptions) || !isEqual(oldOptions.security, newOptions.security)) { loadFeatures(layer, newOptions); } if (!isEqual(newOptions.style, oldOptions.style) diff --git a/web/client/components/map/leaflet/plugins/WMSLayer.js b/web/client/components/map/leaflet/plugins/WMSLayer.js index 1cb724eccfc..a4b2a39f760 100644 --- a/web/client/components/map/leaflet/plugins/WMSLayer.js +++ b/web/client/components/map/leaflet/plugins/WMSLayer.js @@ -10,7 +10,8 @@ import Layers from '../../../../utils/leaflet/Layers'; import { filterWMSParamOptions, getWMSURLs, wmsToLeafletOptions, removeNulls } from '../../../../utils/leaflet/WMSUtils'; import L from 'leaflet'; -import { isArray } from 'lodash'; +import isEqual from 'lodash/isEqual'; +import isArray from 'lodash/isArray'; import {addAuthenticationToSLD, addAuthenticationParameter} from '../../../../utils/SecurityUtils'; import 'leaflet.nontiledlayer'; @@ -56,19 +57,24 @@ Layers.registerType('wms', { }, map, mapId); } const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); if (options.singleTile) { return L.nonTiledLayer.wmsCustom(urls[0], queryParameters); } return L.tileLayer.multipleUrlWMS(urls, queryParameters); }, update: function(layer, newOptions, oldOptions) { - if (oldOptions.singleTile !== newOptions.singleTile || oldOptions.tileSize !== newOptions.tileSize || oldOptions.securityToken !== newOptions.securityToken && newOptions.visibility) { + if ( + (oldOptions.singleTile !== newOptions.singleTile + || oldOptions.tileSize !== newOptions.tileSize + || oldOptions.securityToken !== newOptions.securityToken + || !isEqual(oldOptions.security, newOptions.security)) + && newOptions.visibility) { let newLayer; const urls = getWMSURLs(isArray(newOptions.url) ? newOptions.url : [newOptions.url]); - const queryParameters = wmsToLeafletOptions(newOptions) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, newOptions.securityToken)); + let queryParameters = wmsToLeafletOptions(newOptions) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, newOptions.securityToken, newOptions.security?.sourceId); if (newOptions.singleTile) { // return the nonTiledLayer newLayer = L.nonTiledLayer.wmsCustom(urls[0], queryParameters); diff --git a/web/client/components/map/leaflet/plugins/WMTSLayer.js b/web/client/components/map/leaflet/plugins/WMTSLayer.js index 9d59d7f0a32..f3250cedc3b 100644 --- a/web/client/components/map/leaflet/plugins/WMTSLayer.js +++ b/web/client/components/map/leaflet/plugins/WMTSLayer.js @@ -13,7 +13,8 @@ import {addAuthenticationParameter} from '../../../../utils/SecurityUtils'; import { creditsToAttribution } from '../../../../utils/LayersUtils'; import * as WMTSUtils from '../../../../utils/WMTSUtils'; import WMTS from '../../../../utils/leaflet/WMTS'; -import { isArray } from 'lodash'; +import isArray from 'lodash/isArray'; +import isEqual from 'lodash/isEqual'; import { isVectorFormat } from '../../../../utils/VectorTileUtils'; L.tileLayer.wmts = function(urls, options, matrixOptions) { @@ -47,8 +48,8 @@ function getWMSURLs(urls) { const createLayer = _options => { const options = WMTSUtils.parseTileMatrixSetOption(_options); const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = wmtsToLeafletOptions(options) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = wmtsToLeafletOptions(options) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const srs = normalizeSRS(options.srs || 'EPSG:3857', options.allowedSRS); const { tileMatrixSet, matrixIds } = WMTSUtils.getTileMatrix(options, srs); return L.tileLayer.wmts(urls, queryParameters, { @@ -65,7 +66,8 @@ const createLayer = _options => { const updateLayer = (layer, newOptions, oldOptions) => { if (oldOptions.securityToken !== newOptions.securityToken || oldOptions.format !== newOptions.format - || oldOptions.credits !== newOptions.credits) { + || oldOptions.credits !== newOptions.credits + || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions); } return null; diff --git a/web/client/components/map/openlayers/__tests__/Layer-test.jsx b/web/client/components/map/openlayers/__tests__/Layer-test.jsx index aaf298c8fae..53e92908e79 100644 --- a/web/client/components/map/openlayers/__tests__/Layer-test.jsx +++ b/web/client/components/map/openlayers/__tests__/Layer-test.jsx @@ -1016,6 +1016,41 @@ describe('Openlayers layer', () => { expect(map.getLayers().getLength()).toBe(1); expect(map.getLayers().item(0).getSource().urls.length).toBe(2); }); + + it('allows wmts url-parameters to be added to url', () => { + var options = { + "type": "wmts", + "visibility": true, + "name": "nurc:Arc_Sample", + "group": "Meteo", + "format": "image/png", + "params": { + "myparam1": "myvalue1", + "myparam2": "myvalue2" + }, + "tileMatrixSet": [ + { + "TileMatrix": [], + "ows:Identifier": "EPSG:900913", + "ows:SupportedCRS": "urn:ogc:def:crs:EPSG::900913" + } + ], + "url": ["http://sample.server/geoserver/gwc/service/wmts"] + }; + // create layer + var layer = ReactDOM.render( + , document.getElementById("container")); + + + expect(layer).toBeTruthy(); + // count layers + expect(map.getLayers().getLength()).toBe(1); + const url = map.getLayers().item(0).getSource().urls[0]; + expect(url.includes('myparam1=myvalue1')).toBe(true); + expect(url.includes('myparam2=myvalue2')).toBe(true); + }); + it('test correct wms origin', () => { var options = { "type": "wms", @@ -3357,6 +3392,125 @@ describe('Openlayers layer', () => { expect(cmp.layer).toBeTruthy(); expect(cmp.layer.get('getElevation')).toBeTruthy(); }); + it('wms layer should refresh source when loadingError changes to Error', () => { + var refreshCalled = false; + const options = { + "type": "wms", + "visibility": true, + "name": "nurc:Arc_Sample", + "group": "Meteo", + "format": "image/png", + "url": "http://sample.server/geoserver/wms" + }; + + // create layer + const layer = ReactDOM.render( + , document.getElementById("container")); + + expect(layer).toBeTruthy(); + expect(map.getLayers().getLength()).toBe(1); + + const wmsSource = map.getLayers().item(0).getSource(); + const originalRefresh = wmsSource.refresh; + + // mock refresh method to set boolean to refreshCalled to trigger change + wmsSource.refresh = function() { + refreshCalled = true; + originalRefresh.call(this); + }; + + // update layer with loadingError set to "Error" + ReactDOM.render( + , document.getElementById("container")); + + // check that refresh was called + expect(refreshCalled).toBe(true); + + // restore original method + wmsSource.refresh = originalRefresh; + }); + + it('wms layer should not refresh source when loadingError remains Error', () => { + var refreshCalled = false; + const options = { + "type": "wms", + "visibility": true, + "name": "nurc:Arc_Sample", + "group": "Meteo", + "format": "image/png", + "url": "http://sample.server/geoserver/wms", + "loadingError": "Error" + }; + + // create layer with loadingError already set to "Error" + const layer = ReactDOM.render( + , document.getElementById("container")); + + expect(layer).toBeTruthy(); + + const wmsSource = map.getLayers().item(0).getSource(); + const originalRefresh = wmsSource.refresh; + + // mock refresh method to set boolean to refreshCalled to trigger change + wmsSource.refresh = function() { + refreshCalled = true; + originalRefresh.call(this); + }; + + // update layer with loadingError still "Error" + ReactDOM.render( + , document.getElementById("container")); + + // refresh should NOT be called because loadingError didn't change from non-Error to Error + expect(refreshCalled).toBe(false); + + // restore original method + wmsSource.refresh = originalRefresh; + }); + + it('wms layer should not refresh source when loadingError changes from Error to undefined', () => { + var refreshCalled = false; + const options = { + "type": "wms", + "visibility": true, + "name": "nurc:Arc_Sample", + "group": "Meteo", + "format": "image/png", + "url": "http://sample.server/geoserver/wms", + "loadingError": "Error" + }; + + // create layer with loadingError set to "Error" + const layer = ReactDOM.render( + , document.getElementById("container")); + + expect(layer).toBeTruthy(); + + const wmsSource = map.getLayers().item(0).getSource(); + const originalRefresh = wmsSource.refresh; + + // mock refresh method to set boolean to refreshCalled to trigger change + wmsSource.refresh = function() { + refreshCalled = true; + originalRefresh.call(this); + }; + + // update layer with loadingError changing from "Error" to undefined + ReactDOM.render( + , document.getElementById("container")); + + // refresh should NOT be called + expect(refreshCalled).toBe(false); + + // restore original method + wmsSource.refresh = originalRefresh; + }); it('creates a arcgis layer (MapServer)', () => { const options = { type: 'arcgis', diff --git a/web/client/components/map/openlayers/plugins/ArcGISLayer.js b/web/client/components/map/openlayers/plugins/ArcGISLayer.js index 5bef918b143..0fcb35f2644 100644 --- a/web/client/components/map/openlayers/plugins/ArcGISLayer.js +++ b/web/client/components/map/openlayers/plugins/ArcGISLayer.js @@ -11,15 +11,12 @@ import { registerType } from '../../../../utils/openlayers/Layers'; import TileLayer from 'ol/layer/Tile'; import TileArcGISRest from 'ol/source/TileArcGISRest'; import axios from 'axios'; -import { getCredentials } from '../../../../utils/SecurityUtils'; import { isEqual } from 'lodash'; +import { hasRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; const tileLoadFunction = options => (image, src) => { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { image.getImage().src = URL.createObjectURL(response.data); @@ -31,7 +28,7 @@ const tileLoadFunction = options => (image, src) => { registerType('arcgis', { create: (options) => { const sourceOpt = {}; - if (options.security) { + if (hasRequestConfigurationByUrl(options.url, null, options.security?.sourceId)) { sourceOpt.tileLoadFunction = tileLoadFunction(options); } return new TileLayer({ @@ -58,7 +55,8 @@ registerType('arcgis', { if (oldOptions.maxResolution !== newOptions.maxResolution) { layer.setMaxResolution(newOptions.maxResolution === undefined ? Infinity : newOptions.maxResolution); } - if (!isEqual(oldOptions.security, newOptions.security)) { + if (!isEqual(oldOptions.security, newOptions.security) + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash)) { layer.getSource().setTileLoadFunction(tileLoadFunction(newOptions)); } }, diff --git a/web/client/components/map/openlayers/plugins/COGLayer.js b/web/client/components/map/openlayers/plugins/COGLayer.js index 103e622a687..2b12f4e21ed 100644 --- a/web/client/components/map/openlayers/plugins/COGLayer.js +++ b/web/client/components/map/openlayers/plugins/COGLayer.js @@ -13,15 +13,16 @@ import get from 'lodash/get'; import GeoTIFF from 'ol/source/GeoTIFF.js'; import TileLayer from 'ol/layer/WebGLTile.js'; import { isProjectionAvailable } from '../../../../utils/ProjectionUtils'; -import { getCredentials } from '../../../../utils/SecurityUtils'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function create(options) { - let sourceOptions; - if (options.security) { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; - sourceOptions.headers = { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }; + let sourceOptions = {}; + if (options.security && options.sources && options.sources.length > 0) { + const firstSource = options.sources[0]; + const requestConfig = getRequestConfigurationByUrl(firstSource.url, null, options.security?.sourceId); + if (requestConfig.headers) { + sourceOptions.headers = requestConfig.headers; + } } return new TileLayer({ msId: options.id, @@ -47,6 +48,7 @@ Layers.registerType('cog', { || !isEqual(newOptions.style, oldOptions.style) || !isEqual(newOptions.security, oldOptions.security) || !isEqual(newOptions.sources, oldOptions.sources) // min/max source data value can change + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash) ) { return create(newOptions, map); } diff --git a/web/client/components/map/openlayers/plugins/ElevationLayer.js b/web/client/components/map/openlayers/plugins/ElevationLayer.js index bb1f0167333..139dbaa873f 100644 --- a/web/client/components/map/openlayers/plugins/ElevationLayer.js +++ b/web/client/components/map/openlayers/plugins/ElevationLayer.js @@ -57,8 +57,8 @@ function getElevation(pos) { const createWMSElevationLayer = (options, map) => { const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = wmsToOpenlayersOptions(options) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = wmsToOpenlayersOptions(options) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const layer = new TileLayer({ msId: options.id, opacity: options.opacity !== undefined ? options.opacity : 1, diff --git a/web/client/components/map/openlayers/plugins/TMSLayer.js b/web/client/components/map/openlayers/plugins/TMSLayer.js index efe24b5e1ef..5dcfe493b73 100644 --- a/web/client/components/map/openlayers/plugins/TMSLayer.js +++ b/web/client/components/map/openlayers/plugins/TMSLayer.js @@ -12,14 +12,10 @@ import TileGrid from 'ol/tilegrid/TileGrid'; import TileLayer from 'ol/layer/Tile'; import Layers from '../../../../utils/openlayers/Layers'; -import { getCredentials } from '../../../../utils/SecurityUtils'; - +import { hasRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; const tileLoadFunction = options => (image, src) => { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { image.getImage().src = URL.createObjectURL(response.data); @@ -36,7 +32,7 @@ function tileXYZToOpenlayersOptions(options = {}) { url: `${options.tileMapUrl}/{z}/{x}/{-y}.${options.extension}`, // TODO use resolutions attributions: options.attribution ? [options.attribution] : [] }; - if (options.security) { + if (hasRequestConfigurationByUrl(options.url, null, options.security?.sourceId)) { sourceOpt.tileLoadFunction = tileLoadFunction(options); } @@ -89,7 +85,8 @@ Layers.registerType('tms', { if (oldOptions.maxResolution !== newOptions.maxResolution) { layer.setMaxResolution(newOptions.maxResolution === undefined ? Infinity : newOptions.maxResolution); } - if (!isEqual(oldOptions.security, newOptions.security)) { + if (!isEqual(oldOptions.security, newOptions.security) + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash)) { layer.getSource().setTileLoadFunction(tileLoadFunction(newOptions)); } } diff --git a/web/client/components/map/openlayers/plugins/TileProviderLayer.js b/web/client/components/map/openlayers/plugins/TileProviderLayer.js index 34d6c4bda64..dc490072aa7 100644 --- a/web/client/components/map/openlayers/plugins/TileProviderLayer.js +++ b/web/client/components/map/openlayers/plugins/TileProviderLayer.js @@ -12,19 +12,16 @@ import { getUrls, template } from '../../../../utils/TileProviderUtils'; import XYZ from 'ol/source/XYZ'; import TileLayer from 'ol/layer/Tile'; import axios from 'axios'; -import { getCredentials } from '../../../../utils/SecurityUtils'; import { isEqual } from 'lodash'; +import { hasRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function lBoundsToOlExtent(bounds, destPrj) { var [ [ miny, minx], [ maxy, maxx ] ] = bounds; return CoordinatesUtils.reprojectBbox([minx, miny, maxx, maxy], 'EPSG:4326', CoordinatesUtils.normalizeSRS(destPrj)); } const tileLoadFunction = options => (image, src) => { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { image.getImage().src = URL.createObjectURL(response.data); @@ -41,7 +38,7 @@ function tileXYZToOpenlayersOptions(options) { maxZoom: options.maxZoom ? options.maxZoom : 18, minZoom: options.minZoom ? options.minZoom : 0 // dosen't affect ol layer rendering UNSUPPORTED }); - if (options.security) { + if (hasRequestConfigurationByUrl(options.url, null, options.security?.sourceId)) { sourceOpt.tileLoadFunction = tileLoadFunction(options); } let source = new XYZ(sourceOpt); @@ -70,7 +67,8 @@ Layers.registerType('tileprovider', { if (oldOptions.maxResolution !== newOptions.maxResolution) { layer.setMaxResolution(newOptions.maxResolution === undefined ? Infinity : newOptions.maxResolution); } - if (!isEqual(oldOptions.security, newOptions.security)) { + if (!isEqual(oldOptions.security, newOptions.security) + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash)) { layer.getSource().setTileLoadFunction(tileLoadFunction(newOptions)); } } diff --git a/web/client/components/map/openlayers/plugins/WFSLayer.js b/web/client/components/map/openlayers/plugins/WFSLayer.js index 41b52eec62d..0465eb8ef01 100644 --- a/web/client/components/map/openlayers/plugins/WFSLayer.js +++ b/web/client/components/map/openlayers/plugins/WFSLayer.js @@ -170,7 +170,10 @@ Layers.registerType('wfs', { f.getGeometry().transform(oldCrs, newCrs); }); } - if (needsReload(oldOptions, options) || !isEqual(oldOptions.security, options.security)) { + if (needsReload(oldOptions, options) + || !isEqual(oldOptions.security, options.security) + || !isEqual(oldOptions.requestRuleRefreshHash, options.requestRuleRefreshHash) + ) { source.setLoader(createLoader(source, options)); source.clear(); source.refresh(); diff --git a/web/client/components/map/openlayers/plugins/WMSLayer.js b/web/client/components/map/openlayers/plugins/WMSLayer.js index 554d314dc46..d478e48ce76 100644 --- a/web/client/components/map/openlayers/plugins/WMSLayer.js +++ b/web/client/components/map/openlayers/plugins/WMSLayer.js @@ -104,8 +104,8 @@ const createLayer = (options, map, mapId) => { }, map, mapId); } const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = wmsToOpenlayersOptions(options) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = wmsToOpenlayersOptions(options) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const headers = getAuthenticationHeaders(urls[0], options.securityToken, options.security); const vectorFormat = isVectorFormat(options.format); @@ -187,6 +187,8 @@ const mustCreateNewLayer = (oldOptions, newOptions) => { || oldOptions.forceProxy !== newOptions.forceProxy || oldOptions.tileGridStrategy !== newOptions.tileGridStrategy || !isEqual(oldOptions.tileGrids, newOptions.tileGrids) + || !isEqual(oldOptions.security, newOptions.security) + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash) ); }; @@ -256,19 +258,18 @@ Layers.registerType('wms', { needsRefresh = needsRefresh || changed; } - + // refresh/update wms layer if there is error in loading tiles like: incorrect time dimension date filter, ..etc + if (oldOptions.loadingError !== "Error" && newOptions.loadingError === "Error") { + // Clear tile cache before refresh to avoid showing old broken tiles + wmsSource?.tileCache?.pruneExceptNewestZ?.(); + wmsSource?.refresh(); + } if (oldOptions.minResolution !== newOptions.minResolution) { layer.setMinResolution(newOptions.minResolution === undefined ? 0 : newOptions.minResolution); } if (oldOptions.maxResolution !== newOptions.maxResolution) { layer.setMaxResolution(newOptions.maxResolution === undefined ? Infinity : newOptions.maxResolution); } - if (!isEqual(oldOptions.security, newOptions.security)) { - const urls = getWMSURLs(isArray(newOptions.url) ? newOptions.url : [newOptions.url]); - const headers = getAuthenticationHeaders(urls[0], newOptions.securityToken, newOptions.security); - wmsSource.setTileLoadFunction(loadFunction(newOptions, headers)); - wmsSource.refresh(); - } if (needsRefresh) { // forces tile cache drop // this prevents old cached tiles at lower zoom levels to be diff --git a/web/client/components/map/openlayers/plugins/WMTSLayer.js b/web/client/components/map/openlayers/plugins/WMTSLayer.js index a2e28542896..48731414875 100644 --- a/web/client/components/map/openlayers/plugins/WMTSLayer.js +++ b/web/client/components/map/openlayers/plugins/WMTSLayer.js @@ -13,7 +13,7 @@ import head from 'lodash/head'; import last from 'lodash/last'; import axios from '../../../../libs/ajax'; import { proxySource } from '../../../../utils/openlayers/WMSUtils'; -import {getCredentials, addAuthenticationParameter} from '../../../../utils/SecurityUtils'; +import {addAuthenticationParameter, hasRequestConfigurationByUrl} from '../../../../utils/SecurityUtils'; import * as WMTSUtils from '../../../../utils/WMTSUtils'; import CoordinatesUtils from '../../../../utils/CoordinatesUtils'; import MapUtils from '../../../../utils/MapUtils'; @@ -46,11 +46,8 @@ function getWMSURLs(urls, requestEncoding) { } const tileLoadFunction = (options) => (image, src) => { - const storedProtectedService = options.security ? getCredentials(options.security?.sourceId) : {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { if (isValidResponse(response)) { @@ -119,8 +116,8 @@ const createLayer = options => { // the extent has effect to the tile ranges // we should skip the extent if the layer does not provide bounding box let extent = layerExtent && getIntersection(layerExtent, projection.getExtent()); - const queryParameters = {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = options.params ? options.params : {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const queryParametersString = urlParser.format({ query: { ...queryParameters } }); // TODO: support tileSizes from matrix @@ -154,8 +151,8 @@ const createLayer = options => { }), wrapX: true }; - if (options.security?.sourceId) { - wmtsOptions.urls = urls.map(url => proxySource(options.forceProxy, url)); + if (hasRequestConfigurationByUrl(options.url, null, options.security?.sourceId)) { + wmtsOptions.urls = wmtsOptions.urls.map(url => proxySource(options.forceProxy, url)); wmtsOptions.tileLoadFunction = tileLoadFunction(options); } @@ -189,7 +186,8 @@ const updateLayer = (layer, newOptions, oldOptions) => { || oldOptions.srs !== newOptions.srs || oldOptions.format !== newOptions.format || oldOptions.style !== newOptions.style - || oldOptions.credits !== newOptions.credits) { + || oldOptions.credits !== newOptions.credits + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash)) { return createLayer(newOptions); } if (oldOptions.minResolution !== newOptions.minResolution) { diff --git a/web/client/components/mapcontrols/mouseposition/mousePosition.css b/web/client/components/mapcontrols/mouseposition/mousePosition.css index 4b710598b3e..36ba2204457 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/mapcontrols/search/SearchBar.jsx b/web/client/components/mapcontrols/search/SearchBar.jsx index 12bef4e8ed1..7b2d42abf2f 100644 --- a/web/client/components/mapcontrols/search/SearchBar.jsx +++ b/web/client/components/mapcontrols/search/SearchBar.jsx @@ -25,14 +25,17 @@ import tooltip from '../../misc/enhancers/tooltip'; const TDiv = tooltip('div'); -const SearchServicesContainer = ({activeTool, searchIcon, services = [], selectedService = -1, onServiceSelect = () => {}}) => { +const SearchServicesContainer = ({activeTool, searchIcon, bottomMenuServices, services = [], selectedService = -1, onServiceSelect = () => {}}) => { + const menuClassName = `search-services-submenus ${bottomMenuServices ? "search-services-submenus-bottom" : ""}`; return ( <> + { !bottomMenuServices && onServiceSelect(-1)}> -
+ } +
onServiceSelect(-1)}> @@ -59,10 +62,12 @@ const SearchServicesContainer = ({activeTool, searchIcon, services = [], selecte ); }; -const SearchServicesSelectorMenu = ({activeTool, searchIcon, services = [], selectedService = -1, onServiceSelect = () => {}}) => { +const SearchServicesSelectorMenu = ({activeTool, searchIcon, bottomMenuServices = false, services = [], selectedService = -1, onServiceSelect = () => {}}) => { + if (services.length === 0) { return null; } + if (services.length === 1) { return ( onServiceSelect(-1)}> @@ -76,6 +81,7 @@ const SearchServicesSelectorMenu = ({activeTool, searchIcon, services = [], sele activeTool={activeTool} searchIcon={searchIcon} services={services} + bottomMenuServices={bottomMenuServices} selectedService={selectedService} onServiceSelect={onServiceSelect} />); @@ -183,6 +189,7 @@ export default ({ onChangeActiveSearchTool("addressSearch"); return; }} + bottomMenuServices={searchOptions?.bottomMenuServices} services={searchOptions?.services} /> ); @@ -220,6 +227,12 @@ export default ({ } } + // Move custom services at bottom menu + if (searchOptions?.bottomMenuServices && showAddressSearchOption && searchMenuOptions.length > 1) { + const [topmenu, ...rest] = searchMenuOptions; + searchMenuOptions = [...rest, topmenu]; + } + const getConfigButtons = () => { if (showOptions) { if (activeTool === "coordinatesSearch") { diff --git a/web/client/components/mapcontrols/search/__tests__/SearchBar-test.jsx b/web/client/components/mapcontrols/search/__tests__/SearchBar-test.jsx index 2b0aa88a63a..cd73fa8ab78 100644 --- a/web/client/components/mapcontrols/search/__tests__/SearchBar-test.jsx +++ b/web/client/components/mapcontrols/search/__tests__/SearchBar-test.jsx @@ -1,5 +1,5 @@ /** - * Copyright 2015, GeoSolutions Sas. + * Copyright 2025, GeoSolutions Sas. * All rights reserved. * * This source code is licensed under the BSD-style license found in the @@ -69,6 +69,16 @@ describe("test the SearchBar", () => { expect(searchServicesSubMenus[1].innerHTML).toContain("nominatim"); expect(searchServicesSubMenus[2].innerHTML).toContain("test"); }); + it("test option bottomMenuServices", () => { + const searchOptions = { + bottomMenuServices: true, + services: [{type: "Nominatim"}, { type: "wfs", name: "test"}] + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const submenusBottom = container.querySelectorAll('.search-services-submenus-bottom'); + expect(submenusBottom.length).toBe(1); + }); it('test onSearch with multiple services', () => { const actions = { onSearch: () => {}, diff --git a/web/client/components/mapviews/MapViewSettings.jsx b/web/client/components/mapviews/MapViewSettings.jsx index 80a29eb6970..1e3cc7da4c2 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/components/misc/SecureImage.jsx b/web/client/components/misc/SecureImage.jsx index a87ac6c964b..038c1b40c87 100644 --- a/web/client/components/misc/SecureImage.jsx +++ b/web/client/components/misc/SecureImage.jsx @@ -7,9 +7,8 @@ */ import React, { useEffect, useState } from 'react'; -import axios from 'axios'; - -import { getAuthKeyParameter, getAuthenticationMethod, getAuthorizationBasic, getToken } from '../../utils/SecurityUtils'; +import axios from '../../libs/ajax'; +import { getAuthenticationMethod, getAuthKeyParameter, getToken } from '../../utils/SecurityUtils'; import { updateUrlParams } from '../../utils/URLUtils'; @@ -53,10 +52,9 @@ const SecureImage = ({ } } else if (props?.layer?.security?.sourceId) { - const headers = getAuthorizationBasic(props?.layer?.security?.sourceId); axios.get(src, { responseType: 'blob', - headers + _msAuthSourceId: props?.layer?.security?.sourceId }) .then((response) => { const imageUrl = URL.createObjectURL(response.data); diff --git a/web/client/components/misc/panels/DockPanel.jsx b/web/client/components/misc/panels/DockPanel.jsx index e15eac3df01..7f33f17d0ce 100644 --- a/web/client/components/misc/panels/DockPanel.jsx +++ b/web/client/components/misc/panels/DockPanel.jsx @@ -33,6 +33,7 @@ import { DEFAULT_PANEL_WIDTH } from '../../../utils/LayoutUtils'; * @prop {node} header additional element for header * @prop {node} footer footer content * @prop {bool} hideHeader hide header + * @prop {bool} hideCloseButton hide close button */ export default withState('fullscreen', 'onFullscreen', false)( @@ -55,7 +56,8 @@ export default withState('fullscreen', 'onFullscreen', false)( onFullscreen = () => {}, fixed = false, resizable = false, - hideHeader + hideHeader, + hideCloseButton = false }) =>
+ onFullscreen={onFullscreen} + hideCloseButton={hideCloseButton}/> } footer={open && footer}> {open && children} diff --git a/web/client/components/misc/panels/DockablePanel.jsx b/web/client/components/misc/panels/DockablePanel.jsx index 696f54a0102..24e2c00862f 100644 --- a/web/client/components/misc/panels/DockablePanel.jsx +++ b/web/client/components/misc/panels/DockablePanel.jsx @@ -27,10 +27,13 @@ const Modal = renameProps({ })(({ children, header, + hideCloseButton = false, ...props }) => { return ( - + {header}
}> {children} diff --git a/web/client/components/misc/panels/PanelHeader.jsx b/web/client/components/misc/panels/PanelHeader.jsx index a3c15319ef6..7a05c5e1ff4 100644 --- a/web/client/components/misc/panels/PanelHeader.jsx +++ b/web/client/components/misc/panels/PanelHeader.jsx @@ -59,9 +59,10 @@ export default ({ showFullscreen = false, glyph = 'info-sign', additionalRows, - onFullscreen = () => {} + onFullscreen = () => {}, + hideCloseButton = false }) => { - const closeButton = !onClose ? null : ( + const closeButton = hideCloseButton || !onClose ? null : ( diff --git a/web/client/components/misc/panels/__tests__/PanelHeader-test.jsx b/web/client/components/misc/panels/__tests__/PanelHeader-test.jsx index 5f709cd9a7c..dc1e05b2875 100644 --- a/web/client/components/misc/panels/__tests__/PanelHeader-test.jsx +++ b/web/client/components/misc/panels/__tests__/PanelHeader-test.jsx @@ -95,4 +95,10 @@ describe("test PanelHeader", () => { const closeButton = document.querySelector('.ms-close'); expect(closeButton).toExist(); }); + + it('test icon not button hideCloseButton is true', () => { + ReactDOM.render( {}} />, document.getElementById("container")); + const closeButton = document.querySelector('.ms-close'); + expect(closeButton).toNotExist(); + }); }); diff --git a/web/client/components/styleeditor/GraphicPattern/GraphicPattern.jsx b/web/client/components/styleeditor/GraphicPattern/GraphicPattern.jsx new file mode 100644 index 00000000000..0fa319587a4 --- /dev/null +++ b/web/client/components/styleeditor/GraphicPattern/GraphicPattern.jsx @@ -0,0 +1,354 @@ +/* + * 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'; + +const MARKS_TYPES = { + line: ({ mark }) => { + return ( + + ); + }, + polygon: ({ mark }) => { + return ( + + ); + }, + circle: ({ mark }) => { + return ( + + ); + }, + polyline: ({ mark }) => { + return ( + + ); + } +}; + +const GraphicPattern = ({ id, symbolizer, type }) => { + const graphic = type === 'line' ? symbolizer["graphic-stroke"] : symbolizer["graphic-fill"]; + const mark = graphic?.graphics?.[0]; + + if (!graphic || !mark) return null; + + const size = Number(graphic.size || 10); + const rotation = Number(graphic.rotation || 0); + const opacity = Number(graphic.opacity ?? 1); + + const margin = + symbolizer["vendor-options"]?.["graphic-margin"] + ?.split(" ") + .map(Number) || [0, 0]; + + const patternWidth = size + margin[0]; + const patternHeight = size + margin[1]; + + const getMarkTypes = () => { + switch (mark.mark) { + case "shape://horline": + return MARKS_TYPES.line({ + mark: { + ...mark, + x1: 0, + y1: size / 2, + x2: size, + y2: size / 2, + opacity + } + }); + case "line": + case "shape://vertline": + return MARKS_TYPES.line({ + mark: { + ...mark, + x1: size / 2, + y1: 0, + x2: size / 2, + y2: size, + opacity + } + }); + case "shape://slash": + return MARKS_TYPES.line({ + mark: { + ...mark, + x1: 0, + y1: size, + x2: size, + y2: 0, + opacity + } + }); + case "shape://backslash": + return MARKS_TYPES.line({ + mark: { + ...mark, + x1: 0, + y1: 0, + x2: size, + y2: size, + opacity + } + }); + case "circle": + return MARKS_TYPES.circle({ + mark: { + ...mark, + cx: size / 2, + cy: size / 2, + r: size / 4 + } + }); + case "triangle": + return MARKS_TYPES.polygon({ mark: { ...mark, points: `${size / 2},0 0,${size} ${size},${size}` } }); + case "star": + const cx = size / 2; + const cy = size / 2; + const outerR = size / 2; + const innerR = size / 2.5; + + const points = []; + for (let i = 0; i < 10; i++) { + const angle = (Math.PI / 5) * i - Math.PI / 2; + const r = i % 2 === 0 ? outerR : innerR; + points.push( + `${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}` + ); + } + return MARKS_TYPES.polygon({ mark: { ...mark, points, fill: mark.fill ?? "#000" } }); + case "cross": + return MARKS_TYPES.polygon({ mark: { ...mark, points: `${size / 2},0 ${size / 2},${size} ${size},${size / 2}` } }); + case "shape://dot": + return MARKS_TYPES.circle({ + mark: { + ...mark, + cx: size / 2, + cy: size / 2, + r: size / 8, + opacity + } + }); + case "shape://plus": + return <> + {MARKS_TYPES.line({ + mark: { + ...mark, + x1: 0, + y1: size / 2, + x2: size, + y2: size / 2, + opacity + } + })} + {MARKS_TYPES.line({ + mark: { + ...mark, + x1: size / 2, + y1: 0, + x2: size / 2, + y2: size, + opacity + } + })} + ; + case "shape://times": + return <> + {MARKS_TYPES.line({ + mark: { + ...mark, + x1: 0, + y1: size, + x2: size, + y2: 0, + opacity + } + })} + {MARKS_TYPES.line({ + mark: { + ...mark, + x1: 0, + y1: size, + x2: size, + y2: 0, + opacity + } + })} + ; + case "shape://oarrow": + return MARKS_TYPES.polyline({ + mark: { + ...mark, + points: ` + 0,0 + ${size},${size / 2} + 0,${size} + `, + fill: "none", + stroke: mark.stroke, + strokeWidth: mark["stroke-width"], + strokeOpacity: mark["stroke-opacity"], + opacity + } + }); + + case "shape://carrow": + return MARKS_TYPES.polygon({ + mark: { + ...mark, + points: `0,0 ${size},${size / 2} 0,${size}`, + opacity + } + }); + case "extshape://triangle": + return MARKS_TYPES.polygon({ mark: { ...mark, points: `${size / 2},0 0,${size} ${size},${size}`, opacity, fill: mark.fill ?? "none" } }); + case "extshape://emicircle": + const rx = size / 2; + const ry = size / 4; + return ( + + ); + case "extshape://triangleemicircle": { + const triangleHeight = size / 2; + const semicircleRadius = size / 4; + return ( + <> + {MARKS_TYPES.polygon({ + mark: { + ...mark, + points: ` + ${size / 2},0 + 0,${triangleHeight} + ${size},${triangleHeight} + `, + fill: mark.fill ?? "none", + opacity + } + })} + + + ); + } + case "extshape://narrow": + return MARKS_TYPES.polygon({ + mark: { + ...mark, + points: ` + ${size / 2},0 + 0,${size} + ${size},${size} + `, + fill: mark.fill ?? "none", + opacity + } + }); + case "extshape://sarrow": + return MARKS_TYPES.polygon({ + mark: { + ...mark, + points: ` + 0,0 + ${size},0 + ${size / 2},${size} + `, + fill: mark.fill ?? "none", + opacity + } + }); + case "square": + default: + return MARKS_TYPES.polygon({ + mark: { + ...mark, + points: `0,0 ${size},0 ${size},${size} 0,${size}`, + fill: mark.fill ?? "none", + opacity + } + }); + } + }; + + return ( + + + + {getMarkTypes()} + + + + ); +}; + +export default GraphicPattern; diff --git a/web/client/components/styleeditor/GraphicPattern/__tests__/GraphicPattern-test.jsx b/web/client/components/styleeditor/GraphicPattern/__tests__/GraphicPattern-test.jsx new file mode 100644 index 00000000000..96cafcc9617 --- /dev/null +++ b/web/client/components/styleeditor/GraphicPattern/__tests__/GraphicPattern-test.jsx @@ -0,0 +1,99 @@ +/* + * 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 GraphicPattern from '../GraphicPattern'; + +describe('GraphicPattern', () => { + beforeEach((done) => { + document.body.innerHTML = ''; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById('container')); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should return null when no graphic is provided', () => { + const container = document.getElementById('container'); + ReactDOM.render( + , + container + ); + expect(container.innerHTML).toBe(''); + }); + + it('should render pattern with horizontal line mark for polygon graphic-fill', () => { + const symbolizer = { + "graphic-fill": { + size: 10, + opacity: 0.8, + rotation: 0, + graphics: [{ + mark: "shape://horline", + stroke: "#000000", + "stroke-width": 2, + "stroke-opacity": 1 + }] + }, + "vendor-options": { + "graphic-margin": "2 2" + } + }; + + const container = document.getElementById('container'); + ReactDOM.render( + , + container + ); + + const pattern = container.querySelector('pattern#pattern-horline'); + expect(pattern).toExist(); + const line = pattern.querySelector('line'); + expect(line).toExist(); + expect(line.getAttribute('stroke')).toBe('#000000'); + expect(line.getAttribute('stroke-width')).toBe('2'); + }); + + it('should render pattern with circle mark for line graphic-stroke', () => { + const symbolizer = { + "graphic-stroke": { + size: 12, + opacity: 1, + rotation: 45, + graphics: [{ + mark: "circle", + fill: "#FF0000", + "fill-opacity": 0.5, + stroke: "#000000", + "stroke-width": 1, + "stroke-opacity": 1 + }] + } + }; + + const container = document.getElementById('container'); + ReactDOM.render( + , + container + ); + + const pattern = container.querySelector('pattern#pattern-circle'); + expect(pattern).toExist(); + const circle = pattern.querySelector('circle'); + expect(circle).toExist(); + expect(circle.getAttribute('fill')).toBe('#FF0000'); + expect(circle.getAttribute('stroke')).toBe('#000000'); + }); +}); + + diff --git a/web/client/components/styleeditor/WMSJsonLegendIcon.jsx b/web/client/components/styleeditor/WMSJsonLegendIcon.jsx index 4b6aa9af699..8bfef20f563 100644 --- a/web/client/components/styleeditor/WMSJsonLegendIcon.jsx +++ b/web/client/components/styleeditor/WMSJsonLegendIcon.jsx @@ -11,37 +11,105 @@ import { Glyphicon } from 'react-bootstrap'; import { getWellKnownNameImageFromSymbolizer } from '../../utils/styleparser/StyleParserUtils'; +import { colorToRgbaStr } from '../../utils/ColorUtils'; +import useGraphicPattern from './hooks/useGraphicPattern'; + +function normalizeDashArray(dasharray) { + if (!dasharray) return null; + return Array.isArray(dasharray) ? dasharray.join(" ") : dasharray; +} + +function getPolygonFill(symbolizer, graphicFill) { + const fillOpacity = Number(symbolizer["fill-opacity"]); + const baseFill = graphicFill?.fill || symbolizer.fill; + + // If baseFill is a pattern URL (starts with "url("), return it as-is + // Pattern URLs can't be converted to rgba strings + if (baseFill && typeof baseFill === 'string' && baseFill.startsWith('url(')) { + return baseFill; + } + + // Otherwise, convert the color with opacity + return colorToRgbaStr(baseFill, fillOpacity, baseFill); +} + +function getLineStroke(symbolizer, graphicStroke) { + const strokeOpacity = Number(symbolizer["stroke-opacity"]); + const baseStroke = graphicStroke?.stroke || symbolizer.stroke; + + // If baseStroke is a pattern URL (starts with "url("), return it as-is + // Pattern URLs can't be converted to rgba strings + if (baseStroke && typeof baseStroke === 'string' && baseStroke.startsWith('url(')) { + return baseStroke; + } + + // Otherwise, convert the color with opacity + return colorToRgbaStr(baseStroke, strokeOpacity, baseStroke); +} const icon = { Line: ({ symbolizer }) => { - const displayWidth = symbolizer['stroke-width'] ? symbolizer['stroke-width'] : symbolizer.width === 0 - ? 1 - : symbolizer.width > 7 - ? 7 - : symbolizer.width; - return ( - - { + const { defs, stroke } = useGraphicPattern(sym, "line"); + if (defs) { + allDefs.push(defs); + } + const displayWidth = sym['stroke-width'] || 1; + return ( + + ); + }); + return ( + + {allDefs} + {paths} ); }, Polygon: ({ symbolizer }) => { + // Handle both single symbolizer and array of symbolizers + const symbolizers = Array.isArray(symbolizer) ? symbolizer : [symbolizer]; + // Collect all defs and paths + const allDefs = []; + const paths = symbolizers.map((sym, idx) => { + const { defs, fill } = useGraphicPattern(sym, 'polygon'); + if (defs) { + allDefs.push(defs); + } + const strokeDasharray = normalizeDashArray( + sym["stroke-dasharray"] + ); + return ( + + ); + }); + return ( - + {allDefs} + {paths} ); }, @@ -161,26 +229,63 @@ function createSymbolizerForPoint(pointSymbolizer) { function WMSJsonLegendIcon({ rule }) { - const ruleSymbolizers = rule?.symbolizers; + const ruleSymbolizers = rule?.symbolizers || []; + const polygonSymbolizers = []; + const lineSymbolizers = []; + const pointSymbolizers = []; const icons = []; + ruleSymbolizers.forEach((symbolizer) => { let symbolyzierKinds = Object.keys(symbolizer); const availableSymbolyzers = ['Point', 'Line', 'Polygon']; symbolyzierKinds.forEach(kind => { if (!availableSymbolyzers.includes(kind)) return; else if (kind === 'Point') { - let graphicSymbolyzer = symbolizer[kind]?.graphics?.find(gr => Object.keys(gr).includes('mark')); - const graphicType = graphicSymbolyzer ? 'Mark' : 'Icon'; - if (graphicType === 'Mark') { - symbolizer[kind] = createSymbolizerForPoint(symbolizer[kind]); - } - symbolizer[kind].wellKnownName = graphicType === 'Mark' ? graphicSymbolyzer.mark.charAt(0).toUpperCase() + graphicSymbolyzer.mark.slice(1) : ''; - icons.push({Icon: icon[graphicType], symbolizer: symbolizer[kind]}); + pointSymbolizers.push(symbolizer[kind]); + return; + } else if (kind === 'Line') { + lineSymbolizers.push(symbolizer[kind]); + return; + } else if (kind === 'Polygon') { + polygonSymbolizers.push(symbolizer[kind]); return; } icons.push({Icon: icon[kind], symbolizer: symbolizer[kind]}); }); }); + + // Handle Line symbolizers (single or multiple) + if (lineSymbolizers.length > 0) { + icons.push({ + Icon: icon.Line, + symbolizer: lineSymbolizers.length === 1 ? lineSymbolizers[0] : lineSymbolizers + }); + } + + // Handle Polygon symbolizers (single or multiple) + if (polygonSymbolizers.length > 0) { + icons.push({ + Icon: icon.Polygon, + symbolizer: polygonSymbolizers.length === 1 ? polygonSymbolizers[0] : polygonSymbolizers + }); + } + + // Handle Point symbolizers (individual icons, not stacked) + pointSymbolizers.forEach((pointSym) => { + let graphicSymbolyzer = pointSym?.graphics?.find(gr => Object.keys(gr).includes('mark')); + const graphicType = graphicSymbolyzer ? 'Mark' : 'Icon'; + const processedSymbolizer = graphicType === 'Mark' + ? createSymbolizerForPoint(pointSym) + : pointSym; + if (graphicType === 'Mark' && graphicSymbolyzer) { + processedSymbolizer.wellKnownName = graphicSymbolyzer.mark.charAt(0).toUpperCase() + graphicSymbolyzer.mark.slice(1); + } + icons.push({ + Icon: icon[graphicType], + symbolizer: processedSymbolizer + }); + }); + return icons.length ? <> {icons.map(({ Icon, symbolizer }, idx) =>
)} : null; } diff --git a/web/client/components/styleeditor/__tests__/WMSJsonLegendIcon-test.jsx b/web/client/components/styleeditor/__tests__/WMSJsonLegendIcon-test.jsx index 90db2c7efe0..cca91a49b8f 100644 --- a/web/client/components/styleeditor/__tests__/WMSJsonLegendIcon-test.jsx +++ b/web/client/components/styleeditor/__tests__/WMSJsonLegendIcon-test.jsx @@ -69,4 +69,103 @@ describe('WMSJsonLegendIcon', () => { const svgElements = document.querySelectorAll('svg'); expect(svgElements.length).toBe(1); }); + it('should render polygon icon with graphic-fill pattern', () => { + const symbolizers = [{ + "Polygon": { + "fill": "#4DFF4D", + "fill-opacity": "0.7", + "graphic-fill": { + "size": 10, + "opacity": 1, + "rotation": 45, + "graphics": [{ + "mark": "shape://horline", + "stroke": "#000000", + "stroke-width": 2, + "stroke-opacity": 1 + }] + }, + "vendor-options": { + "graphic-margin": "2 2" + } + } + }]; + ReactDOM.render(, document.getElementById('container')); + const svg = document.querySelector('svg'); + const patterns = svg.querySelectorAll('pattern'); + const paths = svg.querySelectorAll('path'); + expect(patterns.length).toBeGreaterThan(0); + expect(paths.length).toBeGreaterThan(0); + expect(paths[0].getAttribute('fill')).toMatch(/^url\(#pattern-/); + }); + it('should render line icon with graphic-stroke pattern', () => { + const symbolizers = [{ + "Line": { + "stroke": "#AA3333", + "stroke-width": 2, + "stroke-opacity": 1, + "graphic-stroke": { + "size": 8, + "opacity": 1, + "graphics": [{ + "mark": "shape://vertline", + "stroke": "#AA3333", + "stroke-width": 2, + "stroke-opacity": 1 + }] + }, + "vendor-options": { + "graphic-margin": "1 1" + } + } + }]; + ReactDOM.render(, document.getElementById('container')); + const svg = document.querySelector('svg'); + const patterns = svg.querySelectorAll('pattern'); + const paths = svg.querySelectorAll('path'); + expect(patterns.length).toBeGreaterThan(0); + expect(paths.length).toBeGreaterThan(0); + expect(paths[0].getAttribute('stroke')).toMatch(/^url\(#pattern-/); + }); + it('should render multiple polygon symbolizers with patterns', () => { + const symbolizers = [{ + "Polygon": { + "fill": "#FF0000", + "fill-opacity": "0.8", + "graphic-fill": { + "size": 6, + "opacity": 1, + "graphics": [{ + "mark": "circle", + "fill": "#FFFFFF", + "fill-opacity": 1, + "stroke": "#FF0000", + "stroke-width": 1, + "stroke-opacity": 1 + }] + } + } + }, { + "Polygon": { + "fill": "#0000FF", + "fill-opacity": "0.5", + "graphic-fill": { + "size": 6, + "opacity": 1, + "graphics": [{ + "mark": "shape://slash", + "stroke": "#0000FF", + "stroke-width": 1, + "stroke-opacity": 1 + }] + } + } + }]; + ReactDOM.render(, document.getElementById('container')); + const svg = document.querySelector('svg'); + const patterns = svg.querySelectorAll('pattern'); + const paths = svg.querySelectorAll('path'); + expect(patterns.length).toBeGreaterThan(1); + expect(paths.length).toBeGreaterThan(1); + }); }); diff --git a/web/client/components/styleeditor/hooks/__tests__/useGraphicPattern-test.js b/web/client/components/styleeditor/hooks/__tests__/useGraphicPattern-test.js new file mode 100644 index 00000000000..d56a94fa022 --- /dev/null +++ b/web/client/components/styleeditor/hooks/__tests__/useGraphicPattern-test.js @@ -0,0 +1,135 @@ +/* + * 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 useGraphicPattern from '../useGraphicPattern'; + +describe('useGraphicPattern', () => { + let hookResult; + + const TestComponent = ({ symbolizer, type }) => { + hookResult = useGraphicPattern(symbolizer, type); + return ; + }; + + beforeEach((done) => { + document.body.innerHTML = '
'; + hookResult = null; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById('container')); + document.body.innerHTML = ''; + hookResult = null; + setTimeout(done); + }); + + it('should return plain stroke and no defs for line without graphic-stroke', () => { + const symbolizer = { + stroke: '#AA3333', + 'stroke-width': 2 + }; + + act(() => { + ReactDOM.render( + , + document.getElementById('container') + ); + }); + + expect(hookResult).toBeTruthy(); + expect(hookResult.defs).toBe(null); + expect(hookResult.stroke).toBe('#AA3333'); + }); + + it('should return defs and pattern url stroke for line with graphic-stroke', () => { + const symbolizer = { + stroke: '#AA3333', + 'stroke-width': 2, + 'graphic-stroke': { + size: 8, + opacity: 1, + graphics: [{ + mark: 'shape://vertline', + stroke: '#AA3333', + 'stroke-width': 2, + 'stroke-opacity': 1 + }] + }, + 'vendor-options': { + 'graphic-margin': '1 1' + } + }; + + act(() => { + ReactDOM.render( + , + document.getElementById('container') + ); + }); + + expect(hookResult).toBeTruthy(); + expect(hookResult.defs).toExist(); + expect(hookResult.stroke).toMatch(/^url\(#pattern-/); + }); + + it('should return plain fill and no defs for polygon without graphic-fill', () => { + const symbolizer = { + fill: '#AA3333', + 'fill-width': 2 + }; + + act(() => { + ReactDOM.render( + , + document.getElementById('container') + ); + }); + + expect(hookResult).toBeTruthy(); + expect(hookResult.defs).toBe(null); + expect(hookResult.fill).toBe('#AA3333'); + }); + + it('should return defs and pattern url fill for polygon with graphic-fill', () => { + const symbolizer = { + fill: '#AA3333', + 'fill-width': 2, + 'graphic-fill': { + size: 8, + opacity: 1, + graphics: [{ + mark: 'shape://vertline', + fill: '#AA3333', + 'fill-width': 2, + 'fill-opacity': 1 + }] + }, + 'vendor-options': { + 'graphic-margin': '1 1' + } + }; + + act(() => { + ReactDOM.render( + , + document.getElementById('container') + ); + }); + + expect(hookResult).toBeTruthy(); + expect(hookResult.defs).toExist(); + expect(hookResult.fill).toMatch(/^url\(#pattern-/); + }); +}); + + diff --git a/web/client/components/styleeditor/hooks/useGraphicPattern.js b/web/client/components/styleeditor/hooks/useGraphicPattern.js new file mode 100644 index 00000000000..e7ac5b109af --- /dev/null +++ b/web/client/components/styleeditor/hooks/useGraphicPattern.js @@ -0,0 +1,35 @@ +/* + * 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, { useMemo } from "react"; +import { v4 as uuidv4 } from 'uuid'; +import GraphicPattern from "../GraphicPattern/GraphicPattern"; + +function useGraphicPattern(symbolizer, type) { + return useMemo(() => { + if (type === 'line' && !symbolizer?.["graphic-stroke"]) { + return { + defs: null, + stroke: symbolizer?.stroke + }; + } + if (type === 'polygon' && !symbolizer?.["graphic-fill"]) { + return { + defs: null, + fill: symbolizer?.fill + }; + } + const patternId = "pattern-" + uuidv4(); + return { + defs: , + ...(type === 'line' ? { stroke: `url(#${patternId})` } : {}), + ...(type === 'polygon' ? { fill: `url(#${patternId})` } : {}) + }; + }, [symbolizer, type]); +} + +export default useGraphicPattern; diff --git a/web/client/components/widgets/builder/wizard/LegendWizard.jsx b/web/client/components/widgets/builder/wizard/LegendWizard.jsx index acaaf8dc970..fdbac403084 100644 --- a/web/client/components/widgets/builder/wizard/LegendWizard.jsx +++ b/web/client/components/widgets/builder/wizard/LegendWizard.jsx @@ -53,7 +53,7 @@ export default ({ } /> -
+
- + ); diff --git a/web/client/components/widgets/builder/wizard/MapWizard.jsx b/web/client/components/widgets/builder/wizard/MapWizard.jsx index 2aa45c3c874..c83b28b078d 100644 --- a/web/client/components/widgets/builder/wizard/MapWizard.jsx +++ b/web/client/components/widgets/builder/wizard/MapWizard.jsx @@ -30,7 +30,9 @@ export default ({ setEditNode = () => {}, closeNodeEditor = () => {}, isLocalizedLayerStylesEnabled, - env + env, + widgets = [], + widgetId } = {}) => { const [selectedMap, setSelectedMap] = useState({}); const [emptyMap, setEmptyMap] = useState(false); @@ -67,6 +69,8 @@ export default ({ selectedNodes={selectedNodes} onChange={onChange} isLocalizedLayerStylesEnabled={isLocalizedLayerStylesEnabled} + widgets={widgets} + widgetId={widgetId} preview={ { document.body.innerHTML = ''; setTimeout(done); }); + it('LegendWizard rendering with defaults', () => { ReactDOM.render(, document.getElementById("container")); const container = document.getElementById('container'); @@ -29,7 +30,8 @@ describe('LegendWizard component', () => { expect(el).toBeTruthy(); expect(container.querySelector('.empty-state-container')).toBeTruthy(); }); - it('LegendWizard rendering with layers', () => { + + it('LegendWizard rendering with valid dependencies', () => { const LAYERS = [{ id: 'layer:00', name: 'layer:00', @@ -44,8 +46,7 @@ describe('LegendWizard component', () => { visibility: false, type: 'wms', opacity: 0.5 - } - ]; + }]; const dependencies = { layers: LAYERS }; ReactDOM.render(, document.getElementById("container")); const container = document.getElementById('container'); @@ -53,10 +54,127 @@ describe('LegendWizard component', () => { expect(el).toBeTruthy(); expect(container.querySelector('.ms-layers-tree')).toBeTruthy(); }); - it('LegendWizard rendering options', () => { + + it('LegendWizard rendering WidgetOptions', () => { ReactDOM.render(, document.getElementById("container")); const container = document.getElementById('container'); const el = container.querySelector('.widget-options-form'); expect(el).toBeTruthy(); }); + + it('LegendWizard renders legend preview container', () => { + const LAYERS = [{ + id: 'layer:00', + name: 'layer:00', + title: 'Layer', + visibility: true, + type: 'wms' + }]; + const dependencies = { layers: LAYERS }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + expect(container.querySelector('.legend-preview-container')).toBeTruthy(); + }); + + it('LegendWizard renders LegendPreview with correct props', () => { + const LAYERS = [{ + id: 'layer:00', + name: 'layer:00', + title: 'Layer', + visibility: true, + type: 'wms' + }]; + const dependencies = { layers: LAYERS }; + const data = { + dependenciesMap: { + layers: 'widgets[map-widget-1].maps[test-map]' + } + }; + const updatePropertySpy = expect.createSpy(); + + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + // Should render the legend preview + expect(container.querySelector('.ms-wizard')).toBeTruthy(); + expect(container.querySelector('.legend-preview-container')).toBeTruthy(); + expect(container.querySelector('.empty-state-container')).toBeFalsy(); + }); + + it('LegendWizard shows empty state when not valid', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + // Should show empty state when not valid + expect(container.querySelector('.empty-state-container')).toBeTruthy(); + }); + + it('LegendWizard handles step navigation', () => { + const setPageSpy = expect.createSpy(); + + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + // Should render wizard container + expect(container.querySelector('.ms-wizard')).toBeTruthy(); + }); + + it('LegendWizard passes updateProperty to LegendPreview', () => { + const updatePropertySpy = expect.createSpy(); + const LAYERS = [{ + id: 'layer:00', + name: 'layer:00', + title: 'Layer', + visibility: true, + type: 'wms' + }]; + const dependencies = { layers: LAYERS }; + + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + // Should render wizard with updateProperty prop passed + expect(container.querySelector('.ms-wizard')).toBeTruthy(); + expect(container.querySelector('.legend-preview-container')).toBeTruthy(); + }); + + it('LegendWizard passes dependenciesMap from data prop', () => { + const LAYERS = [{ + id: 'layer:00', + name: 'layer:00', + title: 'Layer', + visibility: true, + type: 'wms' + }]; + const dependencies = { layers: LAYERS }; + const data = { + dependenciesMap: { + layers: 'widgets[map-widget-1].maps[test-map]' + } + }; + + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + expect(container.querySelector('.ms-wizard')).toBeTruthy(); + expect(container.querySelector('.legend-preview-container')).toBeTruthy(); + }); }); diff --git a/web/client/components/widgets/builder/wizard/chart/ChartClassification.jsx b/web/client/components/widgets/builder/wizard/chart/ChartClassification.jsx index 4d0d48f73e3..7f5e4cf0d23 100644 --- a/web/client/components/widgets/builder/wizard/chart/ChartClassification.jsx +++ b/web/client/components/widgets/builder/wizard/chart/ChartClassification.jsx @@ -268,6 +268,7 @@ const ChartClassification = ({ const selectedAttribute = options.find((option) => option.value === classificationAttribute); const { filter, ...trace } = data; // remove filter to compute complete classification const disableClassificationAttribute = traces && traces.length > 1; + const hideClassificationAttribute = data.type === 'line'; return ( <> {!disableClassificationAttribute && @@ -312,18 +313,20 @@ const ChartClassification = ({ /> - - - - onChange('sortBy', option?.value)} + /> + + + )} @@ -374,30 +377,34 @@ const ChartClassification = ({ - - - - color && onChangeStyle('marker.line.color', color)} - /> - - - - - - onChangeStyle('marker.line.width', eventValue)} - /> - - + {!hideClassificationAttribute && ( + <> + + + + color && onChangeStyle('marker.line.color', color)} + /> + + + + + + onChangeStyle('marker.line.width', eventValue)} + /> + + + + )} ); }; diff --git a/web/client/components/widgets/builder/wizard/chart/ChartStyleEditor.jsx b/web/client/components/widgets/builder/wizard/chart/ChartStyleEditor.jsx index 378a52a9f2b..602b4df8d96 100644 --- a/web/client/components/widgets/builder/wizard/chart/ChartStyleEditor.jsx +++ b/web/client/components/widgets/builder/wizard/chart/ChartStyleEditor.jsx @@ -14,28 +14,76 @@ import Select from "react-select"; import Message from "../../../../I18N/Message"; import ChartClassification from './ChartClassification'; import set from 'lodash/fp/set'; +import { DEFAULT_CLASSIFICATION } from '../../../../../utils/WidgetsUtils'; const chartStyleEditors = { - line: ({ data, onChangeStyle }) => { + line: ({ data, onChangeStyle, options, onChange }) => { + const msMode = data?.style?.msMode || 'simple'; const mode = data?.style?.mode || 'lines'; + const classificationData = { + ...data, + style: { + ...data?.style, + msClassification: data?.style?.msClassification || DEFAULT_CLASSIFICATION + } + }; + + // Filter mode options based on msMode + const modeOptions = [ + { value: 'lines+markers', label: 'Line with markers' }, + { value: 'lines', label: 'Line' }, + { value: 'markers', label: 'Scatter' } + ].filter(option => { + // Hide 'lines+markers' when classification style is selected + if (msMode === 'classification' && option.value === 'lines+markers') { + return false; + } + return true; + }); + + const handleMsModeChange = (newMsMode) => { + onChangeStyle('msMode', newMsMode); + if (newMsMode === 'classification' && mode === 'lines+markers') { + onChangeStyle('mode', 'lines'); + } + }; + return ( <> onChangeStyle('mode', option?.value)} /> - {mode !== 'markers' && <> + {msMode === 'classification' && ( + + )} + {msMode === 'simple' && mode !== 'markers' && <> @@ -61,7 +109,7 @@ const chartStyleEditors = { } - {mode !== 'lines' && <> + {msMode === 'simple' && mode !== 'lines' && <> diff --git a/web/client/components/widgets/builder/wizard/chart/__tests__/ChartStyleEditor-test.jsx b/web/client/components/widgets/builder/wizard/chart/__tests__/ChartStyleEditor-test.jsx index 16ed2ff0b27..4c7c2aa8aa2 100644 --- a/web/client/components/widgets/builder/wizard/chart/__tests__/ChartStyleEditor-test.jsx +++ b/web/client/components/widgets/builder/wizard/chart/__tests__/ChartStyleEditor-test.jsx @@ -32,6 +32,7 @@ describe('ChartStyleEditor', () => { data={{ type: 'line', style: { + msMode: 'simple', mode: 'lines' } }} @@ -39,6 +40,7 @@ describe('ChartStyleEditor', () => { const controlLabelsNodes = document.querySelectorAll('.control-label'); expect([...controlLabelsNodes].map(node => node.innerText)).toEqual([ 'widgets.advanced.mode', + 'widgets.advanced.type', 'widgets.advanced.lineColor', 'widgets.advanced.lineWidth' ]); @@ -48,6 +50,7 @@ describe('ChartStyleEditor', () => { data={{ type: 'line', style: { + msMode: 'simple', mode: 'markers' } }} @@ -55,6 +58,7 @@ describe('ChartStyleEditor', () => { const controlLabelsNodes = document.querySelectorAll('.control-label'); expect([...controlLabelsNodes].map(node => node.innerText)).toEqual([ 'widgets.advanced.mode', + 'widgets.advanced.type', 'widgets.advanced.markerColor', 'widgets.advanced.markerSize' ]); @@ -64,6 +68,7 @@ describe('ChartStyleEditor', () => { data={{ type: 'line', style: { + msMode: 'simple', mode: 'lines+markers' } }} @@ -71,12 +76,32 @@ describe('ChartStyleEditor', () => { const controlLabelsNodes = document.querySelectorAll('.control-label'); expect([...controlLabelsNodes].map(node => node.innerText)).toEqual([ 'widgets.advanced.mode', + 'widgets.advanced.type', 'widgets.advanced.lineColor', 'widgets.advanced.lineWidth', 'widgets.advanced.markerColor', 'widgets.advanced.markerSize' ]); }); + it('should render line chart style editor (mode classification style)', () => { + ReactDOM.render(, document.getElementById('container')); + const controlLabelsNodes = document.querySelectorAll('.control-label'); + expect([...controlLabelsNodes].map(node => node.innerText)).toEqual([ + 'widgets.advanced.mode', + 'widgets.advanced.type', + 'widgets.builder.wizard.classAttributes.classificationAttribute', + 'styleeditor.method', + 'styleeditor.colorRamp', + 'styleeditor.intervals' + ]); + }); it('should render bar chart style editor (nsMode simple)', () => { ReactDOM.render( { data={{ type: 'line', style: { + msMode: 'simple', mode: 'lines', line: { color: '#ff0000', @@ -149,6 +175,7 @@ describe('ChartStyleEditor', () => { try { expect(key).toBe('style'); expect(value).toEqual({ + msMode: 'simple', mode: 'lines', line: { color: '#ff0000', @@ -165,13 +192,14 @@ describe('ChartStyleEditor', () => { const controlLabelsNodes = document.querySelectorAll('.control-label'); expect([...controlLabelsNodes].map(node => node.innerText)).toEqual([ 'widgets.advanced.mode', + 'widgets.advanced.type', 'widgets.advanced.lineColor', 'widgets.advanced.lineWidth' ]); const inputsNodes = document.querySelectorAll('input'); - expect(inputsNodes.length).toBe(2); - Simulate.focus(inputsNodes[1]); - Simulate.change(inputsNodes[1], { target: { value: 3 } }); + expect(inputsNodes.length).toBe(3); + Simulate.focus(inputsNodes[2]); + Simulate.change(inputsNodes[2], { target: { value: 3 } }); }); }); diff --git a/web/client/components/widgets/builder/wizard/map/MapOptions.jsx b/web/client/components/widgets/builder/wizard/map/MapOptions.jsx index 78ebb4479c0..0d923c56157 100644 --- a/web/client/components/widgets/builder/wizard/map/MapOptions.jsx +++ b/web/client/components/widgets/builder/wizard/map/MapOptions.jsx @@ -13,17 +13,21 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import {compose, withProps} from 'recompose'; +import { FormGroup, Checkbox, Nav, NavItem as BSNavItem, Glyphicon, Alert } from 'react-bootstrap'; import Message from '../../../../I18N/Message'; import emptyState from '../../../../misc/enhancers/emptyState'; import localizeStringMap from '../../../../misc/enhancers/localizeStringMap'; import StepHeader from '../../../../misc/wizard/StepHeader'; +import tooltip from '../../../../misc/enhancers/tooltip'; import nodeEditor from './enhancers/nodeEditor'; import NodeEditorComp from './NodeEditor'; import TOCComp from './TOC'; +const NavItem = tooltip(BSNavItem); + const TOC = emptyState( ({ map = {} } = {}) => !map.layers || (map.layers || []).filter(l => l.group !== 'background').length === 0, () => ({ @@ -42,26 +46,135 @@ const EditorTitle = compose( localizeStringMap('title') )(StepHeader); +// Map View Options +const MapViewOptions = ({ map, onChange = () => {}, widgets = [], widgetId }) => { + const mapPath = `maps[${map.mapId}]`; + + // Check if there's a legend widget that depends on this map widget (any map within it) + const hasLegendWidget = useMemo(() => widgets.some(widget => + widget.widgetType === 'legend' && + widget.dependenciesMap?.layers?.startsWith(`widgets[${widgetId}].maps`) + ), [widgets, widgetId]); + + const handleShowLegendChange = (checked) => { + onChange(`${mapPath}.showLegend`, checked); + }; -export default ({ preview, map = {}, onChange = () => { }, selectedNodes = [], onNodeSelect = () => { }, editNode, closeNodeEditor = () => { }, isLocalizedLayerStylesEnabled }) => (
- } /> -
- } /> -
- {preview} + return ( +
+ {hasLegendWidget && ( + + + + )} + + onChange(`${mapPath}.showBackgroundSelector`, e.target.checked)} + > + + + + + handleShowLegendChange(e.target.checked)} + > + + +
-
- {editNode - ? [, + ); +}; + +// TOC/Layer Editor +const LayersTab = ({ + map = {}, + editNode, + closeNodeEditor = () => {}, + onChange = () => {}, + selectedNodes = [], + onNodeSelect = () => {}, + isLocalizedLayerStylesEnabled +}) => ( + editNode + ? [ + , - ] : [} />, ]} -
); + isLocalizedLayerStylesEnabled={isLocalizedLayerStylesEnabled} + /> + ] : [ + + ] +); + +const mapOptionTabs = [ + { + id: 'toc', + tooltipId: 'layers', + glyph: '1-layer', + visible: true, + Component: LayersTab + }, { + id: 'settings', + tooltipId: 'settings', + glyph: 'wrench', + visible: true, + Component: MapViewOptions + } +]; + +export default ({ preview, widgets = [], widgetId, ...props }) => { + const [activeTab, setActiveTab] = useState('toc'); + return ( + <> +
+ } /> + +
+ } /> +
+ {preview} +
+
+
+
+
+ +
+
+ {mapOptionTabs.filter(tab => tab.id && tab.id === activeTab) + .filter(tab => tab.Component).map(tab => ( + + ))} +
+
+ + ); +}; diff --git a/web/client/components/widgets/builder/wizard/map/__tests__/MapOptions-test.jsx b/web/client/components/widgets/builder/wizard/map/__tests__/MapOptions-test.jsx index e3c9a911e31..98fc9d87b4a 100644 --- a/web/client/components/widgets/builder/wizard/map/__tests__/MapOptions-test.jsx +++ b/web/client/components/widgets/builder/wizard/map/__tests__/MapOptions-test.jsx @@ -11,6 +11,7 @@ import React from 'react'; import {DragDropContext as dragDropContext} from 'react-dnd'; import testBackend from 'react-dnd-test-backend'; import ReactDOM from 'react-dom'; +import { Simulate } from 'react-dom/test-utils'; import MapOptionsComp from '../MapOptions'; @@ -26,19 +27,23 @@ describe('MapOptions component', () => { document.body.innerHTML = ''; setTimeout(done); }); + it('MapOptions rendering with defaults', () => { ReactDOM.render(, document.getElementById("container")); const container = document.getElementById('container'); expect(container.querySelector('.mapstore-step-title')).toBeTruthy(); - // renders the TOC - expect(container.querySelector('.ms-layers-tree')).toBeFalsy(); + // renders the TOC tab by default + expect(container.querySelector('.ms-row-tab')).toBeTruthy(); expect(container.querySelector('.empty-state-container')).toBeTruthy(); - // not the Editor - expect(container.querySelector('.ms-row-tab')).toBeFalsy(); }); - it('MapOptions rendering layers', () => { + + it('MapOptions rendering with layers', () => { ReactDOM.render(, document.getElementById("container")); @@ -47,19 +52,211 @@ describe('MapOptions component', () => { // renders the TOC expect(container.querySelector('.ms-layers-tree')).toBeTruthy(); expect(container.querySelector('.empty-state-container')).toBeFalsy(); - // not the Editor - expect(container.querySelector('.ms-row-tab')).toBeFalsy(); }); + it('MapOptions rendering node editor', () => { ReactDOM.render(, document.getElementById("container")); + editNode={"LAYER"} + />, document.getElementById("container")); const container = document.getElementById('container'); // renders the editor expect(container.querySelector('.ms-row-tab')).toBeTruthy(); // not the TOC expect(container.querySelector('.ms-layers-tree')).toBeFalsy(); }); + + it('MapOptions renders preview section', () => { + const mockPreview =
Preview Content
; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + expect(container.querySelector('.test-preview')).toBeTruthy(); + expect(container.querySelector('.test-preview').textContent).toBe('Preview Content'); + }); + + it('MapOptions tab navigation works correctly', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + // Initially TOC tab should be active + expect(container.querySelector('.ms-row-tab')).toBeTruthy(); + const settingsTab = container.querySelectorAll('.nav-tabs > li')[1].querySelector('a'); + // Find and click the settings tab + expect(settingsTab).toBeTruthy(); + Simulate.click(settingsTab); + + // After clicking settings tab, should show settings content + expect(document.querySelector('.widget-options-form')).toBeTruthy(); + }); + + it('MapViewOptions renders checkboxes correctly', (done) => { + const map = { + mapId: 'test-map', + showBackgroundSelector: true, + showLegend: false + }; + + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + // Switch to settings tab + const settingsTab = container.querySelectorAll('.nav-tabs > li')[1].querySelector('a'); + expect(settingsTab).toBeTruthy(); + Simulate.click(settingsTab); + + // Wait for the component to render and then check checkboxes + setTimeout(() => { + // Check background selector checkbox + const backgroundCheckbox = container.querySelector('input[type="checkbox"]'); + expect(backgroundCheckbox).toBeTruthy(); + expect(backgroundCheckbox.checked).toBe(true); + + // Check legend checkbox + const legendCheckbox = container.querySelectorAll('input[type="checkbox"]')[1]; + expect(legendCheckbox).toBeTruthy(); + expect(legendCheckbox.checked).toBe(false); + done(); + }, 10); + }); + + it('MapViewOptions checkbox interactions trigger onChange', (done) => { + const map = { + mapId: 'test-map', + showBackgroundSelector: false, + showLegend: false + }; + + const onChangeSpy = expect.createSpy(); + + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + // Switch to settings tab + const settingsTab = container.querySelectorAll('.nav-tabs > li')[1].querySelector('a'); + expect(settingsTab).toBeTruthy(); + Simulate.click(settingsTab); + + // Wait for the component to render and then interact with checkboxes + setTimeout(() => { + // Click background selector checkbox + const backgroundCheckbox = container.querySelector('input[type="checkbox"]'); + expect(backgroundCheckbox).toBeTruthy(); + Simulate.change(backgroundCheckbox, { target: { checked: true } }); + + expect(onChangeSpy).toHaveBeenCalledWith('maps[test-map].showBackgroundSelector', true); + + // Click legend checkbox + const legendCheckbox = container.querySelectorAll('input[type="checkbox"]')[1]; + expect(legendCheckbox).toBeTruthy(); + Simulate.change(legendCheckbox, { target: { checked: true } }); + + expect(onChangeSpy).toHaveBeenCalledWith('maps[test-map].showLegend', true); + done(); + }, 10); + }); + + it('MapViewOptions shows legend warning when legend widget exists', (done) => { + const map = { + mapId: 'test-map', + showLegend: true + }; + + const widgets = [{ + widgetType: 'legend', + dependenciesMap: { + layers: 'widgets[test-widget].maps' + } + }]; + + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + // Switch to settings tab + const settingsTab = container.querySelectorAll('.nav-tabs > li')[1].querySelector('a'); + expect(settingsTab).toBeTruthy(); + Simulate.click(settingsTab); + + // Wait for the component to render and then check for warning + setTimeout(() => { + // Should show warning alert + expect(container.querySelector('.alert-warning')).toBeTruthy(); + done(); + }, 10); + }); + + it('MapViewOptions does not show warning when no legend widget exists', (done) => { + const map = { + mapId: 'test-map', + showLegend: true + }; + + const widgets = [{ + widgetType: 'chart', + dependenciesMap: { + layers: 'widgets[test-widget].maps' + } + }]; + + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + // Switch to settings tab + const settingsTab = container.querySelectorAll('.nav-tabs > li')[1].querySelector('a'); + expect(settingsTab).toBeTruthy(); + Simulate.click(settingsTab); + + // Wait for the component to render and then check for no warning + setTimeout(() => { + // Should not show warning alert + expect(container.querySelector('.alert-warning')).toBeFalsy(); + done(); + }, 10); + }); + + it('MapViewOptions handles legend widget with different dependency path', (done) => { + const map = { + mapId: 'test-map', + showLegend: true + }; + + const widgets = [{ + widgetType: 'legend', + dependenciesMap: { + layers: 'widgets[different-widget].maps' + } + }]; + + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + + // Switch to settings tab + const settingsTab = container.querySelectorAll('.nav-tabs > li')[1].querySelector('a'); + expect(settingsTab).toBeTruthy(); + Simulate.click(settingsTab); + + // Wait for the component to render and then check for no warning + setTimeout(() => { + // Should not show warning alert since dependency path doesn't match + expect(container.querySelector('.alert-warning')).toBeFalsy(); + done(); + }, 10); + }); }); diff --git a/web/client/components/widgets/enhancers/tools/withMenu.js b/web/client/components/widgets/enhancers/tools/withMenu.js index f4fe7c16613..1dd8f8edcb2 100644 --- a/web/client/components/widgets/enhancers/tools/withMenu.js +++ b/web/client/components/widgets/enhancers/tools/withMenu.js @@ -26,7 +26,7 @@ const MenuItem = tooltip(MenuItemBS); * transform `widgetTools` property items with `target` = `menu` into a DropDown button to put in `topRightItems` for WidgetContainer, as a menu */ export default ({ className = "widget-menu", menuIcon = "option-vertical"} = {}) => - withProps(({ widgetTools, topRightItems = [], items = [], layer, id }) => ({ + withProps(({ widgetTools, topRightItems = [], items = [], layer, id, map, widgetType }) => ({ topRightItems: hasMenuItems(widgetTools) ? [...topRightItems, ( @@ -48,6 +48,8 @@ export default ({ className = "widget-menu", menuIcon = "option-vertical"} = {}) key={item.name} layer={layer} widgetId={id} + map={map} + widgetType={widgetType} itemComponent={(props) => ( diff --git a/web/client/components/widgets/widget/MapWidget.jsx b/web/client/components/widgets/widget/MapWidget.jsx index 40a37144aeb..7d958167f80 100644 --- a/web/client/components/widgets/widget/MapWidget.jsx +++ b/web/client/components/widgets/widget/MapWidget.jsx @@ -6,23 +6,80 @@ * LICENSE file in the root directory of this source tree. */ -import { omit } from 'lodash'; +import { omit, isEqual } from 'lodash'; import React from 'react'; -import { withHandlers } from 'recompose'; +import { compose, withHandlers, withProps } from 'recompose'; import BorderLayout from '../../layout/BorderLayout'; import LoadingSpinner from '../../misc/LoadingSpinner'; import MapViewComp from './MapView'; import WidgetContainer from './WidgetContainer'; import MapSwitcher from "../builder/wizard/map/MapSwitcher"; +import BackgroundSelector from '../../background/BackgroundSelector'; +import LegendViewComponent from './LegendView'; import { getDerivedLayersVisibility } from "../../../utils/LayersUtils"; const MapView = withHandlers({ - onMapViewChanges: ({ updateProperty = () => { }, id }) => ({layers, ...map}) => updateProperty(id, 'maps', map, "merge") + onMapViewChanges: ({ onUpdateMapProperty = () => { }}) => ({layers, ...map}) => onUpdateMapProperty(map) })(MapViewComp); -export default ({ +const LegendView = withHandlers({ + updateProperty: ({ onUpdateMapProperty = () => { }, map }) => (_, value) => { + const newLayers = map.layers?.map(layer => { + const updateLayer = value?.layers.find(l => l.id === layer.id); + if (updateLayer) { + return { + ...layer, + visibility: updateLayer.visibility, + opacity: updateLayer.opacity, + expanded: updateLayer.expanded, + layerFilter: updateLayer.layerFilter + }; + } + return layer; + }); + const groups = map.groups?.map(group => { + const updateGroup = value?.groups.find(g => g.id === group.id); + if (updateGroup) { + return { + ...group, + visibility: updateGroup.visibility, + expanded: updateGroup.expanded + }; + } + return group; + }); + if (!isEqual(map.layers, newLayers) || !isEqual(map.groups, groups)) { + onUpdateMapProperty({ ...map, layers: newLayers, groups }); + } + } +})(LegendViewComponent); + +const BackgroundSelectorWithHandlers = withHandlers({ + onPropertiesChange: ({ onUpdateMapProperty, map }) => (layerId, properties) => { + if (!layerId) { + return; + } + const newLayers = map.layers?.map(layer => { + if (layer.group === 'background') { + const updatedLayer = { ...layer, visibility: false }; + // set the selected background layer to visible + if (layer.id === layerId) { + return { ...updatedLayer, visibility: true, ...properties }; + } + return updatedLayer; + } + return layer; + }); + if (!isEqual(map.layers, newLayers)) { + onUpdateMapProperty({ ...map, layers: newLayers }); + } + } +})(BackgroundSelector); + +const MapWidgetComponent = ({ updateProperty = () => { }, + onUpdateMapProperty = () => { }, toggleDeleteConfirm = () => { }, id, title, map = {}, @@ -31,7 +88,7 @@ export default ({ icons, hookRegister, mapStateSource, - topRightItems, + topRightItems = [], options = {}, confirmDelete = false, loading = false, @@ -39,14 +96,21 @@ export default ({ onDelete = () => {}, headerStyle, env, - selectionActive -} = {}) => { + selectionActive, + currentZoomLvl, + scales, + language, + currentLocale +}) => { const { size: {height: mapHeight, width: mapWidth} = {}, mapInfoControl } = map; - const enablePopupTools = mapHeight > 400 && mapWidth > 400 && mapInfoControl; + const backgroundLayers = (map.layers || []).filter(layer => layer.group === 'background'); + const enableViewerTools = mapHeight > 400 && mapWidth > 400 && mapInfoControl; + return ( updateProperty(id, ...args)} @@ -65,20 +129,69 @@ export default ({
: null }> - +
+ + {enableViewerTools && <> + {map.showBackgroundSelector && backgroundLayers?.length > 0 && ( + + )} + {map.showLegend && ( +
+ +
+ )} + } +
- ); }; + +const MapWidget = compose( + withProps(({ selectedMapId }) => ({ selectedMapId })), + withHandlers({ + onUpdateMapProperty: ({updateProperty = () => {}, id, selectedMapId}) => (value) => { + // Include mapId in the value so the reducer knows which map to update + const valueWithMapId = selectedMapId ? { ...value, mapId: selectedMapId } : value; + updateProperty(id, "maps", valueWithMapId, "merge"); + } + }) +)(MapWidgetComponent); + +export default MapWidget; diff --git a/web/client/components/widgets/widget/__tests__/MapWidget-test.jsx b/web/client/components/widgets/widget/__tests__/MapWidget-test.jsx index d3903e13869..3050fc74928 100644 --- a/web/client/components/widgets/widget/__tests__/MapWidget-test.jsx +++ b/web/client/components/widgets/widget/__tests__/MapWidget-test.jsx @@ -34,8 +34,8 @@ describe('MapWidget component', () => { it('MapWidget rendering with defaults', () => { ReactDOM.render( {}, getState: () => ({maptype: {mapType: 'openlayers'}})}} >, document.getElementById("container")); const container = document.getElementById('container'); - expect(container.querySelector('.glyphicon-pencil')).toExist(); - expect(container.querySelector('.glyphicon-trash')).toExist(); + expect(container.querySelector('.glyphicon-pencil')).toBeTruthy(); + expect(container.querySelector('.glyphicon-trash')).toBeTruthy(); }); it('view only mode', () => { ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); @@ -43,4 +43,114 @@ describe('MapWidget component', () => { expect(container.querySelector('.glyphicon-pencil')).toNotExist(); expect(container.querySelector('.glyphicon-trash')).toNotExist(); }); + it('renders MapSwitcher when multiple maps are provided', () => { + const maps = [ + { mapId: 'map1', title: 'Map 1' }, + { mapId: 'map2', title: 'Map 2' } + ]; + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); + const container = document.getElementById('container'); + expect(container.querySelector('.map-switcher')).toBeTruthy(); + }); + it('renders BackgroundSelector when showBackgroundSelector is true and background layers exist', () => { + const map = { + size: { height: 500, width: 500 }, + layers: [ + { id: 'bg1', group: 'background', visibility: true }, + { id: 'layer1', group: 'overlay', visibility: true } + ], + showBackgroundSelector: true, + mapInfoControl: true + }; + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} > + , document.getElementById("container")); + const container = document.getElementById('container'); + expect(container.querySelector('.ms-background-selector')).toBeTruthy(); + }); + it('does not render BackgroundSelector when showBackgroundSelector is false', () => { + const map = { + size: { height: 500, width: 500 }, + layers: [ + { id: 'bg1', group: 'background', visibility: true } + ], + showBackgroundSelector: false, + mapInfoControl: true + }; + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); + const container = document.getElementById('container'); + expect(container.querySelector('.ms-background-selector')).toNotExist(); + }); + it('renders LegendView when showLegend is true', () => { + const map = { + size: { height: 500, width: 500 }, + layers: [], + showLegend: true, + mapInfoControl: true + }; + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); + const container = document.getElementById('container'); + expect(container.querySelector('.legend-in-mapview')).toBeTruthy(); + }); + it('does not render LegendView when showLegend is false', () => { + const map = { + size: { height: 500, width: 500 }, + layers: [], + showLegend: false, + mapInfoControl: true + }; + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); + const container = document.getElementById('container'); + expect(container.querySelector('.legend-in-mapview')).toNotExist(); + }); + it('renders loading spinner when loading is true', () => { + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); + const container = document.getElementById('container'); + expect(container.querySelector('.widget-footer')).toBeTruthy(); + expect(container.querySelector('.mapstore-inline-loader')).toBeTruthy(); + }); + it('does not render loading spinner when loading is false', () => { + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); + const container = document.getElementById('container'); + expect(container.querySelector('.widget-footer')).toNotExist(); + }); + it('enables viewer tools when map is large enough and has mapInfoControl', () => { + const map = { + size: { height: 500, width: 500 }, + layers: [], + mapInfoControl: true + }; + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); + const container = document.getElementById('container'); + // MapView should have popup tools enabled + expect(container.querySelector('.map-widget-view-content')).toBeTruthy(); + }); + it('disables viewer tools when map is too small', () => { + const map = { + size: { height: 300, width: 300 }, + layers: [], + mapInfoControl: true + }; + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); + const container = document.getElementById('container'); + // BackgroundSelector and LegendView should not be rendered when tools are disabled + expect(container.querySelector('.ms-background-selector')).toNotExist(); + expect(container.querySelector('.legend-in-mapview')).toNotExist(); + }); + it('disables MapSwitcher when selectionActive is true', () => { + const maps = [ + { mapId: 'map1', title: 'Map 1' }, + { mapId: 'map2', title: 'Map 2' } + ]; + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); + const container = document.getElementById('container'); + const mapSwitcher = container.querySelector('.map-switcher'); + expect(mapSwitcher).toBeTruthy(); + expect(mapSwitcher.classList.contains('is-disabled')).toBeTruthy(); + }); + it('renders with custom topRightItems', () => { + const customItem =
Custom Item
; + ReactDOM.render( { }, getState: () => ({ maptype: { mapType: 'openlayers' } }) }} >, document.getElementById("container")); + const container = document.getElementById('container'); + expect(container.querySelector('.custom-top-item')).toBeTruthy(); + }); }); diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index 7dcc0277c80..318fc989144 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -30,7 +30,6 @@ "mapboxAccessToken": "__ACCESS_TOKEN_MAPBOX__", "initialMapFilter": "", "ignoreMobileCss": false, - "useAuthenticationRules": true, "loadAfterTheme": true, "defaultMapOptions": { "cesium": { @@ -48,14 +47,18 @@ "localizedLayerStyles": { "name": "mapstore_language" }, - "authenticationRules": [ + "requestsConfigurationRules": [ { - "urlPattern": ".*geostore.*", - "method": "bearer" + "urlPattern": ".*rest/geostore.*", + "headers": { + "Authorization": "Bearer ${securityToken}" + } }, { "urlPattern": ".*rest/config.*", - "method": "bearer" + "headers": { + "Authorization": "Bearer ${securityToken}" + } } ], "monitorState": [ @@ -963,6 +966,12 @@ "name": "Dashboard" }, "Notifications", + { + "name": "MapEditor", + "cfg": { + "titleMsgId": "widgets.mapWidget.mapEditorTitle" + } + }, { "name": "About", "cfg": { @@ -1239,6 +1248,7 @@ "UserManager", "GroupManager", "TagsManager", + "IPManager", "Footer", { "name": "About" } ] diff --git a/web/client/epics/__tests__/longitudinalProfile-test.js b/web/client/epics/__tests__/longitudinalProfile-test.js index a492f1f5bec..037149b8b17 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/__tests__/mapEditor-test.js b/web/client/epics/__tests__/mapEditor-test.js index 3b2464a6076..bee0c31bcc0 100644 --- a/web/client/epics/__tests__/mapEditor-test.js +++ b/web/client/epics/__tests__/mapEditor-test.js @@ -11,16 +11,19 @@ import expect from 'expect'; import {testEpic} from './epicTestUtils'; import { - mapEditorConfigureMapState + mapEditorConfigureMapState, + mapEditorClose } from '../mapEditor'; import { - show + show, + hide } from '../../actions/mapEditor'; import { LOAD_MAP_CONFIG, MAP_CONFIG_LOADED } from '../../actions/config'; import {REMOVE_ALL_ADDITIONAL_LAYERS} from '../../actions/additionallayers'; import {RESET_CONTROLS} from '../../actions/controls'; import {CLEAR_LAYERS} from '../../actions/layers'; +import {CLOSE_FEATURE_GRID} from '../../actions/featuregrid'; describe('MapEditor Epics', () => { @@ -69,4 +72,26 @@ describe('MapEditor Epics', () => { done(); }, {}); }); + describe('mapEditorClose', () => { + it('should close feature grid when map editor is hidden', (done) => { + const NUM_ACTIONS = 1; + + testEpic(mapEditorClose, NUM_ACTIONS, hide(), (actions) => { + expect(actions.length).toEqual(NUM_ACTIONS); + expect(actions[0].type).toBe(CLOSE_FEATURE_GRID); + expect(actions[0].closer).toBeFalsy(); + done(); + }, {}); + }); + it('should close feature grid when map editor is hidden with owner', (done) => { + const NUM_ACTIONS = 1; + + testEpic(mapEditorClose, NUM_ACTIONS, hide('widgetInlineEditor'), (actions) => { + expect(actions.length).toEqual(NUM_ACTIONS); + expect(actions[0].type).toBe(CLOSE_FEATURE_GRID); + expect(actions[0].closer).toBeFalsy(); + done(); + }, {}); + }); + }); }); diff --git a/web/client/epics/__tests__/widgets-test.js b/web/client/epics/__tests__/widgets-test.js index 974fff40707..1fe792c59c8 100644 --- a/web/client/epics/__tests__/widgets-test.js +++ b/web/client/epics/__tests__/widgets-test.js @@ -16,7 +16,8 @@ import { updateLayerOnLayerPropertiesChange, updateLayerOnLoadingErrorChange, updateDependenciesMapOnMapSwitch, - onWidgetCreationFromMap + onWidgetCreationFromMap, + onMapEditorOpenEpic } from '../widgets'; import { @@ -31,7 +32,8 @@ import { DEPENDENCY_SELECTOR_KEY, updateWidgetProperty, REPLACE, - onEditorChange + onEditorChange, + UPDATE_PROPERTY } from '../../actions/widgets'; import { configureMap } from '../../actions/config'; @@ -39,6 +41,7 @@ import { changeLayerProperties, layerLoad, layerError, updateNode } from '../../ import { onLocationChanged } from 'connected-react-router'; import { ActionsObservable } from 'redux-observable'; import Rx from 'rxjs'; +import { HIDE, save } from '../../actions/mapEditor'; describe('widgets Epics', () => { it('clearWidgetsOnLocationChange triggers CLEAR_WIDGETS on LOCATION_CHANGE', (done) => { @@ -674,4 +677,60 @@ describe('widgets Epics', () => { [onEditorChange("widgetType", "chart")], checkActions, state); }); + it('onMapEditorOpenEpic triggers updateWidgetProperty and hide on SAVE with widgetId', (done) => { + const checkActions = actions => { + expect(actions.length).toBe(2); + expect(actions[0].type).toBe(UPDATE_PROPERTY); + expect(actions[0].id).toBe("widget-123"); + expect(actions[0].key).toBe("maps"); + expect(actions[0].value).toEqual({ + widgetId: "widget-123", + mapId: "map-456", + layers: ["layer1", "layer2"], + center: { x: 0, y: 0, crs: "EPSG:4326" }, + zoom: 5 + }); + expect(actions[0].mode).toBe("merge"); + expect(actions[1].type).toBe(HIDE); + expect(actions[1].owner).toBe("widgetInlineEditor"); + done(); + }; + const mapData = { + widgetId: "widget-123", + mapId: "map-456", + layers: ["layer1", "layer2"], + center: { x: 0, y: 0, crs: "EPSG:4326" }, + zoom: 5 + }; + testEpic(onMapEditorOpenEpic, + 2, + [save(mapData, "widgetInlineEditor")], + checkActions); + }); + it('onMapEditorOpenEpic does not trigger actions when map has no widgetId', (done) => { + const checkActions = actions => { + expect(actions.length).toBe(0); + done(); + }; + const mapData = { + mapId: "map-456", + layers: ["layer1", "layer2"], + center: { x: 0, y: 0, crs: "EPSG:4326" }, + zoom: 5 + }; + testEpic(onMapEditorOpenEpic, + 0, + [save(mapData, "widgetInlineEditor")], + checkActions); + }); + it('onMapEditorOpenEpic does not trigger actions when map is undefined', (done) => { + const checkActions = actions => { + expect(actions.length).toBe(0); + done(); + }; + testEpic(onMapEditorOpenEpic, + 0, + [save(undefined, "widgetInlineEditor")], + checkActions); + }); }); diff --git a/web/client/epics/dashboard.js b/web/client/epics/dashboard.js index 5c5a85636d0..7d615a7000e 100644 --- a/web/client/epics/dashboard.js +++ b/web/client/epics/dashboard.js @@ -39,7 +39,7 @@ import { download, readJson } from '../utils/FileUtils'; import { createResource, updateResource, getResource, updateResourceAttribute } from '../api/persistence'; import { wrapStartStop } from '../observables/epics'; import { LOCATION_CHANGE, push } from 'connected-react-router'; -import { convertDependenciesMappingForCompatibility } from "../utils/WidgetsUtils"; +import { convertDependenciesMappingForCompatibility, updateDependenciesForMultiViewCompatibility } from "../utils/WidgetsUtils"; const getFTSelectedArgs = (state) => { let layer = getEditingWidgetLayer(state); let url = layer.search && layer.search.url; @@ -125,7 +125,7 @@ export const loadDashboardStream = (action$, {getState = () => {}}) => action$ .ofType(LOAD_DASHBOARD) .switchMap( ({id}) => getResource(id) - .map(({ data, ...resource }) => dashboardLoaded(resource, convertDependenciesMappingForCompatibility(data))) + .map(({ data, ...resource }) => dashboardLoaded(resource, updateDependenciesForMultiViewCompatibility(convertDependenciesMappingForCompatibility(data)))) .let(wrapStartStop( dashboardLoading(true, "loading"), dashboardLoading(false, "loading"), diff --git a/web/client/epics/longitudinalProfile.js b/web/client/epics/longitudinalProfile.js index 15ee4fbd773..7262382db2e 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/epics/mapEditor.js b/web/client/epics/mapEditor.js index 46720379226..e9727f70973 100644 --- a/web/client/epics/mapEditor.js +++ b/web/client/epics/mapEditor.js @@ -6,13 +6,14 @@ * LICENSE file in the root directory of this source tree. */ import { Observable } from 'rxjs'; -import { SHOW } from '../actions/mapEditor'; +import { HIDE, SHOW } from '../actions/mapEditor'; import { loadMapConfig, configureMap } from '../actions/config'; import {removeAllAdditionalLayers} from '../actions/additionallayers'; import {clearLayers} from '../actions/layers'; import {resetControls} from '../actions/controls'; import isObject from 'lodash/isObject'; import { getConfigUrl } from '../utils/ConfigUtils'; +import { closeFeatureGrid } from '../actions/featuregrid'; /** @@ -37,3 +38,9 @@ export const mapEditorConfigureMapState = (action$) => } return Observable.from([removeAllAdditionalLayers(), resetControls(), clearLayers(), loadAction]); }); + +export const mapEditorClose = (action$) => + action$.ofType(HIDE) + .switchMap(() => { + return Observable.from([closeFeatureGrid()]); // add other panel close actions here, if needed + }); diff --git a/web/client/epics/security.js b/web/client/epics/security.js index c5413b551cd..b5942a06d59 100644 --- a/web/client/epics/security.js +++ b/web/client/epics/security.js @@ -8,14 +8,32 @@ import Rx from 'rxjs'; import uniqBy from 'lodash/uniqBy'; import isArray from 'lodash/isArray'; +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import head from 'lodash/head'; +import castArray from 'lodash/castArray'; +import isEmpty from 'lodash/isEmpty'; +import { v4 as uuidv4 } from 'uuid'; import { DASHBOARD_LOADED } from '../actions/dashboard'; import { SET_CURRENT_STORY } from '../actions/geostory'; import { EDITOR_CHANGE } from '../actions/widgets'; import { UPDATE_ITEM } from '../actions/mediaEditor'; import { currentMediaTypeSelector, selectedItemSelector } from '../selectors/mediaEditor'; import { MAP_CONFIG_LOADED } from '../actions/config'; -import { setShowModalStatus, setProtectedServices } from '../actions/security'; -import { getCredentials } from '../utils/SecurityUtils'; +import { + setShowModalStatus, + setProtectedServices, + loadRequestsRules, + LOAD_REQUESTS_RULES, + UPDATE_REQUESTS_RULES +} from '../actions/security'; +import { + getCredentials, + convertAuthenticationRulesToRequestConfiguration +} from '../utils/SecurityUtils'; +import { LOCAL_CONFIG_LOADED } from '../actions/localConfig'; +import { layersSelector } from '../selectors/layers'; +import { changeLayerProperties } from '../actions/layers'; /** * checks if a content is protected in a map @@ -68,7 +86,7 @@ export const checkProtectedContentDashboardEpic = (action$) => return { protectedId: layer?.security?.sourceId, url: layer.url }; }) .filter(v => !!v.protectedId)); - }, []); + }, []).filter(Boolean); const protectedServices = uniqBy(layers, "protectedId") .map(({ protectedId, url }) => { @@ -196,3 +214,72 @@ export const checkProtectedContentGeostoryEpic = (action$) => return Rx.Observable.of(setShowModalStatus(false)); }); +/** + * Epic to handle loading request configuration rules from config + */ +export const loadRequestsRulesFromConfigEpic = (action$) => + action$.ofType(LOCAL_CONFIG_LOADED) + .switchMap((action) => { + const config = action.config; + let rules = config?.requestsConfigurationRules ?? []; + const legacyRules = config?.authenticationRules ?? []; + const useLegacyRules = config?.useAuthenticationRules ?? false; + if (isEmpty(rules) && !isEmpty(legacyRules) && useLegacyRules) { + rules = convertAuthenticationRulesToRequestConfiguration(legacyRules); + } + return Rx.Observable.of(loadRequestsRules(rules)); + }); + +/** + * Helper function to determine which rules have changed + * Returns an array of URL patterns from rules that have changed + */ +const getChangedRuleUrlPatterns = (oldRules, newRules) => { + const makeMap = (rules) => new Map(rules.filter(r => r?.urlPattern).map(r => [r.urlPattern, r])); + const [oldMap, newMap] = [makeMap(oldRules), makeMap(newRules)]; + const changed = new Set([ + ...[...newMap].filter(([p, n]) => !oldMap.has(p) || !isEqual(oldMap.get(p), n)).map(([p]) => p), + ...[...oldMap].filter(([p]) => !newMap.has(p)).map(([p]) => p) + ]); + return [...changed]; +}; + +/** + * Epic to refresh layers when request configuration rules are updated + * This ensures that layers re-fetch tiles with the new authentication parameters + * Only refreshes layers whose URLs match changed rules + */ +export const refreshLayersOnRulesUpdateEpic = (action$, store) => + action$.ofType(LOAD_REQUESTS_RULES, UPDATE_REQUESTS_RULES) + .switchMap((action) => { + const state = store.getState(); + const newRules = get(action, 'rules', []); + const oldRules = state.security?.previousRules || []; + + // Get URL patterns of rules that have changed + const changedPatterns = getChangedRuleUrlPatterns(oldRules, newRules); + + if (isEmpty(changedPatterns)) { + // No rules changed, no need to refresh + return Rx.Observable.empty(); + } + + const layers = layersSelector(state) || []; + + // Find layers that should be refreshed based on matching changed rules + const layersToUpdate = []; + layers.forEach(layer => { + const url = head(castArray(layer.url)); + + // Check if any layer URL matches any changed rule pattern + const shouldRefresh = changedPatterns.some(pattern => url?.match(new RegExp(pattern, "i"))); + if (shouldRefresh) layersToUpdate.push(layer); + }); + + // Dispatch changeLayerProperties for each matching layer + const actions = layersToUpdate.map(layer => { + return changeLayerProperties(layer.id, { requestRuleRefreshHash: uuidv4() }); + }); + + return actions.length > 0 ? Rx.Observable.from(actions) : Rx.Observable.empty(); + }); diff --git a/web/client/epics/wfsquery.js b/web/client/epics/wfsquery.js index 43019031866..9e4d90e46a9 100644 --- a/web/client/epics/wfsquery.js +++ b/web/client/epics/wfsquery.js @@ -47,7 +47,10 @@ import { import { changeDrawingStatus } from '../actions/draw'; import { getLayerJSONFeature } from '../observables/wfs'; -import { describeFeatureTypeToAttributes } from '../utils/FeatureTypeUtils'; +import { + describeFeatureTypeToAttributes, + describeFeatureTypeToJSONSchema +} from '../utils/FeatureTypeUtils'; import * as notifications from '../actions/notifications'; import { find } from 'lodash'; @@ -55,7 +58,6 @@ import {selectedLayerSelector, useLayerFilterSelector} from '../selectors/featur import {layerLoad} from '../actions/layers'; import { mergeFiltersToOGC } from '../utils/FilterUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const extractInfo = (data, fields = []) => { return { @@ -74,6 +76,7 @@ const extractInfo = (data, fields = []) => { return conf; }), original: data, + attributesJSONSchema: describeFeatureTypeToJSONSchema(data), attributes: describeFeatureTypeToAttributes(data, fields) }; }; @@ -136,8 +139,7 @@ export const featureTypeSelectedEpic = (action$, store) => .mergeAll(); } - const headers = getAuthorizationBasic(selectedLayer?.security?.sourceId); - return Rx.Observable.defer( () => axios.get(ConfigUtils.filterUrlParams(action.url, authkeyParamNameSelector(store.getState())) + '?service=WFS&version=1.1.0&request=DescribeFeatureType&typeName=' + action.typeName + '&outputFormat=application/json', {headers})) + return Rx.Observable.defer( () => axios.get(ConfigUtils.filterUrlParams(action.url, authkeyParamNameSelector(store.getState())) + '?service=WFS&version=1.1.0&request=DescribeFeatureType&typeName=' + action.typeName + '&outputFormat=application/json', {_msAuthSourceId: selectedLayer?.security?.sourceId})) .map((response) => { if (typeof response.data === 'object' && response.data.featureTypes && response.data.featureTypes[0]) { const info = extractInfo(response.data, action.fields); diff --git a/web/client/epics/widgets.js b/web/client/epics/widgets.js index 5ade0d4af24..233f6128f8d 100644 --- a/web/client/epics/widgets.js +++ b/web/client/epics/widgets.js @@ -27,7 +27,8 @@ import { replaceWidgets, WIDGETS_MAPS_REGEX, EDITOR_CHANGE, - OPEN_FILTER_EDITOR + OPEN_FILTER_EDITOR, + updateWidgetProperty } from '../actions/widgets'; import { changeMapEditor } from '../actions/queryform'; @@ -54,6 +55,7 @@ import {reprojectBbox} from '../utils/CoordinatesUtils'; import {json2csv} from 'json-2-csv'; import { defaultGetZoomForExtent } from '../utils/MapUtils'; import { updateDependenciesMapOfMapList, DEFAULT_MAP_SETTINGS } from "../utils/WidgetsUtils"; +import { hide, SAVE } from '../actions/mapEditor'; const updateDependencyMap = (active, targetId, { dependenciesMap, mappings}) => { const tableDependencies = ["layer", "filter", "quickFilters", "options"]; @@ -324,6 +326,16 @@ export const onResetMapEpic = (action$, store) => ); }); +export const onMapEditorOpenEpic = (action$) => + action$.ofType(SAVE) + .filter(({map} = {}) => map?.widgetId) + .switchMap(({map}) => { + return Rx.Observable.of( + updateWidgetProperty(map.widgetId, "maps", map, "merge"), + hide("widgetInlineEditor") + ); + }); + export default { exportWidgetData, alignDependenciesToWidgets, @@ -334,5 +346,6 @@ export default { updateDependenciesMapOnMapSwitch, onWidgetCreationFromMap, onOpenFilterEditorEpic, - onResetMapEpic + onResetMapEpic, + onMapEditorOpenEpic }; diff --git a/web/client/hooks/__tests__/useBatchedUpdates-test.js b/web/client/hooks/__tests__/useBatchedUpdates-test.js new file mode 100644 index 00000000000..73949c194c6 --- /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 00000000000..5081ced15c3 --- /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/libs/__tests__/ajax-test.js b/web/client/libs/__tests__/ajax-test.js index d709e6f4709..c7d4a1cabdf 100644 --- a/web/client/libs/__tests__/ajax-test.js +++ b/web/client/libs/__tests__/ajax-test.js @@ -41,9 +41,6 @@ const securityInfoA = { const userB = Object.assign({}, userA, { name: "adminB", attribute: [{ - name: "UUID", - value: "263c6917-543f-43e3-8e1a-6a0d29952f72" - }, { name: "description", value: "admin user" } @@ -99,6 +96,11 @@ const authenticationRules = [ ]; describe('Tests ajax library', () => { + let originalOProxyURL; + beforeEach(() => { + mockAxios = new MockAdapter(axios); + originalOProxyURL = ConfigUtils.getConfigProp("proxyUrl"); + }); afterEach(() => { if (mockAxios) { mockAxios.restore(); @@ -108,21 +110,38 @@ describe('Tests ajax library', () => { setStore({}); ConfigUtils.setConfigProp("authenticationRules", null); ConfigUtils.setConfigProp("useAuthenticationRules", false); + ConfigUtils.setConfigProp("proxyUrl", originalOProxyURL); }); it('uses proxy for requests not on the same origin', (done) => { - axios.get('http://fakeexternaldomain.mapstore2').then(() => { - done(); - }).catch(ex => { - expect(ex.config).toExist(); - expect(ex.config.url).toExist(); - expect(ex.config.url).toContain('proxy/?url='); + mockAxios.onGet().reply((config) => { + expect(config).toExist(); + expect(config.url).toExist(); + expect(config.url).toContain('proxy/?url='); + return [ 200, { }]; + }); + axios.get('http://fakeexternaldomain.mapstore2', { + proxyUrl: { + url: '/proxy/?url=', + useCORS: [], + autoDetectCORS: false + }}).then(() => { done(); + }).catch((e) => { + + done(e); + }).finally(() => { }); }); it('does not use proxy for requests on the same origin', (done) => { - axios.get('base/web/client/test-resources/testConfig.json').then((response) => { + mockAxios.onGet().reply(200, {}); + axios.get('base/web/client/test-resources/testConfig.json', { + proxyUrl: { + url: '/proxy/?url=', + useCORS: [], + autoDetectCORS: false + }}).then((response) => { expect(response.config).toExist(); expect(response.config.url).toExist(); expect(response.config.url).toBe('base/web/client/test-resources/testConfig.json'); @@ -133,6 +152,9 @@ describe('Tests ajax library', () => { }); it('uses a custom proxy for requests on the same origin with varius query params', (done) => { + mockAxios.onGet().reply(200, {}, { + "allow-control-allow-origin": "*" + }); axios.get('http://fakeexternaldomain.mapstore2', { proxyUrl: '/proxy/?url=', params: { @@ -160,6 +182,11 @@ describe('Tests ajax library', () => { }); it('ignore undefined and null query params with custom proxy', (done) => { + mockAxios.onGet().reply((config) => { + const decodedUrl = urlUtil.parse(decodeURIComponent(config.url), true); + expect(decodedUrl.query).toNotContainKeys(['param3', 'param4', 'param11']); + return [200, {}]; + }); axios.get('http://fakeexternaldomain.mapstore2', { proxyUrl: '/proxy/?url=', params: { @@ -181,19 +208,24 @@ describe('Tests ajax library', () => { .then(() => { done(); }) - .catch((ex) => { - const decodedUrl = urlUtil.parse(decodeURIComponent(ex.config.url), true); - expect(decodedUrl.query).toNotContainKeys(['param3', 'param4', 'param11']); - done(); + .catch((e) => { + + done(e); }); }); - it('uses a custom proxy for requests on the same origin with string query param', (done) => { + it('uses a custom proxy for requests on the different origin origin with string query param', (done) => { + mockAxios.onGet().reply(200, {}); axios.get('http://fakeexternaldomain.mapstore2', { - proxyUrl: '/proxy/?url=', + proxyUrl: { + url: '/proxy/?url=', + useCORS: [], + autoDetectCORS: false + }, params: "params" }) .then(() => { + done(); }) .catch((ex) => { @@ -205,83 +237,91 @@ describe('Tests ajax library', () => { }); it('does not use proxy for requests to CORS enabled urls', (done) => { - axios.get('http://www.google.com', { + mockAxios.onGet().reply((config) => { + expect(config).toExist(); + expect(config.url).toExist(); + expect(config.url).toBe('http://www.fakedomain.com'); + return [ 200, { }]; + }); + axios.get('http://www.fakedomain.com', { timeout: 1, proxyUrl: { url: '/proxy/?url=', - useCORS: ['http://www.google.com'] + useCORS: ['http://www.fakedomain.com'], + autoDetectCORS: false } }).then(() => { done(); }).catch((ex) => { - expect(ex.code).toBe("ECONNABORTED"); - done(); + done(ex); }); }); - it('does use proxy for requests on not CORS enabled urls', (done) => { + it('Use proxy for requests on not CORS enabled urls', (done) => { + mockAxios.onGet().reply((config) => { + expect(config).toExist(); + expect(config.url).toExist(); + expect(config.url).toContain('proxy/?url='); + return [ 200, { }]; + }); axios.get('http://notcors.mapstore2', { proxyUrl: { url: '/proxy/?url=', - useCORS: ['http://cors.mapstore2'] + useCORS: ['http://cors.mapstore2'], + autoDetectCORS: false } }).then(() => { done(); }).catch((ex) => { - expect(ex.config).toExist(); - expect(ex.config.url).toExist(); - expect(ex.config.url).toContain('proxy/?url='); - done(); + done(ex); }); }); - - it('test add authkey authentication to axios config with no login', (done) => { - axios.get('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2').then(() => { - done(); - }).catch((exception) => { - expect(exception.config).toExist(); - expect(exception.config.url).toExist(); - expect(exception.config.url.indexOf('authkey')).toBeLessThan(0); + it('Do not use proxy on first request when autoDetectCORS is not false', (done) => { + mockAxios.onGet().reply((config) => { + expect(config).toExist(); + expect(config.url).toExist(); + expect(config.url).toBe('http://cors.mapstore2'); + return [ 200, { }]; + }); + axios.get('http://cors.mapstore2', { + proxyUrl: { + url: '/proxy/?url=' + } + }).then(() => { done(); + }).catch((ex) => { + done(ex); }); }); - it('test add authkey authentication to axios config with login but no uuid', (done) => { + it('test add authkey authentication to axios config with no login', (done) => { + mockAxios.onGet().reply(config => { + expect(config).toExist(); + expect(config.url).toExist(); + expect(config.url.indexOf('authkey')).toBeLessThan(0); + return [200, {}]; + + }); axios.get('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2').then(() => { done(); - }).catch((exception) => { - expect(exception.config).toExist(); - expect(exception.config.url).toExist(); - expect(exception.config.url.indexOf('authkey')).toBeLessThan(0); - done(); + }).catch((e) => { + done(e); }); }); - it('test add authkey authentication to axios config with login and uuid', (done) => { - // mocking the authentication rules - ConfigUtils.setConfigProp("useAuthenticationRules", true); - ConfigUtils.setConfigProp("authenticationRules", authenticationRules); - // authkey authentication with user - setSecurityInfo(securityInfoB); - axios.get('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2&authkey=TEST_AUTHKEY').then(() => { - done(); - }).catch((exception) => { - expect(exception.config).toExist(); - expect(exception.config.url).toExist(); - expect(exception.config.url.indexOf('authkey')).toBeGreaterThan(-1); - expect(exception.config.url.indexOf("TEST_AUTHKEY")).toBeLessThan(0); - done(); - }); - }); + it('test add authkey authentication to axios config with login but authentication deactivated', (done) => { + mockAxios.onGet().reply(config => { + expect(config).toExist(); + expect(config.url).toExist(); + expect(config.url.indexOf('authkey')).toBeLessThan(0); + return [200, {}]; - it('test add authkey authentication to axios config with login and uuid but authentication deactivated', (done) => { + }); axios.get('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2').then(() => { done(); - }).catch((exception) => { - expect(exception.config).toExist(); - expect(exception.config.url).toExist(); - expect(exception.config.url.indexOf('authkey')).toBeLessThan(0); - done(); + }).catch((e) => { + + done(e); }); }); @@ -291,52 +331,65 @@ describe('Tests ajax library', () => { ConfigUtils.setConfigProp("authenticationRules", authenticationRules); // basic authentication header available setSecurityInfo(securityInfoB); + mockAxios.onGet().reply(config => { + expect(config).toExist(); + expect(config.headers).toExist(); + expect(config.headers.Authorization).toBe('Basic 263c6917-543f-43e3-8e1a-6a0d29952f72'); + return [200, {}]; + + }); axios.get('http://www.some-site.com/index?parameter1=value1¶meter2=value2').then(() => { done(); - }).catch((exception) => { - expect(exception.config).toExist(); - expect(exception.config.headers).toExist(); - expect(exception.config.headers.Authorization).toBe('Basic 263c6917-543f-43e3-8e1a-6a0d29952f72'); - done(); + }).catch(e => { + + done(e); }); }); - it('add custom authkey authentication to axios config with login and uuid', (done) => { + it('add custom authkey authentication to axios config with login', (done) => { // mocking the authentication rules and user info ConfigUtils.setConfigProp("useAuthenticationRules", true); ConfigUtils.setConfigProp("authenticationRules", authenticationRules); + mockAxios.onGet().reply(config => { + expect(config).toExist().toIncludeKey('url'); + expect(config.url.indexOf('authkey')).toBeLessThan(0); + expect(config.params.authkey).toNotExist(); + expect(config.params.mario).toEqual(securityInfoB.token); + return [200, {}]; + + }); // basic authentication header available setSecurityInfo(securityInfoB); - const theExpectedString = 'mario%3D' + securityInfoB.token; axios.get('http://non-existent.mapstore2/youhavetouseacustomone?parameter1=value1&par2=v2').then(() => { - done("Axios actually reached the fake url"); - }).catch((exception) => { - expect(exception.config).toExist().toIncludeKey('url'); - expect(exception.config.url.indexOf('authkey')).toBeLessThan(0); - expect(exception.config.url.indexOf(theExpectedString)).toBeGreaterThan(-1); done(); + }).catch((e) => { + done(e); }); }); - it('does not add autkeys if the configuration is wrong', (done) => { + it('does not add authkey if the configuration is wrong', (done) => { + mockAxios.onGet().reply(config => { + expect(config).toExist().toIncludeKey('url'); + expect(config.url.authkey).toNotExist(); + return [200, {}]; + + }); axios.get('http://non-existent.mapstore2/thisismissingtheparam?parameter1=value1&par2=v2').then(() => { - done("Axios actually reached the fake url"); - }).catch((exception) => { - expect(exception.config).toExist().toIncludeKey('url'); - expect(exception.config.url.indexOf('authkey')).toBe(-1); done(); + }).catch((e) => { + done(e); }); }); it('adds generic headers', (done) => { ConfigUtils.setConfigProp("useAuthenticationRules", true); ConfigUtils.setConfigProp("authenticationRules", authenticationRules); + mockAxios.onGet().reply(config => { + expect(config).toExist().toIncludeKey('headers'); + expect(config.headers["X-Test-Token"]).toBe('test'); + return [200, {}]; + }); axios.get('header-site.com').then(() => { - done("Axios actually reached the fake url"); - }).catch((exception) => { - expect(exception.config).toExist().toIncludeKey('headers'); - - expect(exception.config.headers["X-Test-Token"]).toBe('test'); done(); }).catch(e => { done(e); @@ -348,13 +401,17 @@ describe('Tests ajax library', () => { ConfigUtils.setConfigProp("useAuthenticationRules", true); ConfigUtils.setConfigProp("authenticationRules", authenticationRules); // basic authentication header available + mockAxios.onGet().reply(config => { + expect(config).toExist().toIncludeKey('headers'); + expect(config.headers.Authorization).toBe('Bearer 263c6917-543f-43e3-8e1a-6a0d29952f72'); + return [200, {}]; + }); setSecurityInfo(securityInfoB); axios.get('http://non-existent.mapstore2/imtokenized?parameter1=value1&par2=v2').then(() => { - done("Axios actually reached the fake url"); - }).catch((exception) => { - expect(exception.config).toExist().toIncludeKey('headers'); - expect(exception.config.headers.Authorization).toBe('Bearer 263c6917-543f-43e3-8e1a-6a0d29952f72'); done(); + }).catch((e) => { + + done(e); }); }); @@ -362,14 +419,18 @@ describe('Tests ajax library', () => { // mocking the authentication rules and user info ConfigUtils.setConfigProp("useAuthenticationRules", true); ConfigUtils.setConfigProp("authenticationRules", authenticationRules); + mockAxios.onGet().reply(config => { + expect(config).toExist().toIncludeKey('headers'); + expect(config.headers.Authorization).toBe('Bearer 263c6917-543f-43e3-8e1a-6a0d29952f72'); + return [200, {}]; + }); // basic authentication header available setSecurityInfo(securityInfoB); axios.get('tokenservice?param=token', { baseURL: 'http://sitetocheck', timeout: 1}).then(() => { - done("Axios actually reached the fake url"); - }).catch((exception) => { - expect(exception.config).toExist().toIncludeKey('headers'); - expect(exception.config.headers.Authorization).toBe('Bearer 263c6917-543f-43e3-8e1a-6a0d29952f72'); done(); + }).catch((e) => { + + done(e); }); }); @@ -378,12 +439,15 @@ describe('Tests ajax library', () => { ConfigUtils.setConfigProp("useAuthenticationRules", true); ConfigUtils.setConfigProp("authenticationRules", authenticationRules); setSecurityInfo(securityInfoA); + mockAxios.onGet().reply(config => { + expect(config).toExist().toIncludeKey('headers'); + expect(config.headers.Authorization).toNotExist(); + return [200, {}]; + }); axios.get('http://non-existent.mapstore2/imtokenized?parameter1=value1&par2=v2').then(() => { - done("Axios actually reached the fake url"); - }).catch((exception) => { - expect(exception.config).toExist().toIncludeKey('headers'); - expect(exception.config.headers.Authorization).toNotExist(); done(); + }).catch((e) => { + done(e); }); }); @@ -392,64 +456,83 @@ describe('Tests ajax library', () => { ConfigUtils.setConfigProp("useAuthenticationRules", true); ConfigUtils.setConfigProp("authenticationRules", authenticationRules); setSecurityInfo(securityInfoA); + mockAxios.onGet().reply(config => { + expect(config).toExist().toIncludeKey('headers'); + expect(config.headers.Authorization).toNotExist(); + return [200, {}]; + }); axios.get('http://www.some-site.com/index?parameter1=value1¶meter2=value2').then(() => { - done("Axios actually reached the fake url"); - }).catch((exception) => { - expect(exception.config).toExist().toIncludeKey('headers'); - expect(exception.config.headers.Authorization).toNotExist(); done(); + }).catch((e) => { + + done(e); }); }); it('does not add authentication if the method is not supported', (done) => { const url = 'https://not-supported.mapstore2:4433/?parameter1=value1¶meter2=value2'; + mockAxios.onGet().reply(config => { + expect(config).toExist().toIncludeKey('headers'); + expect(config.headers.Authorization).toNotExist(); + expect(config).toExist().toIncludeKey('url'); + expect(url).toEqual(url); + return [200, {}]; + }); axios.get(url).then(() => { - done("Axios actually reached the fake url"); - }).catch((exception) => { - expect(exception.config).toExist().toIncludeKey('headers'); - expect(exception.config.headers.Authorization).toNotExist(); - expect(exception.config).toExist().toIncludeKey('url'); - const parsed = urlUtil.parse(exception.config.url, true); - expect(parsed.query.url).toEqual(url); done(); + }).catch((e) => { + + done(e); }); }); it('the interceptor can handle empty uri', (done) => { // mocking the authentication rules and user info + mockAxios.onGet().reply(config => { + expect(config).toExist(); + + return [200, {}]; + }); axios.get().then(() => { - done("Axios actually reached the fake url"); - }).catch(() => { done(); + }).catch((e) => { + done(e); }); }); it('does set withCredentials on the request', (done)=> { ConfigUtils.setConfigProp("useAuthenticationRules", true); ConfigUtils.setConfigProp("authenticationRules", authenticationRules); + mockAxios.onGet().reply(config => { + expect(config).toExist(); + expect(config.withCredentials).toExist(); + expect(config.withCredentials).toBeTruthy(); + return [200, {}]; + }); axios.get('http://www.useBrowserCredentials.com/useBrowserCredentials?parameter1=value1¶meter2=value2').then(() => { done(); - }).catch( (exception) => { - expect(exception.config).toExist(); - expect(exception.config.withCredentials).toExist(); - expect(exception.config.withCredentials).toBeTruthy(); - done(); + }).catch( (e) => { + + done(e); }); }); it('does not set withCredentials on the request', (done)=> { + mockAxios.onGet().reply(config => { + expect(config).toExist(); + expect(config.withCredentials).toNotExist(); + return [200, {}]; + }); axios.get('http://www.skipBrowserCredentials.com/geoserver?parameter1=value1¶meter2=value2').then(() => { done(); - }).catch( (exception) => { - expect(exception.config).toExist(); - expect(exception.config.withCredentials).toNotExist(); - done(); + }).catch( (e) => { + + done(e); }); }); it('does test for CORS if autoDetectCORS is true', (done) => { - mockAxios = new MockAdapter(axios); mockAxios.onGet().reply(200, {}, { "allow-control-allow-origin": "*" }); diff --git a/web/client/libs/ajax.js b/web/client/libs/ajax.js index 42e0d423456..cab7403ca3d 100644 --- a/web/client/libs/ajax.js +++ b/web/client/libs/ajax.js @@ -10,10 +10,12 @@ import axios from 'axios'; import combineURLs from 'axios/lib/helpers/combineURLs'; import ConfigUtils from '../utils/ConfigUtils'; import { - isAuthenticationActivated, - getAuthenticationRule, + getAuthenticationMethod, + getAuthorizationBasic, + getRequestConfigurationByUrl, + getRequestConfigurationRule, getToken, - getBasicAuthHeader + isRequestConfigurationActivated } from '../utils/SecurityUtils'; import isObject from 'lodash/isObject'; @@ -21,6 +23,7 @@ import omitBy from 'lodash/omitBy'; import isNil from 'lodash/isNil'; import urlUtil from 'url'; import { getProxyCacheByUrl, setProxyCacheByUrl } from '../utils/ProxyUtils'; +import { isEmpty } from 'lodash'; /** * Internal helper that adds an extra paramater to an axios configuration. @@ -44,59 +47,45 @@ function addHeaderToAxiosConfig(axiosConfig, headerName, headerValue) { * authentication method based on the request URL. */ function addAuthenticationToAxios(axiosConfig) { - if (!axiosConfig || !axiosConfig.url || !isAuthenticationActivated()) { + if (!axiosConfig || !axiosConfig.url) { return axiosConfig; } const axiosUrl = combineURLs(axiosConfig.baseURL || '', axiosConfig.url); - const rule = getAuthenticationRule(axiosUrl); - switch (rule && rule.method) { - case 'browserWithCredentials': - { - axiosConfig.withCredentials = true; - return axiosConfig; - } - case 'authkey': - { - const token = getToken(); - if (!token) { - return axiosConfig; - } - addParameterToAxiosConfig(axiosConfig, rule.authkeyParamName || 'authkey', token); - return axiosConfig; + // Extract custom sourceId from axios config if provided + const sourceId = axiosConfig._msAuthSourceId; + + const method = getAuthenticationMethod(axiosUrl); + if (method === "bearer" && !getToken()) return axiosConfig; + if (method === "authkey" && !getToken()) return axiosConfig; + if (method === "basic" && sourceId && isEmpty(getAuthorizationBasic(sourceId))) return axiosConfig; + + // If request configuration is not activated but sourceId is provided, still need to handle basic auth + const { headers, params } = getRequestConfigurationByUrl(axiosUrl, undefined, sourceId); + + if (headers) { + Object.entries(headers).forEach(([headerName, headerValue]) => { + addHeaderToAxiosConfig(axiosConfig, headerName, headerValue); + }); } - case 'test': { - const token = rule ? rule.token : ""; - if (!token) { - return axiosConfig; - } - addParameterToAxiosConfig(axiosConfig, rule.authkeyParamName || 'authkey', token); - return axiosConfig; + if (params) { + Object.entries(params).forEach(([paramName, paramValue]) => { + addParameterToAxiosConfig(axiosConfig, paramName, paramValue); + }); } - case 'basic': - const basicAuthHeader = getBasicAuthHeader(); - if (!basicAuthHeader) { - return axiosConfig; - } - addHeaderToAxiosConfig(axiosConfig, 'Authorization', basicAuthHeader); - return axiosConfig; - case 'bearer': - { - const token = getToken(); - if (!token) { - return axiosConfig; + + // Check for withCredentials + if (isRequestConfigurationActivated()) { + const rule = getRequestConfigurationRule(axiosUrl); + if (rule?.withCredentials) { + axiosConfig.withCredentials = true; } - addHeaderToAxiosConfig(axiosConfig, 'Authorization', "Bearer " + token); - return axiosConfig; - } - case 'header': { - Object.entries(rule.headers).map(([headerName, headerValue]) => addHeaderToAxiosConfig(axiosConfig, headerName, headerValue)); - return axiosConfig; - } - default: - // we cannot handle the required authentication method - return axiosConfig; } + + // Remove the custom prop from config to avoid it being sent as a regular param + delete axiosConfig._msAuthSourceId; + + return axiosConfig; } const checkSameOrigin = (uri) => { @@ -125,12 +114,14 @@ axios.interceptors.request.use(config => { let proxyUrl = ConfigUtils.getProxyUrl(config); if (proxyUrl) { let useCORS = []; + let autoDetectCORS = true; if (isObject(proxyUrl)) { useCORS = proxyUrl.useCORS || []; + autoDetectCORS = proxyUrl.autoDetectCORS ?? true; proxyUrl = proxyUrl.url; } const isCORS = useCORS.some((current) => uri.indexOf(current) === 0); - const proxyNeeded = getProxyCacheByUrl(uri); + const proxyNeeded = getProxyCacheByUrl(uri) || !autoDetectCORS; if (!isCORS && proxyNeeded) { const parsedUri = urlUtil.parse(uri, true, true); const params = omitBy(config.params, isNil); diff --git a/web/client/observables/wfs.js b/web/client/observables/wfs.js index 53cae4cb928..f542b67ab63 100644 --- a/web/client/observables/wfs.js +++ b/web/client/observables/wfs.js @@ -19,7 +19,6 @@ import { getCapabilitiesUrl } from '../utils/LayersUtils'; import { interceptOGCError } from '../utils/ObservableUtils'; import requestBuilder from '../utils/ogc/WFS/RequestBuilder'; import { getDefaultUrl } from '../utils/URLUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const {getFeature, query, sortBy, propertyName} = requestBuilder({ wfsVersion: "1.1.0" }); @@ -170,7 +169,6 @@ export const getXMLFeature = (searchUrl, filterObj, options = {}, downloadOption } const { data, queryString } = getFeatureUtilities(searchUrl, filterObj, options, downloadOption); - const headers = getAuthorizationBasic(options.layer?.security?.sourceId || options.security?.sourceId); return Rx.Observable.defer(() => axios.post(queryString, data, { @@ -178,9 +176,9 @@ export const getXMLFeature = (searchUrl, filterObj, options = {}, downloadOption responseType: 'arraybuffer', headers: { 'Accept': `application/xml`, - 'Content-Type': `application/xml`, - ...headers - } + 'Content-Type': `application/xml` + }, + _msAuthSourceId: options.layer?.security?.sourceId || options.security?.sourceId })); }; @@ -278,14 +276,13 @@ export const getLayerJSONFeature = ({ search = {}, url, name, security } = {}, f }); export const describeFeatureType = ({layer}) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); + const url = toDescribeURL(layer); return Rx.Observable.defer(() => - axios.get(toDescribeURL(layer), {headers})).let(interceptOGCError); + axios.get(url, {_msAuthSourceId: layer?.security?.sourceId})).let(interceptOGCError); }; export const getLayerWFSCapabilities = ({layer}) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); - - return Rx.Observable.defer( () => axios.get(toLayerCapabilitiesURL(layer), {headers})) + const url = toLayerCapabilitiesURL(layer); + return Rx.Observable.defer( () => axios.get(url, {_msAuthSourceId: layer?.security?.sourceId})) .let(interceptOGCError) .switchMap( response => Rx.Observable.bindNodeCallback( (data, callback) => parseString(data, { tagNameProcessors: [stripPrefix], diff --git a/web/client/observables/wms.js b/web/client/observables/wms.js index e71f00bde43..a5c8952593d 100644 --- a/web/client/observables/wms.js +++ b/web/client/observables/wms.js @@ -17,7 +17,7 @@ import axios from '../libs/ajax'; import { determineCrs, fetchProjRemotely, getProjUrl } from '../utils/CoordinatesUtils'; import { getCapabilitiesUrl } from '../utils/LayersUtils'; import { interceptOGCError } from '../utils/ObservableUtils'; -import { cleanAuthParamsFromURL, getAuthorizationBasic } from '../utils/SecurityUtils'; +import { cleanAuthParamsFromURL } from '../utils/SecurityUtils'; import { getDefaultUrl } from '../utils/URLUtils'; const proj4 = Proj4js; @@ -40,12 +40,10 @@ export const toDescribeLayerURL = ({name, search = {}, url} = {}) => { }); }; export const describeLayer = l => { - const headers = getAuthorizationBasic(l?.security?.sourceId); - return Observable.defer( () => axios.get(toDescribeLayerURL(l), {headers})).let(interceptOGCError); + return Observable.defer( () => axios.get(toDescribeLayerURL(l), {_msAuthSourceId: l?.security?.sourceId})).let(interceptOGCError); }; export const getLayerCapabilities = l => { - const headers = getAuthorizationBasic(l?.security?.sourceId); - return Observable.defer(() => WMS.getCapabilities(getCapabilitiesUrl(l), headers)) + return Observable.defer(() => WMS.getCapabilities(getCapabilitiesUrl(l), {_msAuthSourceId: l?.security?.sourceId})) .let(interceptOGCError) .map(c => WMS.parseLayerCapabilities(c, l)); }; diff --git a/web/client/observables/wps/execute.js b/web/client/observables/wps/execute.js index d75c265485b..678f07824f8 100644 --- a/web/client/observables/wps/execute.js +++ b/web/client/observables/wps/execute.js @@ -13,7 +13,6 @@ import { stripPrefix } from 'xml2js/lib/processors'; import axios from '../../libs/ajax'; import { getWPSURL } from './common'; -import { getAuthorizationBasic } from '../../utils/SecurityUtils'; /** * Contains routines pertaining to Execute WPS operation. @@ -194,13 +193,13 @@ export const makeOutputsExtractor = (...extractors) => * @returns {Observable} observable that emits result from axios.post */ export const executeProcessRequest = (url, payload, requestOptions = {}, layer) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); + const wpsUrl = getWPSURL(url, {"version": "1.0.0", "REQUEST": "Execute"}); return Observable.defer(() => - axios.post(getWPSURL(url, {"version": "1.0.0", "REQUEST": "Execute"}), payload, { + axios.post(wpsUrl, payload, { headers: { - 'Content-Type': 'application/xml', - ...headers + 'Content-Type': 'application/xml' }, + _msAuthSourceId: layer?.security?.sourceId, ...requestOptions }) ); diff --git a/web/client/plugins/CRSSelector.jsx b/web/client/plugins/CRSSelector.jsx index e872801cc2f..7730949788c 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 00000000000..246a0081d4c --- /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/RulesDataGrid.jsx b/web/client/plugins/RulesDataGrid.jsx index 029fce6b045..b39721bc944 100644 --- a/web/client/plugins/RulesDataGrid.jsx +++ b/web/client/plugins/RulesDataGrid.jsx @@ -61,7 +61,7 @@ const GSInstancesGridComp = gsInstGridEnhancer(GSInstancesGrid); * @prop {number} cfg.vsOverScan default 20. Number of rows to load above/below the visible slice of the grid * @prop {number} cfg.scrollDebounce default 50. milliseconds of debounce interval between two scroll event * @classdesc - * Rules-grid it's part of rules-manager page. It loads GeoFence's rules from configured geofence instance. + * Rules-grid it's part of {@link api/framework#pages.RulesManager|rules-manager page}. It loads GeoFence's rules from configured geofence instance. * It uses virtualScroll to manage rules loading. It allows to order GeoFence's rules by drag and drop. * Rules can be filtered selecting values form columns' header. */ diff --git a/web/client/plugins/RulesEditor.jsx b/web/client/plugins/RulesEditor.jsx index 37fd1b3a338..9a0949e7e75 100644 --- a/web/client/plugins/RulesEditor.jsx +++ b/web/client/plugins/RulesEditor.jsx @@ -46,7 +46,7 @@ const GSInstanceEditorComp = compose( gsInstanceEnhancer)(GSInstanceEditor); /** - * Rules-editor it's part of rules-manager page. It allow a admin user to add, modify and delete geofence rules + * Rules-editor it's part of {@link api/framework#pages.RulesManager|rules-manager page}. It allow a admin user to add, modify and delete geofence rules * @name RulesEditor * @memberof plugins * @prop {boolean} cfg.disableDetails disable details tab. (Style/Filters/Attribute). Useful to avoid issues with GeoServer integrated version that do not full support this advanced features via REST diff --git a/web/client/plugins/RulesManagerFooter.jsx b/web/client/plugins/RulesManagerFooter.jsx index 84ddcb1a792..ab78987c89e 100644 --- a/web/client/plugins/RulesManagerFooter.jsx +++ b/web/client/plugins/RulesManagerFooter.jsx @@ -43,8 +43,9 @@ class RulesManagerFooter extends React.Component { } /** - * Footer plugin for {@link #plugins.RulesEditor} - * @name RulesManager + * Footer plugin for {@link #plugins.RulesEditor}. Deprecated. Will be removed in next versions of MapStore. + * @name RulesManagerFooter + * @deprecated * @class * @memberof plugins */ diff --git a/web/client/plugins/ScaleBox.jsx b/web/client/plugins/ScaleBox.jsx index f8819640faf..e0600f32cc7 100644 --- a/web/client/plugins/ScaleBox.jsx +++ b/web/client/plugins/ScaleBox.jsx @@ -60,7 +60,7 @@ export default { }, { MapFooter: { name: 'scale', - position: 1, + position: 2, target: 'right-footer', priority: 1 } diff --git a/web/client/plugins/Search.jsx b/web/client/plugins/Search.jsx index e40284cb2d5..a34f6edfa27 100644 --- a/web/client/plugins/Search.jsx +++ b/web/client/plugins/Search.jsx @@ -157,6 +157,7 @@ const SearchResultList = connect(selector, { * @prop {object} cfg.maxResults number of max items present in the result list * @prop {object} cfg.resultsStyle custom style for search results * @prop {bool} cfg.fitResultsToMapSize true by default, fits the result list to the mapSize (can be disabled, for custom uses) + * @prop {bool} cfg.searchOptions.bottomMenuServices false by default, shows the services in the bottom of the search menu * @prop {searchService[]} cfg.searchOptions.services a list of services to perform search. * @prop {object} cfg.coordinateSearchOptions options for the coordinate search * @prop {number} [cfg.coordinateSearchOptions.maxZoomLevel=12] the max zoom level for the coordinate search diff --git a/web/client/plugins/Share.jsx b/web/client/plugins/Share.jsx index 783813dbf76..1ed6051a6b9 100644 --- a/web/client/plugins/Share.jsx +++ b/web/client/plugins/Share.jsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, {useState} from 'react'; import {connect, createPlugin} from '../utils/PluginsUtils'; import { Glyphicon } from 'react-bootstrap'; import Message from '../components/I18N/Message'; @@ -22,7 +22,6 @@ import { mapIdSelector, mapSelector } from '../selectors/map'; import { currentContextSelector } from '../selectors/context'; import { get } from 'lodash'; import controls from '../reducers/controls'; -import { changeFormat } from '../actions/mapInfo'; import { addMarker, hideMarker } from '../actions/search'; import { updateMapView } from '../actions/map'; import { resourceSelector as geostoryResourceSelector, updateUrlOnScrollSelector } from '../selectors/geostory'; @@ -69,7 +68,6 @@ const Share = connect(createSelector([ mapTypeSelector, currentContextSelector, state => get(state, 'controls.share.settings', {}), - (state) => state.mapInfo && state.mapInfo.formatCoord || ConfigUtils.getConfigProp("defaultCoordinateFormat"), state => state.search && state.search.markerPosition || {}, updateUrlOnScrollSelector, state => get(state, 'map.present.viewerOptions'), @@ -84,7 +82,7 @@ const Share = connect(createSelector([ }, state => get(state, 'controls.share.resource.shareUrl') || location.href, state => get(state, 'controls.share.resource.categoryName') -], (isVisible, version, map, mapType, context, settings, formatCoords, point, isScrollPosition, viewerOptions, center, shareUrl, categoryName) => ({ +], (isVisible, version, map, mapType, context, settings, point, isScrollPosition, viewerOptions, center, shareUrl, categoryName) => ({ isVisible, shareUrl, shareApiUrl: getApiUrl(shareUrl), @@ -104,7 +102,6 @@ const Share = connect(createSelector([ bbox: true, centerAndZoom: true }, - formatCoords: formatCoords, point, isScrollPosition, categoryName})), { @@ -112,12 +109,12 @@ const Share = connect(createSelector([ hideMarker, updateMapView, onUpdateSettings: setControlProperty.bind(null, 'share', 'settings'), - onChangeFormat: changeFormat, addMarker: addMarker, onClearShareResource: setControlProperty.bind(null, 'share', 'resource', undefined) })(({ categoryName, ...props }) => { + const [formatCoord, setFormatCoords] = useState(ConfigUtils.getConfigProp("defaultCoordinateFormat") || 'decimal'); const categoryCfg = props[categoryName]; - return ; + return ; }); const ActionCardShareButton = connect( diff --git a/web/client/plugins/StreetView/components/CyclomediaView/Credentials.jsx b/web/client/plugins/StreetView/components/CyclomediaView/Credentials.jsx index c59137a17ee..8b6cad23d58 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 302676c459a..cb15cd6f2e1 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 ?