Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] d294a39843 Bump vite-plugin-istanbul from 6.0.2 to 7.0.0
Bumps [vite-plugin-istanbul](https://github.com/ifaxity/vite-plugin-istanbul) from 6.0.2 to 7.0.0.
- [Release notes](https://github.com/ifaxity/vite-plugin-istanbul/releases)
- [Changelog](https://github.com/iFaxity/vite-plugin-istanbul/blob/next/release.config.js)
- [Commits](https://github.com/ifaxity/vite-plugin-istanbul/compare/v6.0.2...v7.0.0)

---
updated-dependencies:
- dependency-name: vite-plugin-istanbul
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-25 13:35:21 +00:00
185 changed files with 10947 additions and 14992 deletions
-22
View File
@@ -11,29 +11,7 @@ updates:
interval: "daily" interval: "daily"
open-pull-requests-limit: 20 open-pull-requests-limit: 20
versioning-strategy: increase versioning-strategy: increase
groups:
vitest:
patterns:
- "*vitest*"
react:
patterns:
- "*react*"
cooldown:
default-days: 5
semver-major-days: 5
semver-minor-days: 3
semver-patch-days: 3
include:
- "*"
exclude:
- "@maplibre/*"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
cooldown:
default-days: 3
# no semver support for github-actions
# => no specific configuration for this
include:
- "*"
+2 -2
View File
@@ -7,11 +7,11 @@ permissions: write-all
jobs: jobs:
dependabot: dependabot:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} if: ${{ github.actor == 'dependabot[bot]' }}
steps: steps:
- name: Dependabot metadata - name: Dependabot metadata
id: metadata id: metadata
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 uses: dependabot/fetch-metadata@v2.3.0
with: with:
github-token: "${{ secrets.GITHUB_TOKEN }}" github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve Dependabot PRs - name: Approve Dependabot PRs
+30 -71
View File
@@ -7,12 +7,21 @@ on:
branches: [ main ] branches: [ main ]
jobs: jobs:
build-docker:
name: build docker
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v4
- run: docker build -t test-docker-image-build .
# build the editor
build-node: build-node:
name: "build on ${{ matrix.os }}" name: "build on ${{ matrix.os }}"
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
permissions:
contents: read
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
strategy: strategy:
@@ -21,9 +30,8 @@ jobs:
os: [ ubuntu-latest, windows-latest, macos-latest ] os: [ ubuntu-latest, windows-latest, macos-latest ]
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@v4
with: { persist-credentials: false } - uses: actions/setup-node@v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
@@ -36,27 +44,24 @@ jobs:
build-artifacts: build-artifacts:
name: "build artifacts" name: "build artifacts"
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@v4
with: { persist-credentials: false } - uses: actions/setup-node@v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
- name: artifacts/maputnik - name: artifacts/maputnik
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@v4
with: with:
name: maputnik name: maputnik
path: dist path: dist
# Build and upload desktop CLI artifacts # Build and upload desktop CLI artifacts
- name: Set up Go - name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 uses: actions/setup-go@v5
with: with:
go-version: ^1.23.x go-version: ^1.23.x
cache-dependency-path: desktop/go.sum cache-dependency-path: desktop/go.sum
@@ -66,89 +71,43 @@ jobs:
run: npm run build-desktop run: npm run build-desktop
- name: Artifacts/linux - name: Artifacts/linux
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@v4
with: with:
name: maputnik-linux name: maputnik-linux
path: ./desktop/bin/linux/ path: ./desktop/bin/linux/
- name: Artifacts/darwin - name: Artifacts/darwin
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@v4
with: with:
name: maputnik-darwin name: maputnik-darwin
path: ./desktop/bin/darwin/ path: ./desktop/bin/darwin/
- name: Artifacts/windows - name: Artifacts/windows
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@v4
with: with:
name: maputnik-windows name: maputnik-windows
path: ./desktop/bin/windows/ path: ./desktop/bin/windows/
unit-tests:
name: "Unit tests"
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: { persist-credentials: false }
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.nvmrc'
- run: npm ci
- run: npm run test-unit-ci
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
files: ${{ github.workspace }}/coverage/coverage-final.json
verbose: true
e2e-tests: e2e-tests:
name: "E2E tests using chrome" name: "E2E tests using ${{ matrix.browser }}"
strategy:
fail-fast: false
matrix:
browser: [ chrome ]
runs-on: ubuntu-latest runs-on: ubuntu-22.04
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@v4
with: { persist-credentials: false }
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
- name: Cypress run - name: Cypress run
uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1 uses: cypress-io/github-action@v6
with: with:
build: npm run build build: npm run build
start: npm run start start: npm run start
browser: chrome browser: ${{ matrix.browser }}
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 uses: codecov/codecov-action@v5
with:
files: ${{ github.workspace }}/.nyc_output/out.json
verbose: true
e2e-tests-docker:
name: "E2E tests using chrome and docker"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: { persist-credentials: false }
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.nvmrc'
- run: npm ci
- name: Cypress run
uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1
with:
build: docker build -t maputnik .
start: docker run --rm --network host maputnik --port=8888
browser: chrome
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with: with:
files: ${{ github.workspace }}/.nyc_output/out.json files: ${{ github.workspace }}/.nyc_output/out.json
verbose: true verbose: true
+4 -4
View File
@@ -38,11 +38,11 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -53,7 +53,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/autobuild@v3
# ️ Command-line programs to run using the OS shell. # ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -67,4 +67,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/analyze@v3
+3 -3
View File
@@ -16,13 +16,13 @@ jobs:
run: run:
shell: bash shell: bash
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: main ref: main
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@v4
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
@@ -32,7 +32,7 @@ jobs:
./build/bump-version-changelog.js ${{ inputs.version }} ./build/bump-version-changelog.js ${{ inputs.version }}
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 uses: peter-evans/create-pull-request@v7
with: with:
commit-message: Bump version to ${{ inputs.version }} commit-message: Bump version to ${{ inputs.version }}
branch: bump-version-to-${{ inputs.version }} branch: bump-version-to-${{ inputs.version }}
+7 -11
View File
@@ -8,15 +8,12 @@ jobs:
deploy-pages: deploy-pages:
name: deploy/pages name: deploy/pages
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
if: ${{ github.event_name == 'push' }} if: ${{ github.event_name == 'push' }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@v4
with: { persist-credentials: false }
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
@@ -27,7 +24,7 @@ jobs:
run: npm run build run: npm run build
- name: Upload to GitHub Pages - name: Upload to GitHub Pages
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 uses: peaceiris/actions-gh-pages@v4
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: dist publish_dir: dist
@@ -36,20 +33,19 @@ jobs:
deploy-docker: deploy-docker:
name: deploy/docker name: deploy/docker
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' }} if: ${{ github.event_name == 'push' }}
permissions:
contents: read
packages: write
strategy: strategy:
fail-fast: false fail-fast: false
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@v4
- run: docker build -t ghcr.io/maplibre/maputnik:main . - run: docker build -t ghcr.io/maplibre/maputnik:main .
- run: docker push ghcr.io/maplibre/maputnik:main - run: docker push ghcr.io/maplibre/maputnik:main
+9 -12
View File
@@ -12,23 +12,20 @@ jobs:
defaults: defaults:
run: run:
shell: bash shell: bash
permissions:
contents: read
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: main ref: main
persist-credentials: false
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@v4
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
- name: Check if version has been updated - name: Check if version has been updated
id: check id: check
uses: EndBug/version-check@095362f3cd50f690c8fa0e6afeea81834bd8d320 # latest uses: EndBug/version-check@v2
outputs: outputs:
publish: ${{ steps.check.outputs.changed }} publish: ${{ steps.check.outputs.changed }}
@@ -42,19 +39,19 @@ jobs:
run: run:
shell: bash shell: bash
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: main ref: main
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@v4
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- name: Set up Go for desktop build - name: Set up Go for desktop build
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 uses: actions/setup-go@v5
with: with:
go-version: ^1.23.x go-version: ^1.23.x
cache-dependency-path: desktop/go.sum cache-dependency-path: desktop/go.sum
@@ -62,7 +59,7 @@ jobs:
- name: Get version - name: Get version
id: package-version id: package-version
uses: martinbeentjes/npm-get-version-action@3cf273023a0dda27efcd3164bdfb51908dd46a5b # v1.3.1 uses: martinbeentjes/npm-get-version-action@v1.3.1
- name: Install - name: Install
run: npm ci run: npm ci
@@ -73,7 +70,7 @@ jobs:
- name: Tag commit and push - name: Tag commit and push
id: tag_version id: tag_version
uses: mathieudutour/github-tag-action@a22cf08638b34d5badda920f9daf6e72c477b07b # v6.2 uses: mathieudutour/github-tag-action@v6.2
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
custom_tag: ${{ steps.package-version.outputs.current-version }} custom_tag: ${{ steps.package-version.outputs.current-version }}
@@ -91,7 +88,7 @@ jobs:
- name: Create GitHub Release - name: Create GitHub Release
id: create_regular_release id: create_regular_release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 uses: ncipollo/release-action@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
+1 -1
View File
@@ -6,7 +6,7 @@ ci:
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v5.0.0
hooks: hooks:
- id: check-added-large-files - id: check-added-large-files
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
-47
View File
@@ -1,47 +0,0 @@
Maputnik is a MapLibre style editor written using React and TypeScript.
To get started, install all npm packages:
```
npm install
```
Verify code correctness by running ESLint:
```
npm run lint
```
Or try fixing lint issues with:
```
npm run lint -- --fix
```
The project type checked and built with:
```
npm run build
```
To run the tests make sure that xvfb is installed:
```
apt install xvfb
```
Run the development server in the background with Vite:
```
nohup npm run start &
```
Then start the Cypress tests with:
```
xvfb-run -a npm run test
```
## Pull Requests
- Pull requests should update `CHANGELOG.md` with a short description of the change.
+2 -52
View File
@@ -1,46 +1,6 @@
## main ## main
### ✨ Features and improvements ### ✨ Features and improvements
- _...Add new stuff here..._
### 🐞 Bug fixes
- _...Add new stuff here..._
## 3.1.0
### ✨ Features and improvements
- Added translation to "Links" in debug modal
- Add support for hillshade's color arrays and relief-color elevation expression
- Change layers icons to make them a bit more distinct
- Remove `@mdi` packages in favor of `react-icons`
- Add ability to control the projection of the map - either globe or mercator
- Add markdown support for doc related to the style-spec fields
- Added global state modal to allow editing the global state
- Added color highlight for problematic properties
- Upgraded codemirror from version 5 to version 6
- Add code editor to allow editing the entire style
- Add support for sprite object in setting modal
- Set the correct map view when opening a new style on an empty map
- Allow root-relative urls in the stylefile
### 🐞 Bug fixes
- Improved the local style open modal and local file upload.
- Fixed the Expression editor (for long expressions) being able to be float under other components further down
- Fixed an issue when clicking on a popup and then clicking on the map again
- Fix modal close button position
- Fixed an issue with the generation of translations
- Fix missing spec info when clicking next to a property
- Fix Firefox open file that stopped working due to react upgrade
- Fix issue with missing bottom error panel
- Fixed headers in left panes (Layers list and Layer editor) to remain visible when scrolling
- Fix error when using a source from localhost
- Fix an issue with scrolling when using the code editor
## 3.0.0
### ✨ Features and improvements
- Fix radio/delete filter buttons styling regression
- Add german translation - Add german translation
- Use same version number for web and desktop versions - Use same version number for web and desktop versions
- Add scheme type options for vector/raster tile - Add scheme type options for vector/raster tile
@@ -50,22 +10,12 @@
- Upgrade to MapLibre LG JS v5 - Upgrade to MapLibre LG JS v5
- Upgrade Vite 6 and Cypress 14 ([#970](https://github.com/maplibre/maputnik/pull/970)) - Upgrade Vite 6 and Cypress 14 ([#970](https://github.com/maplibre/maputnik/pull/970))
- Upgrade OpenLayers from v6 to v10 - Upgrade OpenLayers from v6 to v10
- When loading a style into localStorage that causes a QuotaExceededError, purge localStorage and retry - _...Add new stuff here..._
- Remove react-autobind dependency
- Remove usage of legacy `childContextTypes` API
- Refactor Field components to use arrow function syntax
- Replace react-autocomplete with Downshift in the autocomplete component
- Add LocationIQ as supported map provider with access token field and gallery style
- Use maputnik go binary for the docker image to allow file watching
- Revmove support for `debug` and `localport` url parameters
- Replace react-sortable-hoc with dnd-kit to avoid react console warnings and also use a maintained library
### 🐞 Bug fixes ### 🐞 Bug fixes
- Fix incorrect handing of network error response (#944) - Fix incorrect handing of network error response (#944)
- Show an error when adding a layer with a duplicate ID - _...Add new stuff here..._
- Replace deprecated `ReactDOM.render` usage with `createRoot` and drop the
`DOMNodeRemoved` cleanup hack
## 2.1.1 ## 2.1.1
+10 -8
View File
@@ -1,14 +1,16 @@
FROM golang:1.23-alpine AS builder FROM node:18 as builder
WORKDIR /maputnik WORKDIR /maputnik
RUN apk add --no-cache nodejs npm make git gcc g++ libc-dev # Only copy package.json to prevent npm install from running on every build
COPY package.json package-lock.json .npmrc ./
RUN npm ci
# Build maputnik # Build maputnik
COPY . . COPY . .
RUN npm ci RUN npx vite build
RUN CGO_ENABLED=1 GOOS=linux npm run build-linux
FROM alpine:latest #---------------------------------------------------------------------------
WORKDIR /app # Create a clean nginx-alpine slim image with just the build results
COPY --from=builder /maputnik/desktop/bin/linux ./ FROM nginx:alpine-slim
ENTRYPOINT ["/app/maputnik"]
COPY --from=builder /maputnik/dist /usr/share/nginx/html/
+2 -8
View File
@@ -18,15 +18,9 @@ targeted at developers and map designers.
- In a Docker, run this command and browse to http://localhost:8888, Ctrl+C to stop the server. - In a Docker, run this command and browse to http://localhost:8888, Ctrl+C to stop the server.
```bash ```bash
docker run -it --rm -p 8888:8000 ghcr.io/maplibre/maputnik:main docker run -it --rm -p 8888:80 ghcr.io/maplibre/maputnik:main
``` ```
To see the CLI options (for example file watching or style serving) run:
```bash
docker run -it --rm -p 8888:8000 ghcr.io/maplibre/maputnik:main --help
```
You might need to mount a volume (`-v`) to be able to use these options.
## Documentation ## Documentation
The documentation can be found in the [Wiki](https://github.com/maplibre/maputnik/wiki). You are welcome to collaborate! The documentation can be found in the [Wiki](https://github.com/maplibre/maputnik/wiki). You are welcome to collaborate!
@@ -81,7 +75,7 @@ npm run sort-styles
## Tests ## Tests
For E2E testing we use [Cypress](https://www.cypress.io/) For E2E testing we use [Cypress](https://www.cypress.io/)
[Cypress](https://www.cypress.io/) doesn't start a server so you'll need to start one manually by running `npm run start`. [Cypress](https://www.cypress.io/) doesn't starts a server so you'll need to start one manually by running `npm run start`.
Now open a terminal and run the following using *chrome*: Now open a terminal and run the following using *chrome*:
+6 -6
View File
@@ -10,12 +10,12 @@
* https://github.com/maplibre/maplibre-gl-js/blob/bc70bc559cea5c987fa1b79fd44766cef68bbe28/build/release-notes.js * https://github.com/maplibre/maplibre-gl-js/blob/bc70bc559cea5c987fa1b79fd44766cef68bbe28/build/release-notes.js
*/ */
import * as fs from "fs"; import * as fs from 'fs';
const changelogPath = "CHANGELOG.md"; const changelogPath = 'CHANGELOG.md';
let changelog = fs.readFileSync(changelogPath, "utf8"); let changelog = fs.readFileSync(changelogPath, 'utf8');
changelog = changelog.replace("## main", `## ${process.argv[2]}`); changelog = changelog.replace('## main', `## ${process.argv[2]}`);
changelog = changelog.replaceAll("- _...Add new stuff here..._\n", ""); changelog = changelog.replaceAll('- _...Add new stuff here..._\n', '');
changelog = `## main changelog = `## main
### ✨ Features and improvements ### ✨ Features and improvements
@@ -26,4 +26,4 @@ changelog = `## main
` + changelog; ` + changelog;
fs.writeFileSync(changelogPath, changelog, "utf8"); fs.writeFileSync(changelogPath, changelog, 'utf8');
+7 -7
View File
@@ -3,10 +3,10 @@
// Copied from maplibre/maplibre-gl-js // Copied from maplibre/maplibre-gl-js
// https://github.com/maplibre/maplibre-gl-js/blob/bc70bc559cea5c987fa1b79fd44766cef68bbe28/build/release-notes.js // https://github.com/maplibre/maplibre-gl-js/blob/bc70bc559cea5c987fa1b79fd44766cef68bbe28/build/release-notes.js
import * as fs from "fs"; import * as fs from 'fs';
const changelogPath = "CHANGELOG.md"; const changelogPath = 'CHANGELOG.md';
const changelog = fs.readFileSync(changelogPath, "utf8"); const changelog = fs.readFileSync(changelogPath, 'utf8');
/* /*
Parse the raw changelog text and split it into individual releases. Parse the raw changelog text and split it into individual releases.
@@ -25,8 +25,8 @@ let match;
// eslint-disable-next-line no-cond-assign // eslint-disable-next-line no-cond-assign
while (match = regex.exec(changelog)) { while (match = regex.exec(changelog)) {
releaseNotes.push({ releaseNotes.push({
"version": match[1], 'version': match[1],
"changelog": match[2].trim(), 'changelog': match[2].trim(),
}); });
} }
@@ -35,10 +35,10 @@ const previous = releaseNotes[1];
// Print the release notes template. // Print the release notes template.
let header = "Changes since previous version"; let header = 'Changes since previous version'
if (previous) { if (previous) {
header = `https://github.com/maplibre/maputnik header = `https://github.com/maplibre/maputnik
[Changes](https://github.com/maplibre/maputnik/compare/v${previous.version}...v${latest.version}) since [Maputnik v${previous.version}](https://github.com/maplibre/maputnik/releases/tag/v${previous.version})`; [Changes](https://github.com/maplibre/maputnik/compare/v${previous.version}...v${latest.version}) since [Maputnik v${previous.version}](https://github.com/maplibre/maputnik/releases/tag/v${previous.version})`
} }
const templatedReleaseNotes = `${header} const templatedReleaseNotes = `${header}
-18
View File
@@ -8,34 +8,16 @@ export default defineConfig({
exclude: "cypress/**/*.*", exclude: "cypress/**/*.*",
}, },
}, },
e2e: { e2e: {
setupNodeEvents(on, config) { setupNodeEvents(on, config) {
// implement node event listeners here // implement node event listeners here
require("@cypress/code-coverage/task")(on, config); require("@cypress/code-coverage/task")(on, config);
on("before:browser:launch", (browser, launchOptions) => {
if (browser.family !== "chromium") {
return;
}
launchOptions.args.push("--disable-gpu");
launchOptions.args.push("--enable-features=AllowSwiftShaderFallback,AllowSoftwareGLFallbackDueToCrashes");
launchOptions.args.push("--enable-unsafe-swiftshader");
return launchOptions;
});
return config; return config;
}, },
baseUrl: "http://localhost:8888", baseUrl: "http://localhost:8888",
scrollBehavior: "center",
retries: { retries: {
runMode: 2, runMode: 2,
openMode: 0, openMode: 0,
}, },
}, },
component: {
devServer: {
framework: "react",
bundler: "vite",
},
},
}); });
+2 -2
View File
@@ -18,10 +18,10 @@ describe("accessibility", () => {
then(get.skipTargetLayerList()).shouldBeFocused(); then(get.skipTargetLayerList()).shouldBeFocused();
}); });
it("skip link to layer editor", () => { // This fails for some reason only in Chrome, but passes in firefox. Adding a skip here to allow merge and later on we'll decide if we want to fix this or not.
it.skip("skip link to layer editor", () => {
const selector = "root:skip:layer-editor"; const selector = "root:skip:layer-editor";
then(get.elementByTestId(selector)).shouldExist(); then(get.elementByTestId(selector)).shouldExist();
then(get.elementByTestId("skip-target-layer-editor")).shouldExist();
when.tab().tab(); when.tab().tab();
then(get.elementByTestId(selector)).shouldBeFocused(); then(get.elementByTestId(selector)).shouldBeFocused();
when.click(selector); when.click(selector);
-18
View File
@@ -1,18 +0,0 @@
import { MaputnikDriver } from "./maputnik-driver";
describe("code editor", () => {
const { beforeAndAfter, when, get, then } = new MaputnikDriver();
beforeAndAfter();
it("open code editor", () => {
when.click("nav:code-editor");
then(get.element(".maputnik-code-editor")).shouldExist();
});
it("closes code editor", () => {
when.click("nav:code-editor");
then(get.element(".maputnik-code-editor")).shouldExist();
when.click("nav:code-editor");
then(get.element(".maputnik-code-editor")).shouldNotExist();
});
});
+1
View File
@@ -16,6 +16,7 @@ describe("history", () => {
it("undo/redo", () => { it("undo/redo", () => {
when.setStyle("geojson"); when.setStyle("geojson");
when.modal.open(); when.modal.open();
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
when.modal.fillLayers({ when.modal.fillLayers({
id: "step 1", id: "step 1",
-294
View File
@@ -1,294 +0,0 @@
import { MaputnikDriver } from "./maputnik-driver";
import { v1 as uuid } from "uuid";
describe("layer editor", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(() => {
when.setStyle("both");
when.modal.open();
});
function createBackground() {
const id = uuid();
when.selectWithin("add-layer.layer-type", "background");
when.setValue("add-layer.layer-id.input", "background:" + id);
when.click("add-layer");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + id,
type: "background",
},
],
});
return id;
}
it("expand/collapse");
it("id", () => {
const bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
const id = uuid();
when.setValue("layer-editor.layer-id.input", "foobar:" + id);
when.click("min-zoom");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "foobar:" + id,
type: "background",
},
],
});
});
describe("source", () => {
it("should show error when the source is invalid", () => {
when.modal.fillLayers({
type: "circle",
layer: "invalid",
});
then(get.element(".maputnik-input-block--error .maputnik-input-block-label")).shouldHaveCss("color", "rgb(207, 74, 74)");
});
});
describe("min-zoom", () => {
let bgId: string;
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue("min-zoom.input-text", "1");
when.click("layer-editor.layer-id");
});
it("should update min-zoom in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
minzoom: 1,
},
],
});
});
it("when clicking next layer should update style on local storage", () => {
when.type("min-zoom.input-text", "{backspace}");
when.click("max-zoom.input-text");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
minzoom: 1,
},
],
});
});
});
describe("max-zoom", () => {
let bgId: string;
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue("max-zoom.input-text", "1");
when.click("layer-editor.layer-id");
});
it("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
maxzoom: 1,
},
],
});
});
});
describe("comments", () => {
let bgId: string;
const comment = "42";
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue("layer-comment.input", comment);
when.click("layer-editor.layer-id");
});
it("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
metadata: {
"maputnik:comment": comment,
},
},
],
});
});
describe("when unsetting", () => {
beforeEach(() => {
when.clear("layer-comment.input");
when.click("min-zoom.input-text");
});
it("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
},
],
});
});
});
});
describe("color", () => {
let bgId: string;
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.click("spec-field:background-color");
});
it("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
},
],
});
});
});
describe("opacity", () => {
let bgId: string;
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.type("spec-field-input:background-opacity", "0.");
});
it("should keep '.' in the input field", () => {
then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0.");
});
it("should revert to a valid value when focus out", () => {
when.click("layer-list-item:background:" + bgId);
then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0");
});
});
describe("filter", () => {
it("expand/collapse");
it("compound filter");
});
describe("layout", () => {
it("text-font", () => {
when.setStyle("font");
when.collapseGroupInLayerEditor();
when.collapseGroupInLayerEditor(1);
when.collapseGroupInLayerEditor(2);
when.doWithin("spec-field:text-font", () => {
get.element(".maputnik-autocomplete input").first().click();
});
then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible();
then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3);
});
});
describe("paint", () => {
it("expand/collapse");
it("color");
it("pattern");
it("opacity");
});
describe("json-editor", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "circle",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "circle",
source: "example",
},
],
});
const sourceText = get.elementByText('"source"');
sourceText.click();
sourceText.type("\"");
then(get.element(".cm-lint-marker-error")).shouldExist();
});
it("expand/collapse");
it("modify");
it("parse error", () => {
const bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.collapseGroupInLayerEditor();
when.collapseGroupInLayerEditor(1);
then(get.element(".cm-lint-marker-error")).shouldNotExist();
when.appendTextInJsonEditor(
"\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013 {"
);
then(get.element(".cm-lint-marker-error")).shouldExist();
});
});
describe("sticky header", () => {
it("should keep layer header visible when scrolling properties", () => {
// Setup: Create a layer with many properties (e.g., symbol layer)
when.modal.fillLayers({
type: "symbol",
layer: "example",
});
when.wait(500);
const header = get.elementByTestId("layer-editor.header");
then(header).shouldBeVisible();
get.element(".maputnik-scroll-container").scrollTo("bottom", { ensureScrollable: false });
when.wait(200);
then(header).shouldBeVisible();
then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible();
});
});
});
-541
View File
@@ -1,541 +0,0 @@
import { MaputnikDriver } from "./maputnik-driver";
describe("layers list", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(() => {
when.setStyle("both");
when.modal.open();
});
describe("ops", () => {
let id: string;
beforeEach(() => {
id = when.modal.fillLayers({
type: "background",
});
});
it("should update layers in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
},
],
});
});
describe("when clicking delete", () => {
beforeEach(() => {
when.click("layer-list-item:" + id + ":delete");
});
it("should empty layers in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [],
});
});
});
describe("when clicking duplicate", () => {
beforeEach(() => {
when.click("layer-list-item:" + id + ":copy");
});
it("should add copy layer in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id + "-copy",
type: "background",
},
{
id: id,
type: "background",
},
],
});
});
});
describe("when clicking hide", () => {
beforeEach(() => {
when.click("layer-list-item:" + id + ":toggle-visibility");
});
it("should update visibility to none in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
layout: {
visibility: "none",
},
},
],
});
});
describe("when clicking show", () => {
beforeEach(() => {
when.click("layer-list-item:" + id + ":toggle-visibility");
});
it("should update visibility to visible in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
layout: {
visibility: "visible",
},
},
],
});
});
});
describe("when selecting a layer", () => {
let secondId: string;
beforeEach(() => {
when.modal.open();
secondId = when.modal.fillLayers({
id: "second-layer",
type: "background",
});
});
it("should show the selected layer in the editor", () => {
when.realClick("layer-list-item:" + secondId);
then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId);
when.realClick("layer-list-item:" + id);
then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id);
});
});
});
});
describe("background", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "background",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
},
],
});
});
describe("modify", () => {});
});
describe("fill", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "fill",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "fill",
source: "example",
},
],
});
});
// TODO: Change source
it("change source");
});
describe("line", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "line",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "line",
source: "example",
},
],
});
});
it("groups", () => {
when.modal.open();
const id1 = when.modal.fillLayers({
id: "aa",
type: "line",
layer: "example",
});
when.modal.open();
const id2 = when.modal.fillLayers({
id: "aa-2",
type: "line",
layer: "example",
});
when.modal.open();
const id3 = when.modal.fillLayers({
id: "b",
type: "line",
layer: "example",
});
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible();
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
when.click("layer-list-group:aa-0");
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
when.click("layer-list-item:" + id2);
when.click("skip-target-layer-editor");
when.click("menu-move-layer-down");
then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist();
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "aa",
type: "line",
source: "example",
},
{
id: "b",
type: "line",
source: "example",
},
{
id: "aa-2",
type: "line",
source: "example",
},
],
});
});
});
describe("symbol", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "symbol",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "symbol",
source: "example",
},
],
});
});
it("should show spec info when hovering and clicking single line property", () => {
when.modal.fillLayers({
type: "symbol",
layer: "example",
});
when.hover("spec-field-container:text-rotate");
then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
when.click("field-doc-button-Rotate", 0);
then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the ");
});
it("should show spec info when hovering and clicking multi line property", () => {
when.modal.fillLayers({
type: "symbol",
layer: "example",
});
when.hover("spec-field-container:text-offset");
then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible();
when.click("field-doc-button-Offset", 0);
then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance");
});
it("should hide spec info when clicking a second time", () => {
when.modal.fillLayers({
type: "symbol",
layer: "example",
});
when.hover("spec-field-container:text-rotate");
then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
when.click("field-doc-button-Rotate", 0);
when.wait(200);
when.click("field-doc-button-Rotate", 0);
then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible();
});
});
describe("raster", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "raster",
layer: "raster",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "raster",
source: "raster",
},
],
});
});
});
describe("circle", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "circle",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "circle",
source: "example",
},
],
});
});
});
describe("fill extrusion", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "fill-extrusion",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "fill-extrusion",
source: "example",
},
],
});
});
});
describe("hillshade", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "hillshade",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "hillshade",
source: "example",
},
],
});
});
it("set hillshade illumination direction array", () => {
const id = when.modal.fillLayers({
type: "hillshade",
layer: "example",
});
when.collapseGroupInLayerEditor();
when.collapseGroupInLayerEditor(1);
when.setValueToPropertyArray("spec-field:hillshade-illumination-direction", "1");
when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "2");
when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "3");
when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "4");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "hillshade",
source: "example",
paint: {
"hillshade-illumination-direction": [ 1, 2, 3, 4 ]
}
},
],
});
});
it("set hillshade highlight color array", () => {
const id = when.modal.fillLayers({
type: "hillshade",
layer: "example",
});
when.collapseGroupInLayerEditor();
when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue");
when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00");
when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 1)");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "hillshade",
source: "example",
paint: {
"hillshade-highlight-color": [ "blue", "#00ff00", "rgba(255, 255, 0, 1)" ]
}
},
],
});
});
});
describe("color-relief", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "color-relief",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "color-relief",
source: "example",
},
],
});
});
it("adds elevation expression when clicking the elevation button", () => {
when.modal.fillLayers({
type: "color-relief",
layer: "example",
});
when.collapseGroupInLayerEditor();
when.click("make-elevation-function");
then(get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line")).shouldBeVisible();
});
});
describe("groups", () => {
it("simple", () => {
when.setStyle("geojson");
when.modal.open();
when.modal.fillLayers({
id: "foo",
type: "background",
});
when.modal.open();
when.modal.fillLayers({
id: "foo_bar",
type: "background",
});
when.modal.open();
when.modal.fillLayers({
id: "foo_bar_baz",
type: "background",
});
then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible();
then(
get.elementByTestId("layer-list-item:foo_bar_baz")
).shouldNotBeVisible();
when.click("layer-list-group:foo-0");
then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
then(get.elementByTestId("layer-list-item:foo_bar")).shouldBeVisible();
then(
get.elementByTestId("layer-list-item:foo_bar_baz")
).shouldBeVisible();
});
});
describe("drag and drop", () => {
it("move layer should update local storage", () => {
when.modal.open();
const firstId = when.modal.fillLayers({
id: "a",
type: "background",
});
when.modal.open();
const secondId = when.modal.fillLayers({
id: "b",
type: "background",
});
when.modal.open();
const thirdId = when.modal.fillLayers({
id: "c",
type: "background",
});
when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: secondId,
type: "background",
},
{
id: thirdId,
type: "background",
},
{
id: firstId,
type: "background",
},
],
});
});
});
describe("sticky header", () => {
it("should keep header visible when scrolling layer list", () => {
// Setup: Create multiple layers to enable scrolling
for (let i = 0; i < 20; i++) {
when.modal.open();
when.modal.fillLayers({
id: `layer-${i}`,
type: "background",
});
}
when.wait(500);
const header = get.elementByTestId("layer-list.header");
then(header).shouldBeVisible();
// Scroll the layer list container (use ensureScrollable: false to avoid flakiness)
get.elementByTestId("layer-list").scrollTo("bottom", { ensureScrollable: false });
when.wait(200);
then(header).shouldBeVisible();
then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible();
});
});
});
+526
View File
@@ -0,0 +1,526 @@
import { v1 as uuid } from "uuid";
import { MaputnikDriver } from "./maputnik-driver";
describe("layers", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(() => {
when.setStyle("both");
when.modal.open();
});
describe("ops", () => {
let id: string;
beforeEach(() => {
id = when.modal.fillLayers({
type: "background",
});
});
it("should update layers in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
},
],
});
});
describe("when clicking delete", () => {
beforeEach(() => {
when.click("layer-list-item:" + id + ":delete");
});
it("should empty layers in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [],
});
});
});
describe("when clicking duplicate", () => {
beforeEach(() => {
when.click("layer-list-item:" + id + ":copy");
});
it("should add copy layer in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id + "-copy",
type: "background",
},
{
id: id,
type: "background",
},
],
});
});
});
describe("when clicking hide", () => {
beforeEach(() => {
when.click("layer-list-item:" + id + ":toggle-visibility");
});
it("should update visibility to none in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
layout: {
visibility: "none",
},
},
],
});
});
describe("when clicking show", () => {
beforeEach(() => {
when.click("layer-list-item:" + id + ":toggle-visibility");
});
it("should update visibility to visible in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
layout: {
visibility: "visible",
},
},
],
});
});
});
});
});
describe("background", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "background",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
},
],
});
});
describe("modify", () => {
function createBackground() {
// Setup
const id = uuid();
when.selectWithin("add-layer.layer-type", "background");
when.setValue("add-layer.layer-id.input", "background:" + id);
when.click("add-layer");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + id,
type: "background",
},
],
});
return id;
}
// ====> THESE SHOULD BE FROM THE SPEC
describe("layer", () => {
it("expand/collapse");
it("id", () => {
const bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
const id = uuid();
when.setValue("layer-editor.layer-id.input", "foobar:" + id);
when.click("min-zoom");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "foobar:" + id,
type: "background",
},
],
});
});
describe("min-zoom", () => {
let bgId: string;
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue("min-zoom.input-text", "1");
when.click("layer-editor.layer-id");
});
it("should update min-zoom in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
minzoom: 1,
},
],
});
});
it("when clicking next layer should update style on local storage", () => {
when.type("min-zoom.input-text", "{backspace}");
when.click("max-zoom.input-text");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
minzoom: 1,
},
],
});
});
});
describe("max-zoom", () => {
let bgId: string;
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue("max-zoom.input-text", "1");
when.click("layer-editor.layer-id");
});
it("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
maxzoom: 1,
},
],
});
});
});
describe("comments", () => {
let bgId: string;
const comment = "42";
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue("layer-comment.input", comment);
when.click("layer-editor.layer-id");
});
it("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
metadata: {
"maputnik:comment": comment,
},
},
],
});
});
describe("when unsetting", () => {
beforeEach(() => {
when.clear("layer-comment.input");
when.click("min-zoom.input-text");
});
it("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
},
],
});
});
});
});
describe("color", () => {
let bgId: string;
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.click("spec-field:background-color");
});
it("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
},
],
});
});
});
describe("opacity", () => {
let bgId: string;
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.type("spec-field-input:background-opacity", "0.");
});
it("should keep '.' in the input field", () => {
then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0.");
});
it("should revert to a valid value when focus out", () => {
when.click("layer-list-item:background:" + bgId);
then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue('0');
});
});
});
describe("filter", () => {
it("expand/collapse");
it("compound filter");
});
describe("paint", () => {
it("expand/collapse");
it("color");
it("pattern");
it("opacity");
});
// <=====
describe("json-editor", () => {
it("expand/collapse");
it("modify");
// TODO
it.skip("parse error", () => {
const bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
const errorSelector = ".CodeMirror-lint-marker-error";
then(get.elementByTestId(errorSelector)).shouldNotExist();
when.click(".CodeMirror");
when.typeKeys(
"\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013 {"
);
then(get.elementByTestId(errorSelector)).shouldExist();
});
});
});
});
describe("fill", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "fill",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "fill",
source: "example",
},
],
});
});
// TODO: Change source
it("change source");
});
describe("line", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "line",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "line",
source: "example",
},
],
});
});
it("groups", () => {
// TODO
// Click each of the layer groups.
});
});
describe("symbol", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "symbol",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "symbol",
source: "example",
},
],
});
});
});
describe("raster", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "raster",
layer: "raster",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "raster",
source: "raster",
},
],
});
});
});
describe("circle", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "circle",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "circle",
source: "example",
},
],
});
});
});
describe("fill extrusion", () => {
it("add", () => {
const id = when.modal.fillLayers({
type: "fill-extrusion",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "fill-extrusion",
source: "example",
},
],
});
});
});
describe("groups", () => {
it("simple", () => {
when.setStyle("geojson");
when.modal.open();
when.modal.fillLayers({
id: "foo",
type: "background",
});
when.modal.open();
when.modal.fillLayers({
id: "foo_bar",
type: "background",
});
when.modal.open();
when.modal.fillLayers({
id: "foo_bar_baz",
type: "background",
});
then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible();
then(
get.elementByTestId("layer-list-item:foo_bar_baz")
).shouldNotBeVisible();
when.click("layer-list-group:foo-0");
then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
then(get.elementByTestId("layer-list-item:foo_bar")).shouldBeVisible();
then(
get.elementByTestId("layer-list-item:foo_bar_baz")
).shouldBeVisible();
});
});
describe("layereditor jsonlint should error", ()=>{
it("add", () => {
const id = when.modal.fillLayers({
type: "circle",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "circle",
source: "example",
},
],
});
const sourceText = get.elementByText('"source"');
sourceText.click();
sourceText.type("\"");
const error = get.element('.CodeMirror-lint-marker-error');
error.should('exist');
});
});
});
+1 -36
View File
@@ -22,46 +22,11 @@ describe("map", () => {
"Zoom: " + (zoomLevel + 1) "Zoom: " + (zoomLevel + 1)
); );
}); });
it("via style file definition", () => {
when.setStyle("zoom_7_center_0_51");
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
"Zoom: " + (7)
);
then(get.locationHash().should("contain", "#7/51/0"));
// opening another stylefile does not update the map view again
// as discussed in https://github.com/maplibre/maputnik/issues/1546
when.openASecondStyleWithDifferentZoomAndCenter();
then(get.locationHash().should("contain", "#7/51/0"));
});
}); });
describe("search", () => { describe("search", () => {
it("should exist", () => { it('should exist', () => {
then(get.searchControl()).shouldBeVisible(); then(get.searchControl()).shouldBeVisible();
}); });
}); });
describe("popup", () => {
beforeEach(() => {
when.setStyle("rectangles");
then(get.locationHash().should("exist"));
});
it("should open on feature click", () => {
when.clickCenter("maplibre:map");
then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
});
it("should open a second feature after closing popup", () => {
when.clickCenter("maplibre:map");
then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
when.closePopup();
then(get.elementByTestId("feature-layer-popup")).shouldNotExist();
when.clickCenter("maplibre:map");
then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
});
});
}); });
-37
View File
@@ -1,6 +1,4 @@
/// <reference types="cypress-real-events" />
import { CypressHelper } from "@shellygo/cypress-test-utils"; import { CypressHelper } from "@shellygo/cypress-test-utils";
import "cypress-real-events/support";
export default class MaputnikCypressHelper { export default class MaputnikCypressHelper {
private helper = new CypressHelper({ defaultDataAttribute: "data-wd-key" }); private helper = new CypressHelper({ defaultDataAttribute: "data-wd-key" });
@@ -10,45 +8,10 @@ export default class MaputnikCypressHelper {
}; };
public get = { public get = {
locationHash: (): Cypress.Chainable<string> => cy.location("hash"),
...this.helper.get, ...this.helper.get,
}; };
public when = { public when = {
dragAndDropWithWait: (element: string, targetElement: string) => {
this.helper.get.elementByTestId(element).realMouseDown({ button: "left", position: "center" });
this.helper.get.elementByTestId(element).realMouseMove(0, 10, { position: "center" });
this.helper.get.elementByTestId(targetElement).realMouseMove(0, 0, { position: "center" });
this.helper.when.wait(1);
this.helper.get.elementByTestId(targetElement).realMouseUp();
},
clickCenter: (element: string) => {
this.helper.get.elementByTestId(element).realMouseDown({ button: "left", position: "center" });
this.helper.when.wait(200);
this.helper.get.elementByTestId(element).realMouseUp();
},
openFileByFixture: (fixture: string, buttonTestId: string, inputTestId: string) => {
cy.window().then((win) => {
const file = {
text: cy.stub().resolves(cy.fixture(fixture).then(JSON.stringify)),
};
const fileHandle = {
getFile: cy.stub().resolves(file),
};
if (!win.showOpenFilePicker) {
this.helper.get.elementByTestId(inputTestId).selectFile("cypress/fixtures/" + fixture, { force: true });
} else {
cy.stub(win, "showOpenFilePicker").resolves([fileHandle]);
this.helper.get.elementByTestId(buttonTestId).click();
}
});
},
dropFileByFixture: (fixture: string, dropzoneTestId: string) => {
this.helper.get.elementByTestId(dropzoneTestId).selectFile("cypress/fixtures/" + fixture, {
action: "drag-drop",
force: true,
});
},
...this.helper.when, ...this.helper.when,
}; };
+26 -112
View File
@@ -8,10 +8,8 @@ const baseUrl = "http://localhost:8888/";
const styleFromWindow = (win: Window) => { const styleFromWindow = (win: Window) => {
const styleId = win.localStorage.getItem("maputnik:latest_style"); const styleId = win.localStorage.getItem("maputnik:latest_style");
const styleItemKey = `maputnik:style:${styleId}`; const styleItem = win.localStorage.getItem(`maputnik:style:${styleId}`);
const styleItem = win.localStorage.getItem(styleItemKey); const obj = JSON.parse(styleItem || "");
if (!styleItem) throw new Error("Could not get styleItem from localStorage");
const obj = JSON.parse(styleItem);
return obj; return obj;
}; };
@@ -78,34 +76,6 @@ export class MaputnikDriver {
fixture: "geojson-raster-style.json", fixture: "geojson-raster-style.json",
}, },
}); });
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "rectangles-style.json",
response: {
fixture: "rectangles-style.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "example-style-with-fonts.json",
response: {
fixture: "example-style-with-fonts.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "example-style-with-zoom-7-and-center-0-51.json",
response: {
fixture: "example-style-with-zoom-7-and-center-0-51.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "example-style-with-zoom-5-and-center-50-50.json",
response: {
fixture: "example-style-with-zoom-5-and-center-50-50.json",
},
});
this.helper.given.interceptAndMockResponse({ this.helper.given.interceptAndMockResponse({
method: "GET", method: "GET",
url: "*example.local/*", url: "*example.local/*",
@@ -116,79 +86,52 @@ export class MaputnikDriver {
url: "*example.com/*", url: "*example.com/*",
response: [], response: [],
}); });
this.helper.given.interceptAndMockResponse({
method: "GET",
url: "https://www.glyph-server.com/*",
response: ["Font 1", "Font 2", "Font 3"],
});
}, },
}; };
public when = { public when = {
...this.helper.when, ...this.helper.when,
modal: this.modalDriver.when, modal: this.modalDriver.when,
doWithin: (selector: string, fn: () => void) => { within: (selector: string, fn: () => void) => {
this.helper.when.doWithin(fn, selector); this.helper.when.within(fn, selector);
}, },
tab: () => this.helper.get.element("body").tab(), tab: () => this.helper.get.element("body").tab(),
waitForExampleFileResponse: () => { waitForExampleFileResponse: () => {
this.helper.when.waitForResponse("example-style.json"); this.helper.when.waitForResponse("example-style.json");
}, },
openASecondStyleWithDifferentZoomAndCenter: () => {
cy.contains("button", "Open").click();
cy.get('[data-wd-key="modal:open.url.input"]')
.should("be.enabled")
.clear()
.type("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json{enter}");
},
chooseExampleFile: () => { chooseExampleFile: () => {
this.helper.given.fixture("example-style.json", "example-style.json"); this.helper.get
this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input"); .bySelector("type", "file")
this.helper.when.wait(200); .selectFile("cypress/fixtures/example-style.json", { force: true });
},
dropExampleFile: () => {
this.helper.given.fixture("example-style.json", "example-style.json");
this.helper.when.dropFileByFixture("example-style.json", "modal:open.dropzone");
this.helper.when.wait(200);
}, },
setStyle: ( setStyle: (
styleProperties: "geojson" | "raster" | "both" | "layer" | "rectangles" | "font" | "zoom_7_center_0_51" | "", styleProperties: "geojson" | "raster" | "both" | "layer" | "",
zoom?: number zoom?: number
) => { ) => {
const url = new URL(baseUrl); let url = "?debug";
switch (styleProperties) { switch (styleProperties) {
case "geojson": case "geojson":
url.searchParams.set("style", baseUrl + "geojson-style.json"); url += `&style=${baseUrl}geojson-style.json`;
break; break;
case "raster": case "raster":
url.searchParams.set("style", baseUrl + "raster-style.json"); url += `&style=${baseUrl}raster-style.json`;
break; break;
case "both": case "both":
url.searchParams.set("style", baseUrl + "geojson-raster-style.json"); url += `&style=${baseUrl}geojson-raster-style.json`;
break; break;
case "layer": case "layer":
url.searchParams.set("style", baseUrl + "example-layer-style.json"); url += `&style=${baseUrl}/example-layer-style.json`;
break; break;
case "rectangles":
url.searchParams.set("style", baseUrl + "rectangles-style.json");
break;
case "font":
url.searchParams.set("style", baseUrl + "example-style-with-fonts.json");
break;
case "zoom_7_center_0_51":
url.searchParams.set("style", baseUrl + "example-style-with-zoom-7-and-center-0-51.json");
break;
} }
if (zoom) { if (zoom) {
url.hash = `${zoom}/41.3805/2.1635`; url += `#${zoom}/41.3805/2.1635`;
} }
this.helper.when.visit(url.toString()); this.helper.when.visit(baseUrl + url);
if (styleProperties) { if (styleProperties) {
this.helper.when.acceptConfirm(); this.helper.when.acceptConfirm();
} }
// when methods should not include assertions // when methods should not include assertions
const toolbarLink = this.helper.get.elementByTestId("toolbar:link"); const toolbarLink = this.helper.get.elementByTestId("toolbar:link")
toolbarLink.scrollIntoView(); toolbarLink.scrollIntoView();
toolbarLink.should("be.visible"); toolbarLink.should("be.visible");
}, },
@@ -200,7 +143,7 @@ export class MaputnikDriver {
}, },
selectWithin: (selector: string, value: string) => { selectWithin: (selector: string, value: string) => {
this.when.doWithin(selector, () => { this.when.within(selector, () => {
this.helper.get.element("select").select(value); this.helper.get.element("select").select(value);
}); });
}, },
@@ -219,35 +162,6 @@ export class MaputnikDriver {
.clear() .clear()
.type(text, { parseSpecialCharSequences: false }); .type(text, { parseSpecialCharSequences: false });
}, },
setValueToPropertyArray: (selector: string, value: string) => {
this.when.doWithin(selector, () => {
this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true });
});
},
addValueToPropertyArray: (selector: string, value: string) => {
this.when.doWithin(selector, () => {
this.helper.get.element(".maputnik-array-add-value").click({ force: true });
this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true });
});
},
closePopup: () => {
this.helper.get.element(".maplibregl-popup-close-button").click();
},
collapseGroupInLayerEditor: (index = 0) => {
this.helper.get.element(".maputnik-layer-editor-group__button").eq(index).realClick();
},
appendTextInJsonEditor: (text: string) => {
this.helper.get.element(".cm-line").first().click().type(text, { parseSpecialCharSequences: false });
},
setTextInJsonEditor: (text: string) => {
this.helper.get.element(".cm-line").first().click().clear().type(text, { parseSpecialCharSequences: false });
}
}; };
public get = { public get = {
@@ -267,6 +181,6 @@ export class MaputnikDriver {
skipTargetLayerEditor: () => skipTargetLayerEditor: () =>
this.helper.get.elementByTestId("skip-target-layer-editor"), this.helper.get.elementByTestId("skip-target-layer-editor"),
canvas: () => this.helper.get.element("canvas"), canvas: () => this.helper.get.element("canvas"),
searchControl: () => this.helper.get.element(".maplibregl-ctrl-geocoder") searchControl: () => this.helper.get.element('.maplibregl-ctrl-geocoder')
}; };
} }
+3 -3
View File
@@ -7,7 +7,7 @@ export default class ModalDriver {
public when = { public when = {
fillLayers: (opts: { type: string; layer?: string; id?: string }) => { fillLayers: (opts: { type: string; layer?: string; id?: string }) => {
// Having logic in test code is an anti pattern. // Having logic in test code is an anti pattern.
// This should be split to multiple single responsibility functions // This should be splitted to multiple single responsibility functions
const type = opts.type; const type = opts.type;
const layer = opts.layer; const layer = opts.layer;
let id; let id;
@@ -20,8 +20,8 @@ export default class ModalDriver {
this.helper.when.type("add-layer.layer-id.input", id); this.helper.when.type("add-layer.layer-id.input", id);
if (layer) { if (layer) {
this.helper.when.doWithin(() => { this.helper.when.within(() => {
this.helper.get.element("input").clear().type(layer!); this.helper.get.element("input").type(layer!);
}, "add-layer.layer-source-block"); }, "add-layer.layer-source-block");
} }
this.helper.when.click("add-layer"); this.helper.when.click("add-layer");
+7 -185
View File
@@ -18,14 +18,10 @@ describe("modals", () => {
then(get.elementByTestId("modal:open")).shouldNotExist(); then(get.elementByTestId("modal:open")).shouldNotExist();
}); });
it("upload", () => { it.skip("upload", () => {
// HM: I was not able to make the following choose file actually to select a file and close the modal...
when.chooseExampleFile(); when.chooseExampleFile();
then(get.fixture("example-style.json")).shouldEqualToStoredStyle(); then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
});
it("upload via drag and drop", () => {
when.dropExampleFile();
then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
}); });
describe("when click open url", () => { describe("when click open url", () => {
@@ -175,22 +171,12 @@ describe("modals", () => {
}); });
it("sprite url", () => { it("sprite url", () => {
when.setTextInJsonEditor("\"http://example.com\""); when.setValue("modal:settings.sprite", "http://example.com");
when.click("modal:settings.name"); when.click("modal:settings.name");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sprite: "http://example.com", sprite: "http://example.com",
}); });
}); });
it("sprite object", () => {
when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }]));
when.click("modal:settings.name");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sprite: [{ id: "1", url: "2" }],
});
});
it("glyphs url", () => { it("glyphs url", () => {
const glyphsUrl = "http://example.com/{fontstack}/{range}.pbf"; const glyphsUrl = "http://example.com/{fontstack}/{range}.pbf";
when.setValue("modal:settings.glyphs", glyphsUrl); when.setValue("modal:settings.glyphs", glyphsUrl);
@@ -238,41 +224,6 @@ describe("modals", () => {
).shouldInclude({ "maputnik:stadia_access_token": apiKey }); ).shouldInclude({ "maputnik:stadia_access_token": apiKey });
}); });
it("locationiq access token", () => {
const apiKey = "testing123";
when.setValue(
"modal:settings.maputnik:locationiq_access_token",
apiKey
);
when.click("modal:settings.name");
then(
get.styleFromLocalStorage().then((style) => style.metadata)
).shouldInclude({ "maputnik:locationiq_access_token": apiKey });
});
it("style projection mercator", () => {
when.select("modal:settings.projection", "mercator");
then(
get.styleFromLocalStorage().then((style) => style.projection)
).shouldInclude({ type: "mercator" });
});
it("style projection globe", () => {
when.select("modal:settings.projection", "globe");
then(
get.styleFromLocalStorage().then((style) => style.projection)
).shouldInclude({ type: "globe" });
});
it("style projection vertical-perspective", () => {
when.select("modal:settings.projection", "vertical-perspective");
then(
get.styleFromLocalStorage().then((style) => style.projection)
).shouldInclude({ type: "vertical-perspective" });
});
it("style renderer", () => { it("style renderer", () => {
cy.on("uncaught:exception", () => false); // this is due to the fact that this is an invalid style for openlayers cy.on("uncaught:exception", () => false); // this is due to the fact that this is an invalid style for openlayers
when.select("modal:settings.maputnik:renderer", "ol"); when.select("modal:settings.maputnik:renderer", "ol");
@@ -288,12 +239,12 @@ describe("modals", () => {
it("include API key when change renderer", () => { it("inlcude API key when change renderer", () => {
when.click("modal:settings.close-modal"); when.click("modal:settings.close-modal")
when.click("nav:open"); when.click("nav:open");
get.elementByAttribute("aria-label", "MapTiler Basic").should("exist").click(); get.elementByAttribute('aria-label', "MapTiler Basic").should('exist').click();
when.wait(1000); when.wait(1000);
when.click("nav:settings"); when.click("nav:settings");
@@ -321,136 +272,7 @@ describe("modals", () => {
}); });
describe("add layer", () => {
beforeEach(() => {
when.setStyle("layer");
when.modal.open();
});
it("shows duplicate id error", () => {
when.setValue("add-layer.layer-id.input", "background");
when.click("add-layer");
then(get.elementByTestId("modal:add-layer")).shouldExist();
then(get.element(".maputnik-modal-error")).shouldContainText(
"Layer ID already exists"
);
});
});
describe("sources", () => { describe("sources", () => {
it("toggle"); it("toggle");
}); });
describe("global state", () => {
beforeEach(() => {
when.click("nav:global-state");
});
it("add variable", () => {
when.wait(100);
when.click("global-state-add-variable");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key1: { default: "value" } },
});
});
it("add multiple variables", () => {
when.click("global-state-add-variable");
when.click("global-state-add-variable");
when.click("global-state-add-variable");
when.wait(100);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key1: { default: "value" }, key2: { default: "value" }, key3: { default: "value" } },
});
});
it("remove variable", () => {
when.click("global-state-add-variable");
when.click("global-state-add-variable");
when.click("global-state-add-variable");
when.click("global-state-remove-variable", 0);
when.wait(100);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key2: { default: "value" }, key3: { default: "value" } },
});
});
it("edit variable key", () => {
when.click("global-state-add-variable");
when.wait(100);
when.setValue("global-state-variable-key:0", "mykey");
when.typeKeys("{enter}");
when.wait(100);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { mykey: { default: "value" } },
});
});
it("edit variable value", () => {
when.click("global-state-add-variable");
when.wait(100);
when.setValue("global-state-variable-value:0", "myvalue");
when.typeKeys("{enter}");
when.wait(100);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key1: { default: "myvalue" } },
});
});
});
describe("error panel", () => {
it("not visible when no errors", () => {
then(get.element("maputnik-message-panel-error")).shouldNotExist();
});
it("visible on style error", () => {
when.modal.open();
when.modal.fillLayers({
type: "circle",
layer: "invalid",
});
then(get.element(".maputnik-message-panel-error")).shouldBeVisible();
});
});
describe("Handle localStorage QuotaExceededError", () => {
it("handles quota exceeded error when opening style from URL", () => {
// Clear localStorage to start fresh
cy.clearLocalStorage();
// fill localStorage until we get a QuotaExceededError
cy.window().then(win => {
let chunkSize = 1000;
const chunk = new Array(chunkSize).join("x");
let index = 0;
// Keep adding until we hit the quota
while (true) {
try {
const key = `maputnik:fill-${index++}`;
win.localStorage.setItem(key, chunk);
} catch (e: any) {
// Verify it's a quota error
if (e.name === "QuotaExceededError") {
if (chunkSize <= 1) return;
else {
chunkSize /= 2;
continue;
}
}
throw e; // Unexpected error
}
}
});
// Open the style via URL input
when.click("nav:open");
when.setValue("modal:open.url.input", get.exampleFileUrl());
when.click("modal:open.url.button");
then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
then(get.styleFromLocalStorage()).shouldExist();
});
});
}); });
@@ -1,38 +0,0 @@
{
"id": "test-style",
"version": 8,
"name": "Test Style",
"metadata": {
"maputnik:renderer": "mlgljs"
},
"sources": {
"example": {
"type": "geojson",
"data": {
"type": "FeatureCollection",
"features":[{
"type": "Feature",
"properties": {
"name": "Dinagat Islands"
},
"geometry":{
"type": "Point",
"coordinates": [125.6, 10.1]
}
}]
}
}
},
"glyphs": "https://www.glyph-server.com/fonts/{fontstack}/{range}.pbf",
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
"layers": [
{
"id": "label",
"type": "symbol",
"source": "example",
"layout": {
"text-font": ["Font"]
}
}
]
}
@@ -1,19 +0,0 @@
{
"id": "test-style",
"center": [50,50],
"zoom": 5,
"version": 8,
"name": "Test Style",
"sources": {
"rectangles": {
"type": "geojson",
"data": {
"type": "FeatureCollection",
"features": []
}
}
},
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
"layers": []
}
@@ -1,17 +0,0 @@
{
"id": "test-style",
"center": [0,51],
"zoom": 7,
"version": 8,
"name": "Test Style",
"sources": {
"rectangles": {
"type": "geojson",
"data": {
"type": "FeatureCollection",
"features": []
}
}
},
"layers": []
}
+10 -112
View File
@@ -1,114 +1,12 @@
{ {
"id": "test-style", "id": "test-style",
"version": 8, "version": 8,
"name": "Test Style", "name": "Test Style",
"metadata": { "metadata": {
"maputnik:renderer": "mlgljs", "maputnik:renderer": "mlgljs"
"data": [ },
0, "sources": {},
1, "glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
2, "sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
3, "layers": []
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43,
44,
45,
46,
47,
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59,
60,
61,
62,
63,
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75,
76,
77,
78,
79,
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91,
92,
93,
94,
95,
96,
97,
98,
99
]
},
"sources": {},
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
"layers": []
} }
-92
View File
@@ -1,92 +0,0 @@
{
"version": 8,
"sources": {
"rectangles": {
"type": "geojson",
"data": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-130.78125,
-33.13755119234615
],
[
-130.78125,
63.548552232036414
],
[
15.468749999999998,
63.548552232036414
],
[
15.468749999999998,
-33.13755119234615
],
[
-130.78125,
-33.13755119234615
]
]
]
}
},
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-48.515625,
-54.97761367069625
],
[
-48.515625,
36.5978891330702
],
[
169.45312499999997,
36.5978891330702
],
[
169.45312499999997,
-54.97761367069625
],
[
-48.515625,
-54.97761367069625
]
]
]
}
}
]
}
}
},
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "white"
}
},
{
"id": "rectangles",
"type": "fill",
"source": "rectangles",
"paint": {
"fill-opacity": 0.3
}
}
]
}
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "./commands";
import { mount } from "cypress/react";
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
/* eslint-disable @typescript-eslint/no-namespace */
namespace Cypress {
interface Chainable {
mount: typeof mount
}
}
}
Cypress.Commands.add("mount", mount);
// Example use:
// cy.mount(<MyComponent />)
+2 -5
View File
@@ -2,7 +2,8 @@ SOURCEDIR=.
SOURCES := $(shell find $(SOURCEDIR) -name '*.go') SOURCES := $(shell find $(SOURCEDIR) -name '*.go')
BINARY=maputnik BINARY=maputnik
VERSION := $(shell node -p "require('../package.json').version") VERSION := $(shell node -p "require('../package.json').version")
GOBIN := $(or $(shell if [ -d /go/bin ]; then echo "/go/bin"; fi),$(HOME)/go/bin) GOPATH := $(if $(GOPATH),$(GOPATH),$(HOME)/go)
GOBIN := $(if $(GOBIN),$(GOBIN),$(HOME)/go/bin)
all: $(BINARY) all: $(BINARY)
@@ -10,10 +11,6 @@ $(BINARY): $(GOBIN)/gox $(GOBIN)/go-winres $(SOURCES) version.go rice-box.go win
$(GOBIN)/go-winres make --product-version=$(VERSION) $(GOBIN)/go-winres make --product-version=$(VERSION)
$(GOBIN)/gox -osarch "windows/amd64 linux/amd64 darwin/amd64" -output "bin/{{.OS}}/${BINARY}" $(GOBIN)/gox -osarch "windows/amd64 linux/amd64 darwin/amd64" -output "bin/{{.OS}}/${BINARY}"
bin/linux/$(BINARY): $(GOBIN)/gox $(GOBIN)/go-winres $(SOURCES) version.go rice-box.go winres/winres.json
$(GOBIN)/go-winres make --product-version=$(VERSION)
$(GOBIN)/gox -osarch "linux/amd64" -output "bin/{{.OS}}/${BINARY}"
winres/winres.json: winres/winres_template.json winres/winres.json: winres/winres_template.json
sed 's/{{.Version}}/$(VERSION)/g' winres/winres_template.json > $@ sed 's/{{.Version}}/$(VERSION)/g' winres/winres_template.json > $@
+2 -1
View File
@@ -5,7 +5,8 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"github.com/GeertJohan/go.rice"
"github.com/GeertJohan/go.rice"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/maputnik/desktop/filewatch" "github.com/maputnik/desktop/filewatch"
+30 -39
View File
@@ -1,65 +1,56 @@
import eslint from "@eslint/js"; import eslint from '@eslint/js';
import {defineConfig} from "eslint/config"; import tseslint from 'typescript-eslint';
import stylisticTs from "@stylistic/eslint-plugin"; import reactPlugin from 'eslint-plugin-react';
import tseslint from "typescript-eslint"; import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactPlugin from "eslint-plugin-react"; import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import reactHooksPlugin from "eslint-plugin-react-hooks";
import reactRefreshPlugin from "eslint-plugin-react-refresh";
export default defineConfig({ export default tseslint.config({
extends: [ extends: [
eslint.configs.recommended, eslint.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
], ],
files: ["**/*.{js,jsx,ts,tsx}"], files: ['**/*.{js,jsx,ts,tsx}'],
ignores: [ ignores: [
"dist/**/*", "dist/**/*",
], ],
languageOptions: { languageOptions: {
ecmaVersion: 2024, ecmaVersion: 2024,
sourceType: "module", sourceType: 'module',
globals: { globals: {
global: "readonly" global: 'readonly'
} }
}, },
settings: { settings: {
react: { version: "18.2" } react: { version: '18.2' }
}, },
plugins: { plugins: {
"react": reactPlugin, 'react': reactPlugin,
"react-hooks": reactHooksPlugin, 'react-hooks': reactHooksPlugin,
"react-refresh": reactRefreshPlugin, 'react-refresh': reactRefreshPlugin
"@stylistic": stylisticTs
}, },
rules: { rules: {
"react-refresh/only-export-components": [ 'react-refresh/only-export-components': [
"warn", 'warn',
{ allowConstantExport: true } { allowConstantExport: true }
], ],
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [ '@typescript-eslint/no-unused-vars': [
"warn", 'warn',
{ {
varsIgnorePattern: "^_", varsIgnorePattern: '^_',
caughtErrors: "all", caughtErrors: 'all',
caughtErrorsIgnorePattern: "^_", caughtErrorsIgnorePattern: '^_',
argsIgnorePattern: "^_" argsIgnorePattern: '^_'
} }
], ],
"no-unused-vars": "off", 'no-unused-vars': 'off',
"react/prop-types": "off", 'react/prop-types': 'off',
"no-undef": "off", 'no-undef': 'off',
"indent": "off", 'indent': ['error', 2],
"@stylistic/indent": ["error", 2], 'no-var': 'error',
"semi": "off", '@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
"@stylistic/semi": ["error", "always"], '@typescript-eslint/ban-ts-comment': 'off',
"quotes": "off", '@typescript-eslint/no-empty-object-type': 'off',
"@stylistic/quotes": ["error", "double", { avoidEscape: true }],
"no-var": "error",
"@typescript-eslint/no-non-null-asserted-optional-chain": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }],
}, },
linterOptions: { linterOptions: {
@@ -67,4 +58,4 @@ export default defineConfig({
noInlineConfig: false noInlineConfig: false
} }
} }
); )
+17
View File
@@ -0,0 +1,17 @@
export default {
output: 'src/locales/$LOCALE/$NAMESPACE.json',
locales: [ 'de', 'fr', 'he', 'it','ja', 'zh' ],
// Because some keys are dynamically generated, i18next-parser can't detect them.
// We add these keys manually, so we don't want to remove them.
keepRemoved: true,
// We use plain English keys, so we disable key and namespace separators.
keySeparator: false,
namespaceSeparator: false,
defaultValue: (_locale, _ns, _key) => {
// The default value is a string that indicates that the string is not translated.
return '__STRING_NOT_TRANSLATED__';
}
}
-10
View File
@@ -1,10 +0,0 @@
import { defineConfig } from "i18next-cli";
export default defineConfig({
locales: ["de", "fr", "he", "it", "ja", "ko", "tr", "zh"],
extract: {
input: ["src/**/*.{js,jsx,ts,tsx}"],
output: "src/locales/{{language}}/{{namespace}}.json",
defaultValue: "__STRING_NOT_TRANSLATED__"
}
});
+4986 -7032
View File
File diff suppressed because it is too large Load Diff
+65 -69
View File
@@ -1,19 +1,16 @@
{ {
"name": "maputnik", "name": "maputnik",
"version": "3.1.0", "version": "2.1.1",
"description": "A MapLibre GL visual style editor", "description": "A MapLibre GL visual style editor",
"type": "module", "type": "module",
"main": "''", "main": "''",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "tsc && vite build --mode=production", "build": "tsc && vite build --base=/maputnik/",
"build-desktop": "tsc && vite build --mode=desktop && cd desktop && make", "build-desktop": "tsc && vite build --base=/ && cd desktop && make",
"build-linux": "tsc && vite build --mode=desktop && cd desktop && make bin/linux/maputnik", "i18n:refresh": "i18next 'src/**/*.{ts,tsx,js,jsx}'",
"i18n:extract": "npx i18next-cli extract",
"lint": "eslint", "lint": "eslint",
"test": "cypress run", "test": "cypress run",
"test-unit": "vitest",
"test-unit-ci": "vitest run --coverage --reporter=json",
"cy:open": "cypress open", "cy:open": "cypress open",
"lint-css": "stylelint \"src/styles/*.scss\"", "lint-css": "stylelint \"src/styles/*.scss\"",
"sort-styles": "jq 'sort_by(.id)' src/config/styles.json > tmp.json && mv tmp.json src/config/styles.json" "sort-styles": "jq 'sort_by(.id)' src/config/styles.json > tmp.json && mv tmp.json src/config/styles.json"
@@ -26,57 +23,55 @@
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/maplibre/maputnik#readme", "homepage": "https://github.com/maplibre/maputnik#readme",
"dependencies": { "dependencies": {
"@codemirror/lang-json": "^6.0.2", "@mapbox/mapbox-gl-rtl-text": "^0.3.0",
"@codemirror/lint": "^6.9.7", "@maplibre/maplibre-gl-geocoder": "^1.7.1",
"@codemirror/state": "^6.7.0", "@maplibre/maplibre-gl-inspect": "^1.7.1",
"@codemirror/theme-one-dark": "^6.1.3", "@maplibre/maplibre-gl-style-spec": "^23.1.0",
"@codemirror/view": "^6.43.4", "@mdi/js": "^7.4.47",
"@dnd-kit/core": "^6.3.1", "@mdi/react": "^1.6.1",
"@dnd-kit/sortable": "^10.0.0", "@prantlf/jsonlint": "^16.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
"@maplibre/maplibre-gl-inspect": "^1.8.2",
"@maplibre/maplibre-gl-style-spec": "^25.0.1",
"array-move": "^4.0.0", "array-move": "^4.0.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror": "^6.0.2", "codemirror": "^5.65.18",
"color": "^5.0.3", "color": "^5.0.0",
"detect-browser": "^5.3.0", "detect-browser": "^5.3.0",
"downshift": "^9.4.0",
"events": "^3.3.0", "events": "^3.3.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"i18next": "^26.3.4", "i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.0.4",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"json-stringify-pretty-compact": "^4.0.0", "json-stringify-pretty-compact": "^4.0.0",
"json-to-ast": "^2.1.0", "json-to-ast": "^2.1.0",
"lodash": "^4.18.1", "lodash": "^4.17.21",
"lodash.capitalize": "^4.2.1", "lodash.capitalize": "^4.2.1",
"lodash.clamp": "^4.0.3", "lodash.clamp": "^4.0.3",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"maplibre-gl": "^5.24.0", "maplibre-gl": "^5.1.0",
"maputnik-design": "github:maputnik/design#172b06c", "maputnik-design": "github:maputnik/design#172b06c",
"ol": "^10.9.0", "ol": "^10.4.0",
"ol-mapbox-style": "^13.4.1", "ol-mapbox-style": "^12.4.0",
"pmtiles": "^4.4.1", "pmtiles": "^4.1.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^19.2.4", "react": "^18.2.0",
"react-accessible-accordion": "^5.0.1", "react-accessible-accordion": "^5.0.0",
"react-aria-menubutton": "^8.0.0", "react-aria-menubutton": "^7.0.3",
"react-aria-modal": "^5.0.2", "react-aria-modal": "^5.0.2",
"react-autobind": "^1.0.6",
"react-autocomplete": "^1.8.1",
"react-collapse": "^5.1.1", "react-collapse": "^5.1.1",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-dom": "^19.2.4", "react-dom": "^18.2.0",
"react-i18next": "^17.0.7", "react-file-reader-input": "^2.0.0",
"react-icons": "^5.6.0", "react-i18next": "^15.4.0",
"react-markdown": "^10.1.0", "react-icon-base": "^2.1.2",
"react-icons": "^5.4.0",
"react-sortable-hoc": "^2.0.0",
"reconnecting-websocket": "^4.4.0", "reconnecting-websocket": "^4.4.0",
"slugify": "^1.6.9", "slugify": "^1.6.6",
"string-hash": "^1.1.3", "string-hash": "^1.1.3",
"url": "^0.11.4" "url": "^0.11.4"
}, },
@@ -98,15 +93,14 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@cypress/code-coverage": "^4.0.3", "@cypress/code-coverage": "^3.13.11",
"@eslint/js": "^10.0.1", "@eslint/js": "^9.19.0",
"@istanbuljs/nyc-config-typescript": "^1.0.2", "@istanbuljs/nyc-config-typescript": "^1.0.2",
"@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-replace": "^6.0.2",
"@shellygo/cypress-test-utils": "^6.0.6", "@shellygo/cypress-test-utils": "^4.1.12",
"@stylistic/eslint-plugin": "^5.10.0", "@types/codemirror": "^5.60.15",
"@types/codemirror": "^5.60.17", "@types/color": "^4.2.0",
"@types/color": "^4.2.1", "@types/cors": "^2.8.17",
"@types/cors": "^2.8.19",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@types/json-to-ast": "^2.1.4", "@types/json-to-ast": "^2.1.4",
@@ -117,37 +111,39 @@
"@types/lodash.isequal": "^4.5.8", "@types/lodash.isequal": "^4.5.8",
"@types/lodash.throttle": "^4.1.9", "@types/lodash.throttle": "^4.1.9",
"@types/randomcolor": "^0.5.9", "@types/randomcolor": "^0.5.9",
"@types/react": "^19.2.14", "@types/react": "^18.2.67",
"@types/react-aria-menubutton": "^6.2.14", "@types/react-aria-menubutton": "^6.2.14",
"@types/react-aria-modal": "^5.0.0", "@types/react-aria-modal": "^5.0.0",
"@types/react-autocomplete": "^1.8.11",
"@types/react-collapse": "^5.0.4", "@types/react-collapse": "^5.0.4",
"@types/react-color": "^3.0.13", "@types/react-color": "^3.0.13",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^18.2.22",
"@types/react-file-reader-input": "^2.0.4",
"@types/react-icon-base": "^2.1.6",
"@types/string-hash": "^1.1.3", "@types/string-hash": "^1.1.3",
"@types/wicg-file-system-access": "^2023.10.7", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.1.4", "@types/wicg-file-system-access": "^2023.10.5",
"@vitest/coverage-v8": "^4.1.9", "@vitejs/plugin-react": "^4.3.4",
"cors": "^2.8.6", "cors": "^2.8.5",
"cypress": "^15.18.0", "cypress": "^14.0.2",
"cypress-plugin-tab": "^2.0.0", "cypress-plugin-tab": "^1.0.5",
"eslint": "^10.6.0", "eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.4.18",
"i18next-cli": "^1.53.2", "i18next-parser": "^9.1.0",
"istanbul": "^0.4.5", "istanbul": "^0.4.5",
"istanbul-lib-coverage": "^3.2.2", "istanbul-lib-coverage": "^3.2.2",
"postcss": "^8.5.16", "postcss": "^8.5.1",
"react-hot-loader": "^4.13.1", "react-hot-loader": "^4.13.1",
"sass": "^1.101.0", "sass": "^1.85.1",
"stylelint": "^17.14.0", "stylelint": "^16.14.1",
"stylelint-config-recommended-scss": "^17.0.1", "stylelint-config-recommended-scss": "^14.1.0",
"stylelint-scss": "^7.2.0", "stylelint-scss": "^6.11.0",
"typescript": "^6.0.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.62.1", "typescript-eslint": "^8.25.0",
"uuid": "^14.0.1", "uuid": "^11.0.5",
"vite": "^7.3.2", "vite": "^6.2.0",
"vite-plugin-istanbul": "^9.0.1", "vite-plugin-istanbul": "^7.0.0"
"vitest": "^4.1.9"
} }
} }
+314 -292
View File
File diff suppressed because it is too large Load Diff
+29 -31
View File
@@ -1,52 +1,50 @@
import React from "react"; import React from 'react'
import ScrollContainer from "./ScrollContainer"; import PropTypes from 'prop-types'
import { type WithTranslation, withTranslation } from "react-i18next"; import ScrollContainer from './ScrollContainer'
import { IconContext } from "react-icons"; import { WithTranslation, withTranslation } from 'react-i18next';
type AppLayoutInternalProps = { type AppLayoutInternalProps = {
toolbar: React.ReactElement toolbar: React.ReactElement
layerList: React.ReactElement layerList: React.ReactElement
layerEditor?: React.ReactElement layerEditor?: React.ReactElement
codeEditor?: React.ReactElement
map: React.ReactElement map: React.ReactElement
bottom?: React.ReactElement bottom?: React.ReactElement
modals?: React.ReactNode modals?: React.ReactNode
} & WithTranslation; } & WithTranslation;
class AppLayoutInternal extends React.Component<AppLayoutInternalProps> { class AppLayoutInternal extends React.Component<AppLayoutInternalProps> {
static childContextTypes = {
reactIconBase: PropTypes.object
}
getChildContext() {
return {
reactIconBase: { size: 14 }
}
}
render() { render() {
document.body.dir = this.props.i18n.dir(); document.body.dir = this.props.i18n.dir();
return <IconContext.Provider value={{size: "14px"}}> return <div className="maputnik-layout">
<div className="maputnik-layout"> {this.props.toolbar}
{this.props.toolbar} <div className="maputnik-layout-main">
<div className="maputnik-layout-main"> <div className="maputnik-layout-list">
{this.props.codeEditor && <div className="maputnik-layout-code-editor"> {this.props.layerList}
<ScrollContainer>
{this.props.codeEditor}
</ScrollContainer>
</div>
}
{!this.props.codeEditor && <>
<div className="maputnik-layout-list">
{this.props.layerList}
</div>
<div className="maputnik-layout-drawer">
<ScrollContainer>
{this.props.layerEditor}
</ScrollContainer>
</div>
</>}
{this.props.map}
</div> </div>
{this.props.bottom && <div className="maputnik-layout-bottom"> <div className="maputnik-layout-drawer">
{this.props.bottom} <ScrollContainer>
{this.props.layerEditor}
</ScrollContainer>
</div> </div>
} {this.props.map}
{this.props.modals}
</div> </div>
</IconContext.Provider>; {this.props.bottom && <div className="maputnik-layout-bottom">
{this.props.bottom}
</div>
}
{this.props.modals}
</div>
} }
} }
+20 -19
View File
@@ -1,33 +1,34 @@
import React from "react"; import React from 'react'
import { formatLayerId } from "../libs/format"; import {formatLayerId} from '../libs/format';
import { type LayerSpecification, type StyleSpecification } from "maplibre-gl"; import {LayerSpecification, StyleSpecification} from 'maplibre-gl';
import { type WithTranslation, withTranslation } from "react-i18next"; import { Trans, WithTranslation, withTranslation } from 'react-i18next';
import { type MappedError } from "../libs/definitions";
type AppMessagePanelInternalProps = { type AppMessagePanelInternalProps = {
errors?: MappedError[] errors?: unknown[]
infos?: string[] infos?: string[]
mapStyle?: StyleSpecification mapStyle?: StyleSpecification
onLayerSelect?(index: number): void; onLayerSelect?(...args: unknown[]): unknown
currentLayer?: LayerSpecification currentLayer?: LayerSpecification
selectedLayerIndex?: number selectedLayerIndex?: number
} & WithTranslation; } & WithTranslation;
class AppMessagePanelInternal extends React.Component<AppMessagePanelInternalProps> { class AppMessagePanelInternal extends React.Component<AppMessagePanelInternalProps> {
static defaultProps = { static defaultProps = {
onLayerSelect: () => { }, onLayerSelect: () => {},
}; }
render() { render() {
const { t, selectedLayerIndex } = this.props; const {t, selectedLayerIndex} = this.props;
const errors = this.props.errors?.map((error, idx) => { const errors = this.props.errors?.map((error: any, idx) => {
let content; let content;
if (error.parsed && error.parsed.type === "layer") { if (error.parsed && error.parsed.type === "layer") {
const { parsed } = error; const {parsed} = error;
const layerId = this.props.mapStyle?.layers[parsed.data.index].id; const layerId = this.props.mapStyle?.layers[parsed.data.index].id;
content = ( content = (
<> <>
{t("Layer")} <span>{formatLayerId(layerId)}</span>: {parsed.data.message} <Trans t={t}>
Layer <span>{formatLayerId(layerId)}</span>: {parsed.data.message}
</Trans>
{selectedLayerIndex !== parsed.data.index && {selectedLayerIndex !== parsed.data.index &&
<> <>
&nbsp;&mdash;&nbsp; &nbsp;&mdash;&nbsp;
@@ -45,19 +46,19 @@ class AppMessagePanelInternal extends React.Component<AppMessagePanelInternalPro
else { else {
content = error.message; content = error.message;
} }
return <p key={"error-" + idx} className="maputnik-message-panel-error"> return <p key={"error-"+idx} className="maputnik-message-panel-error">
{content} {content}
</p>; </p>
}); })
const infos = this.props.infos?.map((m, i) => { const infos = this.props.infos?.map((m, i) => {
return <p key={"info-" + i}>{m}</p>; return <p key={"info-"+i}>{m}</p>
}); })
return <div className="maputnik-message-panel"> return <div className="maputnik-message-panel">
{errors} {errors}
{infos} {infos}
</div>; </div>
} }
} }
+27 -38
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from 'react'
import classnames from "classnames"; import classnames from 'classnames'
import {detect} from "detect-browser"; import {detect} from 'detect-browser';
import { import {
MdOpenInBrowser, MdOpenInBrowser,
@@ -9,22 +9,18 @@ import {
MdHelpOutline, MdHelpOutline,
MdFindInPage, MdFindInPage,
MdLanguage, MdLanguage,
MdSave, MdSave
MdPublic, } from 'react-icons/md'
MdCode import pkgJson from '../../package.json'
} from "react-icons/md";
import pkgJson from "../../package.json";
//@ts-ignore //@ts-ignore
import maputnikLogo from "maputnik-design/logos/logo-color.svg?inline"; import maputnikLogo from 'maputnik-design/logos/logo-color.svg?inline'
import { withTranslation, type WithTranslation } from "react-i18next"; import { withTranslation, WithTranslation } from 'react-i18next';
import { supportedLanguages } from "../i18n"; import { supportedLanguages } from '../i18n';
import type { OnStyleChangedCallback } from "../libs/definitions";
// This is required because of <https://stackoverflow.com/a/49846426>, there isn't another way to detect support that I'm aware of. // This is required because of <https://stackoverflow.com/a/49846426>, there isn't another way to detect support that I'm aware of.
const browser = detect(); const browser = detect();
const colorAccessibilityFiltersEnabled = ["chrome", "firefox"].indexOf(browser!.name) > -1; const colorAccessibilityFiltersEnabled = ['chrome', 'firefox'].indexOf(browser!.name) > -1;
export type ModalTypes = "settings" | "sources" | "open" | "shortcuts" | "export" | "debug" | "globalState" | "codeEditor";
type IconTextProps = { type IconTextProps = {
children?: React.ReactNode children?: React.ReactNode
@@ -33,7 +29,7 @@ type IconTextProps = {
class IconText extends React.Component<IconTextProps> { class IconText extends React.Component<IconTextProps> {
render() { render() {
return <span className="maputnik-icon-text">{this.props.children}</span>; return <span className="maputnik-icon-text">{this.props.children}</span>
} }
} }
@@ -41,19 +37,20 @@ type ToolbarLinkProps = {
className?: string className?: string
children?: React.ReactNode children?: React.ReactNode
href?: string href?: string
onToggleModal?(...args: unknown[]): unknown
}; };
class ToolbarLink extends React.Component<ToolbarLinkProps> { class ToolbarLink extends React.Component<ToolbarLinkProps> {
render() { render() {
return <a return <a
className={classnames("maputnik-toolbar-link", this.props.className)} className={classnames('maputnik-toolbar-link', this.props.className)}
href={this.props.href} href={this.props.href}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
data-wd-key="toolbar:link" data-wd-key="toolbar:link"
> >
{this.props.children} {this.props.children}
</a>; </a>
} }
} }
@@ -69,7 +66,7 @@ class ToolbarSelect extends React.Component<ToolbarSelectProps> {
data-wd-key={this.props.wdKey} data-wd-key={this.props.wdKey}
> >
{this.props.children} {this.props.children}
</div>; </div>
} }
} }
@@ -87,7 +84,7 @@ class ToolbarAction extends React.Component<ToolbarActionProps> {
onClick={this.props.onClick} onClick={this.props.onClick}
> >
{this.props.children} {this.props.children}
</button>; </button>
} }
} }
@@ -96,13 +93,13 @@ export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deut
type AppToolbarInternalProps = { type AppToolbarInternalProps = {
mapStyle: object mapStyle: object
inspectModeEnabled: boolean inspectModeEnabled: boolean
onStyleChanged: OnStyleChangedCallback onStyleChanged(...args: unknown[]): unknown
// A new style has been uploaded // A new style has been uploaded
onStyleOpen: OnStyleChangedCallback onStyleOpen(...args: unknown[]): unknown
// A dict of source id's and the available source layers // A dict of source id's and the available source layers
sources: object sources: object
children?: React.ReactNode children?: React.ReactNode
onToggleModal(modal: ModalTypes): void onToggleModal(...args: unknown[]): unknown
onSetMapState(mapState: MapState): unknown onSetMapState(mapState: MapState): unknown
mapState?: MapState mapState?: MapState
renderer?: string renderer?: string
@@ -117,7 +114,7 @@ class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
add: false, add: false,
export: false, export: false,
} }
}; }
handleSelection(val: MapState) { handleSelection(val: MapState) {
this.props.onSetMapState(val); this.props.onSetMapState(val);
@@ -135,7 +132,7 @@ class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
const el = document.querySelector("#skip-target-"+target) as HTMLButtonElement; const el = document.querySelector("#skip-target-"+target) as HTMLButtonElement;
el.focus(); el.focus();
} }
}; }
render() { render() {
const t = this.props.t; const t = this.props.t;
@@ -149,7 +146,7 @@ class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
id: "inspect", id: "inspect",
group: "general", group: "general",
title: t("Inspect"), title: t("Inspect"),
disabled: this.props.renderer === "ol", disabled: this.props.renderer === 'ol',
}, },
{ {
id: "filter-deuteranopia", id: "filter-deuteranopia",
@@ -222,30 +219,22 @@ class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
</a> </a>
</div> </div>
<div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar"> <div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar">
<ToolbarAction wdKey="nav:open" onClick={() => this.props.onToggleModal("open")}> <ToolbarAction wdKey="nav:open" onClick={this.props.onToggleModal.bind(this, 'open')}>
<MdOpenInBrowser /> <MdOpenInBrowser />
<IconText>{t("Open")}</IconText> <IconText>{t("Open")}</IconText>
</ToolbarAction> </ToolbarAction>
<ToolbarAction wdKey="nav:export" onClick={() => this.props.onToggleModal("export")}> <ToolbarAction wdKey="nav:export" onClick={this.props.onToggleModal.bind(this, 'export')}>
<MdSave /> <MdSave />
<IconText>{t("Save")}</IconText> <IconText>{t("Save")}</IconText>
</ToolbarAction> </ToolbarAction>
<ToolbarAction wdKey="nav:code-editor" onClick={() => this.props.onToggleModal("codeEditor")}> <ToolbarAction wdKey="nav:sources" onClick={this.props.onToggleModal.bind(this, 'sources')}>
<MdCode />
<IconText>{t("Code Editor")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:sources" onClick={() => this.props.onToggleModal("sources")}>
<MdLayers /> <MdLayers />
<IconText>{t("Data Sources")}</IconText> <IconText>{t("Data Sources")}</IconText>
</ToolbarAction> </ToolbarAction>
<ToolbarAction wdKey="nav:settings" onClick={() => this.props.onToggleModal("settings")}> <ToolbarAction wdKey="nav:settings" onClick={this.props.onToggleModal.bind(this, 'settings')}>
<MdSettings /> <MdSettings />
<IconText>{t("Style Settings")}</IconText> <IconText>{t("Style Settings")}</IconText>
</ToolbarAction> </ToolbarAction>
<ToolbarAction wdKey="nav:global-state" onClick={() => this.props.onToggleModal("globalState")}>
<MdPublic />
<IconText>{t("Global State")}</IconText>
</ToolbarAction>
<ToolbarSelect wdKey="nav:inspect"> <ToolbarSelect wdKey="nav:inspect">
<MdFindInPage /> <MdFindInPage />
@@ -302,7 +291,7 @@ class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
</ToolbarLink> </ToolbarLink>
</div> </div>
</div> </div>
</nav>; </nav>
} }
} }
+16 -16
View File
@@ -1,13 +1,14 @@
import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent} from "react"; import React, {PropsWithChildren, SyntheticEvent} from 'react'
import classnames from "classnames"; import classnames from 'classnames'
import FieldDocLabel from "./FieldDocLabel"; import FieldDocLabel from './FieldDocLabel'
import Doc from "./Doc"; import Doc from './Doc'
export type BlockProps = PropsWithChildren & {
type BlockProps = PropsWithChildren & {
"data-wd-key"?: string "data-wd-key"?: string
label?: string label?: string
action?: React.ReactElement action?: React.ReactElement
style?: CSSProperties style?: object
onChange?(...args: unknown[]): unknown onChange?(...args: unknown[]): unknown
fieldSpec?: object fieldSpec?: object
wideMode?: boolean wideMode?: boolean
@@ -26,13 +27,13 @@ export default class Block extends React.Component<BlockProps, BlockState> {
super(props); super(props);
this.state = { this.state = {
showDoc: false, showDoc: false,
}; }
} }
onChange(e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) { onChange(e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) {
const value = e.target.value; const value = e.target.value
if (this.props.onChange) { if (this.props.onChange) {
return this.props.onChange(value === "" ? undefined : value); return this.props.onChange(value === "" ? undefined : value)
} }
} }
@@ -40,7 +41,7 @@ export default class Block extends React.Component<BlockProps, BlockState> {
this.setState({ this.setState({
showDoc: val showDoc: val
}); });
}; }
/** /**
* Some fields for example <InputColor/> bind click events inside the element * Some fields for example <InputColor/> bind click events inside the element
@@ -58,7 +59,7 @@ export default class Block extends React.Component<BlockProps, BlockState> {
if (event.nativeEvent.target.nodeName !== "A") { if (event.nativeEvent.target.nodeName !== "A") {
event.preventDefault(); event.preventDefault();
} }
}; }
render() { render() {
return <label style={this.props.style} return <label style={this.props.style}
@@ -66,8 +67,7 @@ export default class Block extends React.Component<BlockProps, BlockState> {
className={classnames({ className={classnames({
"maputnik-input-block": true, "maputnik-input-block": true,
"maputnik-input-block--wide": this.props.wideMode, "maputnik-input-block--wide": this.props.wideMode,
"maputnik-action-block": this.props.action, "maputnik-action-block": this.props.action
"maputnik-input-block--error": this.props.error
})} })}
onClick={this.onLabelClick} onClick={this.onLabelClick}
> >
@@ -88,17 +88,17 @@ export default class Block extends React.Component<BlockProps, BlockState> {
<div className="maputnik-input-block-action"> <div className="maputnik-input-block-action">
{this.props.action} {this.props.action}
</div> </div>
<div className="maputnik-input-block-content" ref={el => {this._blockEl = el;}}> <div className="maputnik-input-block-content" ref={el => this._blockEl = el}>
{this.props.children} {this.props.children}
</div> </div>
{this.props.fieldSpec && {this.props.fieldSpec &&
<div <div
className="maputnik-doc-inline" className="maputnik-doc-inline"
style={{display: this.state.showDoc ? "" : "none"}} style={{display: this.state.showDoc ? '' : 'none'}}
> >
<Doc fieldSpec={this.props.fieldSpec} /> <Doc fieldSpec={this.props.fieldSpec} />
</div> </div>
} }
</label>; </label>
} }
} }
-29
View File
@@ -1,29 +0,0 @@
import InputJson from "./InputJson";
import React from "react";
import { withTranslation, type WithTranslation } from "react-i18next";
import { type StyleSpecification } from "maplibre-gl";
import { type StyleSpecificationWithId } from "../libs/definitions";
export type CodeEditorProps = {
value: StyleSpecification;
onChange: (value: StyleSpecificationWithId) => void;
onClose: () => void;
} & WithTranslation;
const CodeEditorInternal: React.FC<CodeEditorProps> = (props) => {
return <>
<button className="maputnik-button" onClick={props.onClose} aria-label={props.t("Close")} style={{ position: "sticky", top: "0", zIndex: 1 }}>{props.t("Click to close the editor")}</button>
<InputJson
lintType="style"
value={props.value}
onChange={props.onChange}
className={"maputnik-code-editor"}
withScroll={true}
/>;
</>;
};
const CodeEditor = withTranslation()(CodeEditorInternal);
export default CodeEditor;
+6 -6
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from 'react'
import { Collapse as ReactCollapse } from "react-collapse"; import { Collapse as ReactCollapse } from 'react-collapse'
import {reducedMotionEnabled} from "../libs/accessibility"; import {reducedMotionEnabled} from '../libs/accessibility'
type CollapseProps = { type CollapseProps = {
@@ -12,7 +12,7 @@ type CollapseProps = {
export default class Collapse extends React.Component<CollapseProps> { export default class Collapse extends React.Component<CollapseProps> {
static defaultProps = { static defaultProps = {
isActive: true isActive: true
}; }
render() { render() {
if (reducedMotionEnabled()) { if (reducedMotionEnabled()) {
@@ -20,14 +20,14 @@ export default class Collapse extends React.Component<CollapseProps> {
<div style={{display: this.props.isActive ? "block" : "none"}}> <div style={{display: this.props.isActive ? "block" : "none"}}>
{this.props.children} {this.props.children}
</div> </div>
); )
} }
else { else {
return ( return (
<ReactCollapse isOpened={this.props.isActive}> <ReactCollapse isOpened={this.props.isActive}>
{this.props.children} {this.props.children}
</ReactCollapse> </ReactCollapse>
); )
} }
} }
} }
+4 -4
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from 'react'
import {MdArrowDropDown, MdArrowDropUp} from "react-icons/md"; import {MdArrowDropDown, MdArrowDropUp} from 'react-icons/md'
type CollapserProps = { type CollapserProps = {
isCollapsed: boolean isCollapsed: boolean
@@ -12,7 +12,7 @@ export default class Collapser extends React.Component<CollapserProps> {
width: 20, width: 20,
height: 20, height: 20,
...this.props.style, ...this.props.style,
}; }
return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />; return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />
} }
} }
+7 -19
View File
@@ -1,10 +1,10 @@
import React from "react"; import React from 'react'
import Markdown from "react-markdown";
const headers = { const headers = {
js: "JS", js: "JS",
android: "Android", android: "Android",
ios: "iOS" ios: "iOS",
macos: "macOS",
}; };
type DocProps = { type DocProps = {
@@ -15,7 +15,7 @@ type DocProps = {
doc?: string doc?: string
} }
} }
"sdk-support"?: { 'sdk-support'?: {
[key: string]: typeof headers [key: string]: typeof headers
} }
docUrl?: string, docUrl?: string,
@@ -28,7 +28,7 @@ export default class Doc extends React.Component<DocProps> {
const {fieldSpec} = this.props; const {fieldSpec} = this.props;
const {doc, values, docUrl, docUrlLinkText} = fieldSpec; const {doc, values, docUrl, docUrlLinkText} = fieldSpec;
const sdkSupport = fieldSpec["sdk-support"]; const sdkSupport = fieldSpec['sdk-support'];
const renderValues = ( const renderValues = (
!!values && !!values &&
@@ -37,23 +37,11 @@ export default class Doc extends React.Component<DocProps> {
!Array.isArray(values) !Array.isArray(values)
); );
const sdkSupportToJsx = (value: string) => {
const supportValue = value.toLowerCase();
if (supportValue.startsWith("https://")) {
return <a href={supportValue} target="_blank" rel="noreferrer">{"#" + supportValue.split("/").pop()}</a>;
}
return value;
};
return ( return (
<> <>
{doc && {doc &&
<div className="SpecDoc"> <div className="SpecDoc">
<div className="SpecDoc__doc" data-wd-key='spec-field-doc'> <div className="SpecDoc__doc" data-wd-key='spec-field-doc'>{doc}</div>
<Markdown components={{
a: ({node: _node, href, children, ...props}) => <a href={href} target="_blank" {...props}>{children}</a>,
}}>{doc}</Markdown>
</div>
{renderValues && {renderValues &&
<ul className="SpecDoc__values"> <ul className="SpecDoc__values">
{Object.entries(values).map(([key, value]) => { {Object.entries(values).map(([key, value]) => {
@@ -86,7 +74,7 @@ export default class Doc extends React.Component<DocProps> {
<td>{key}</td> <td>{key}</td>
{Object.keys(headers).map((k) => { {Object.keys(headers).map((k) => {
if (Object.prototype.hasOwnProperty.call(supportObj, k)) { if (Object.prototype.hasOwnProperty.call(supportObj, k)) {
return <td key={k}>{sdkSupportToJsx(supportObj[k as keyof typeof headers])}</td>; return <td key={k}>{supportObj[k as keyof typeof headers]}</td>;
} }
else { else {
return <td key={k}>no</td>; return <td key={k}>no</td>;
+9 -10
View File
@@ -1,5 +1,6 @@
import InputArray, { type InputArrayProps } from "./InputArray"; import React from 'react'
import Fieldset from "./Fieldset"; import InputArray, { FieldArrayProps as InputArrayProps } from './InputArray'
import Fieldset from './Fieldset'
type FieldArrayProps = InputArrayProps & { type FieldArrayProps = InputArrayProps & {
name?: string name?: string
@@ -8,12 +9,10 @@ type FieldArrayProps = InputArrayProps & {
} }
}; };
const FieldArray: React.FC<FieldArrayProps> = (props) => { export default class FieldArray extends React.Component<FieldArrayProps> {
return ( render() {
<Fieldset label={props.label} fieldSpec={props.fieldSpec}> return <Fieldset label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputArray {...props} /> <InputArray {...this.props} />
</Fieldset> </Fieldset>
); }
}; }
export default FieldArray;
+9 -10
View File
@@ -1,5 +1,6 @@
import Block from "./Block"; import React from 'react'
import InputAutocomplete, { type InputAutocompleteProps } from "./InputAutocomplete"; import Block from './Block'
import InputAutocomplete, { InputAutocompleteProps } from './InputAutocomplete'
type FieldAutocompleteProps = InputAutocompleteProps & { type FieldAutocompleteProps = InputAutocompleteProps & {
@@ -7,12 +8,10 @@ type FieldAutocompleteProps = InputAutocompleteProps & {
}; };
const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => { export default class FieldAutocomplete extends React.Component<FieldAutocompleteProps> {
return ( render() {
<Block label={props.label}> return <Block label={this.props.label}>
<InputAutocomplete {...props} /> <InputAutocomplete {...this.props} />
</Block> </Block>
); }
}; }
export default FieldAutocomplete;
+9 -10
View File
@@ -1,5 +1,6 @@
import Block from "./Block"; import React from 'react'
import InputCheckbox, {type InputCheckboxProps} from "./InputCheckbox"; import Block from './Block'
import InputCheckbox, {InputCheckboxProps} from './InputCheckbox'
type FieldCheckboxProps = InputCheckboxProps & { type FieldCheckboxProps = InputCheckboxProps & {
@@ -7,12 +8,10 @@ type FieldCheckboxProps = InputCheckboxProps & {
}; };
const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => { export default class FieldCheckbox extends React.Component<FieldCheckboxProps> {
return ( render() {
<Block label={props.label}> return <Block label={this.props.label}>
<InputCheckbox {...props} /> <InputCheckbox {...this.props} />
</Block> </Block>
); }
}; }
export default FieldCheckbox;
+9 -10
View File
@@ -1,5 +1,6 @@
import Block from "./Block"; import React from 'react'
import InputColor, {type InputColorProps} from "./InputColor"; import Block from './Block'
import InputColor, {InputColorProps} from './InputColor'
type FieldColorProps = InputColorProps & { type FieldColorProps = InputColorProps & {
@@ -10,12 +11,10 @@ type FieldColorProps = InputColorProps & {
}; };
const FieldColor: React.FC<FieldColorProps> = (props) => { export default class FieldColor extends React.Component<FieldColorProps> {
return ( render() {
<Block label={props.label} fieldSpec={props.fieldSpec}> return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputColor {...props} /> <InputColor {...this.props} />
</Block> </Block>
); }
}; }
export default FieldColor;
+16 -18
View File
@@ -1,8 +1,8 @@
import React from "react"; import React from 'react'
import Block from "./Block"; import Block from './Block'
import InputString from "./InputString"; import InputString from './InputString'
import { type WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from 'react-i18next';
type FieldCommentInternalProps = { type FieldCommentInternalProps = {
value?: string value?: string
@@ -10,31 +10,29 @@ type FieldCommentInternalProps = {
error: {message: string} error: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldCommentInternal: React.FC<FieldCommentInternalProps> = (props) => { class FieldCommentInternal extends React.Component<FieldCommentInternalProps> {
const t = props.t; render() {
const fieldSpec = { const t = this.props.t;
doc: t( const fieldSpec = {
"Comments for the current layer. This is non-standard and not in the spec." doc: t("Comments for the current layer. This is non-standard and not in the spec."),
), };
};
return ( return <Block
<Block
label={t("Comments")} label={t("Comments")}
fieldSpec={fieldSpec} fieldSpec={fieldSpec}
data-wd-key="layer-comment" data-wd-key="layer-comment"
error={props.error} error={this.props.error}
> >
<InputString <InputString
multi={true} multi={true}
value={props.value} value={this.props.value}
onChange={props.onChange} onChange={this.props.onChange}
default={t("Comment...")} default={t("Comment...")}
data-wd-key="layer-comment.input" data-wd-key="layer-comment.input"
/> />
</Block> </Block>
); }
}; }
const FieldComment = withTranslation()(FieldCommentInternal); const FieldComment = withTranslation()(FieldCommentInternal);
export default FieldComment; export default FieldComment;
+43 -31
View File
@@ -1,5 +1,5 @@
import React, { type JSX } from "react"; import React from 'react'
import {MdInfoOutline, MdHighlightOff} from "react-icons/md"; import {MdInfoOutline, MdHighlightOff} from 'react-icons/md'
type FieldDocLabelProps = { type FieldDocLabelProps = {
label: JSX.Element | string | undefined label: JSX.Element | string | undefined
@@ -9,45 +9,57 @@ type FieldDocLabelProps = {
onToggleDoc?(...args: unknown[]): unknown onToggleDoc?(...args: unknown[]): unknown
}; };
type FieldDocLabelState = {
open: boolean
};
const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => { export default class FieldDocLabel extends React.Component<FieldDocLabelProps, FieldDocLabelState> {
const [open, setOpen] = React.useState(false); constructor (props: FieldDocLabelProps) {
super(props);
const onToggleDoc = (state: boolean) => { this.state = {
setOpen(state); open: false,
if (props.onToggleDoc) {
props.onToggleDoc(state);
} }
}; }
const { label, fieldSpec } = props; onToggleDoc = (open: boolean) => {
const { doc } = fieldSpec || {}; this.setState({
open,
}, () => {
if (this.props.onToggleDoc) {
this.props.onToggleDoc(this.state.open);
}
});
}
if (doc) { render() {
return ( const {label, fieldSpec} = this.props;
<label className="maputnik-doc-wrapper"> const {doc} = fieldSpec || {};
if (doc) {
return <label className="maputnik-doc-wrapper">
<div className="maputnik-doc-target"> <div className="maputnik-doc-target">
{label} {label}
{"\xa0"} {'\xa0'}
<button <button
aria-label={open ? "close property documentation" : "open property documentation"} aria-label={this.state.open ? "close property documentation" : "open property documentation"}
className={`maputnik-doc-button maputnik-doc-button--${open ? "open" : "closed"}`} className={`maputnik-doc-button maputnik-doc-button--${this.state.open ? 'open' : 'closed'}`}
onClick={() => onToggleDoc(!open)} onClick={() => this.onToggleDoc(!this.state.open)}
data-wd-key={"field-doc-button-" + label} data-wd-key={'field-doc-button-'+label}
> >
{open ? <MdHighlightOff /> : <MdInfoOutline />} {this.state.open ? <MdHighlightOff /> : <MdInfoOutline />}
</button> </button>
</div> </div>
</label> </label>
); }
} else if (label) { else if (label) {
return ( return <label className="maputnik-doc-wrapper">
<label className="maputnik-doc-wrapper"> <div className="maputnik-doc-target">
<div className="maputnik-doc-target">{label}</div> {label}
</div>
</label> </label>
); }
else {
<div />
}
} }
return <div />; }
};
export default FieldDocLabel;
+9 -10
View File
@@ -1,16 +1,15 @@
import InputDynamicArray, {type InputDynamicArrayProps} from "./InputDynamicArray"; import React from 'react'
import Fieldset from "./Fieldset"; import InputDynamicArray, {FieldDynamicArrayProps as InputDynamicArrayProps} from './InputDynamicArray'
import Fieldset from './Fieldset'
type FieldDynamicArrayProps = InputDynamicArrayProps & { type FieldDynamicArrayProps = InputDynamicArrayProps & {
name?: string name?: string
}; };
const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => { export default class FieldDynamicArray extends React.Component<FieldDynamicArrayProps> {
return ( render() {
<Fieldset label={props.label}> return <Fieldset label={this.props.label}>
<InputDynamicArray {...props} /> <InputDynamicArray {...this.props} />
</Fieldset> </Fieldset>
); }
}; }
export default FieldDynamicArray;
+9 -10
View File
@@ -1,5 +1,6 @@
import InputEnum, {type InputEnumProps} from "./InputEnum"; import React from 'react'
import Fieldset from "./Fieldset"; import InputEnum, {InputEnumProps} from './InputEnum'
import Fieldset from './Fieldset';
type FieldEnumProps = InputEnumProps & { type FieldEnumProps = InputEnumProps & {
@@ -10,12 +11,10 @@ type FieldEnumProps = InputEnumProps & {
}; };
const FieldEnum: React.FC<FieldEnumProps> = (props) => { export default class FieldEnum extends React.Component<FieldEnumProps> {
return ( render() {
<Fieldset label={props.label} fieldSpec={props.fieldSpec}> return <Fieldset label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputEnum {...props} /> <InputEnum {...this.props} />
</Fieldset> </Fieldset>
); }
}; }
export default FieldEnum;
+231 -231
View File
@@ -1,12 +1,11 @@
import React from "react"; import React from 'react'
import SpecProperty from "./_SpecProperty"; import SpecProperty from './_SpecProperty'
import DataProperty, { type Stop } from "./_DataProperty"; import DataProperty, { Stop } from './_DataProperty'
import ZoomProperty from "./_ZoomProperty"; import ZoomProperty from './_ZoomProperty'
import ExpressionProperty from "./_ExpressionProperty"; import ExpressionProperty from './_ExpressionProperty'
import {function as styleFunction} from "@maplibre/maplibre-gl-style-spec"; import {function as styleFunction} from '@maplibre/maplibre-gl-style-spec';
import {findDefaultFromSpec} from "../libs/spec-helper"; import {findDefaultFromSpec} from '../libs/spec-helper';
import { type MappedLayerErrors } from "../libs/definitions";
function isLiteralExpression(value: any) { function isLiteralExpression(value: any) {
@@ -23,9 +22,9 @@ function isGetExpression(value: any) {
function isZoomField(value: any) { function isZoomField(value: any) {
return ( return (
typeof(value) === "object" && typeof(value) === 'object' &&
value.stops && value.stops &&
typeof(value.property) === "undefined" && typeof(value.property) === 'undefined' &&
Array.isArray(value.stops) && Array.isArray(value.stops) &&
value.stops.length > 1 && value.stops.length > 1 &&
value.stops.every((stop: Stop) => { value.stops.every((stop: Stop) => {
@@ -39,7 +38,7 @@ function isZoomField(value: any) {
function isIdentityProperty(value: any) { function isIdentityProperty(value: any) {
return ( return (
typeof(value) === "object" && typeof(value) === 'object' &&
value.type === "identity" && value.type === "identity" &&
Object.prototype.hasOwnProperty.call(value, "property") Object.prototype.hasOwnProperty.call(value, "property")
); );
@@ -47,16 +46,16 @@ function isIdentityProperty(value: any) {
function isDataStopProperty(value: any) { function isDataStopProperty(value: any) {
return ( return (
typeof(value) === "object" && typeof(value) === 'object' &&
value.stops && value.stops &&
typeof(value.property) !== "undefined" && typeof(value.property) !== 'undefined' &&
value.stops.length > 1 && value.stops.length > 1 &&
Array.isArray(value.stops) && Array.isArray(value.stops) &&
value.stops.every((stop: Stop) => { value.stops.every((stop: Stop) => {
return ( return (
Array.isArray(stop) && Array.isArray(stop) &&
stop.length === 2 && stop.length === 2 &&
typeof(stop[0]) === "object" typeof(stop[0]) === 'object'
); );
}) })
); );
@@ -91,18 +90,6 @@ function getDataType(value: any, fieldSpec={} as any) {
else if (fieldSpec.type === "array" && isArrayOfPrimatives(value)) { else if (fieldSpec.type === "array" && isArrayOfPrimatives(value)) {
return "value"; return "value";
} }
else if (fieldSpec.type === "numberArray" && isArrayOfPrimatives(value)) {
return "value";
}
else if (fieldSpec.type === "colorArray") {
return "value";
}
else if (fieldSpec.type === "padding") {
return "value";
}
else if (fieldSpec.type === "variableAnchorOffsetCollection") {
return "value";
}
else if (isZoomField(value)) { else if (isZoomField(value)) {
return "zoom_function"; return "zoom_function";
} }
@@ -120,287 +107,300 @@ type FieldFunctionProps = {
fieldName: string fieldName: string
fieldType: string fieldType: string
fieldSpec: any fieldSpec: any
errors?: MappedLayerErrors errors?: {[key: string]: {message: string}}
value?: any value?: any
}; };
type FieldFunctionState = {
dataType: string
isEditing: boolean
}
/** Supports displaying spec field for zoom function objects /** Supports displaying spec field for zoom function objects
* https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property * https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property
*/ */
const FieldFunction: React.FC<FieldFunctionProps> = (props) => { export default class FieldFunction extends React.Component<FieldFunctionProps, FieldFunctionState> {
const [dataType, setDataType] = React.useState( constructor (props: FieldFunctionProps) {
getDataType(props.value, props.fieldSpec) super(props);
); this.state = {
const [isEditing, setIsEditing] = React.useState(false); dataType: getDataType(props.value, props.fieldSpec),
isEditing: false,
React.useEffect(() => {
if (!isEditing) {
setDataType(getDataType(props.value, props.fieldSpec));
} }
}, [props.value, props.fieldSpec, isEditing]); }
const getFieldFunctionType = (fieldSpec: any) => { static getDerivedStateFromProps(props: Readonly<FieldFunctionProps>, state: FieldFunctionState) {
// Because otherwise when editing values we end up accidentally changing field type.
if (state.isEditing) {
return {};
}
else {
return {
isEditing: false,
dataType: getDataType(props.value, props.fieldSpec)
};
}
}
getFieldFunctionType(fieldSpec: any) {
if (fieldSpec.expression.interpolated) { if (fieldSpec.expression.interpolated) {
return "exponential"; return "exponential"
} }
if (fieldSpec.type === "number") { if (fieldSpec.type === "number") {
return "interval"; return "interval"
} }
return "categorical"; return "categorical"
}; }
const addStop = () => { addStop = () => {
const stops = props.value.stops.slice(0); const stops = this.props.value.stops.slice(0)
const lastStop = stops[stops.length - 1]; const lastStop = stops[stops.length - 1]
if (typeof lastStop[0] === "object") { if (typeof lastStop[0] === "object") {
stops.push([ stops.push([
{ zoom: lastStop[0].zoom + 1, value: lastStop[0].value }, {zoom: lastStop[0].zoom + 1, value: lastStop[0].value},
lastStop[1], lastStop[1]
]); ])
} else { }
stops.push([lastStop[0] + 1, lastStop[1]]); else {
stops.push([lastStop[0] + 1, lastStop[1]])
} }
const changedValue = { const changedValue = {
...props.value, ...this.props.value,
stops: stops, stops: stops,
};
props.onChange(props.fieldName, changedValue);
};
const deleteExpression = () => {
const { fieldSpec, fieldName } = props;
props.onChange(fieldName, fieldSpec.default);
setDataType("value");
};
const deleteStop = (stopIdx: number) => {
const stops = props.value.stops.slice(0);
stops.splice(stopIdx, 1);
let changedValue: any = {
...props.value,
stops: stops,
};
if (stops.length === 1) {
changedValue = stops[0][1];
} }
props.onChange(props.fieldName, changedValue); this.props.onChange(this.props.fieldName, changedValue)
}; }
const makeZoomFunction = () => { deleteExpression = () => {
const { value } = props; const {fieldSpec, fieldName} = this.props;
this.props.onChange(fieldName, fieldSpec.default);
this.setState({
dataType: "value",
});
}
let zoomFunc: any; deleteStop = (stopIdx: number) => {
if (typeof value === "object") { const stops = this.props.value.stops.slice(0)
stops.splice(stopIdx, 1)
let changedValue = {
...this.props.value,
stops: stops,
}
if(stops.length === 1) {
changedValue = stops[0][1]
}
this.props.onChange(this.props.fieldName, changedValue)
}
makeZoomFunction = () => {
const {value} = this.props;
let zoomFunc;
if (typeof(value) === "object") {
if (value.stops) { if (value.stops) {
zoomFunc = { zoomFunc = {
base: value.base, base: value.base,
stops: value.stops.map((stop: Stop) => { stops: value.stops.map((stop: Stop) => {
return [stop[0].zoom, stop[1] || findDefaultFromSpec(props.fieldSpec)]; return [stop[0].zoom, stop[1] || findDefaultFromSpec(this.props.fieldSpec)];
}), })
}; }
} else { }
else {
zoomFunc = { zoomFunc = {
base: value.base, base: value.base,
stops: [ stops: [
[6, findDefaultFromSpec(props.fieldSpec)], [6, findDefaultFromSpec(this.props.fieldSpec)],
[10, findDefaultFromSpec(props.fieldSpec)], [10, findDefaultFromSpec(this.props.fieldSpec)]
], ]
}; }
} }
} else { }
else {
zoomFunc = { zoomFunc = {
stops: [ stops: [
[6, value || findDefaultFromSpec(props.fieldSpec)], [6, value || findDefaultFromSpec(this.props.fieldSpec)],
[10, value || findDefaultFromSpec(props.fieldSpec)], [10, value || findDefaultFromSpec(this.props.fieldSpec)]
], ]
}; }
} }
props.onChange(props.fieldName, zoomFunc); this.props.onChange(this.props.fieldName, zoomFunc)
}; }
const undoExpression = () => { undoExpression = () => {
const { value, fieldName } = props; const {value, fieldName} = this.props;
if (isGetExpression(value)) { if (isGetExpression(value)) {
props.onChange(fieldName, { this.props.onChange(fieldName, {
type: "identity", "type": "identity",
property: value[1], "property": value[1]
});
this.setState({
dataType: "value",
}); });
setDataType("value");
} else if (isLiteralExpression(value)) {
props.onChange(fieldName, value[1]);
setDataType("value");
} }
}; else if (isLiteralExpression(value)) {
this.props.onChange(fieldName, value[1]);
this.setState({
dataType: "value",
});
}
}
const canUndo = () => { canUndo = () => {
const { value, fieldSpec } = props; const {value, fieldSpec} = this.props;
return ( return (
isGetExpression(value) || isGetExpression(value) ||
isLiteralExpression(value) || isLiteralExpression(value) ||
isPrimative(value) || isPrimative(value) ||
(Array.isArray(value) && fieldSpec.type === "array") (Array.isArray(value) && fieldSpec.type === "array")
); );
}; }
const makeExpression = () => { makeExpression = () => {
const { value, fieldSpec } = props; const {value, fieldSpec} = this.props;
let expression; let expression;
if (typeof value === "object" && "stops" in value) { if (typeof(value) === "object" && 'stops' in value) {
expression = styleFunction.convertFunction(value, fieldSpec); expression = styleFunction.convertFunction(value, fieldSpec);
} else if (isIdentityProperty(value)) {
expression = ["get", value.property];
} else {
expression = ["literal", value || props.fieldSpec.default];
} }
props.onChange(props.fieldName, expression); else if (isIdentityProperty(value)) {
}; expression = ["get", value.property];
}
else {
expression = ["literal", value || this.props.fieldSpec.default];
}
this.props.onChange(this.props.fieldName, expression);
}
const makeDataFunction = () => { makeDataFunction = () => {
const functionType = getFieldFunctionType(props.fieldSpec); const functionType = this.getFieldFunctionType(this.props.fieldSpec);
const stopValue = functionType === "categorical" ? "" : 0; const stopValue = functionType === 'categorical' ? '' : 0;
const { value } = props; const {value} = this.props;
let dataFunc; let dataFunc;
if (typeof value === "object") { if (typeof(value) === "object") {
if (value.stops) { if (value.stops) {
dataFunc = { dataFunc = {
property: "", property: "",
type: functionType, type: functionType,
base: value.base, base: value.base,
stops: value.stops.map((stop: Stop) => { stops: value.stops.map((stop: Stop) => {
return [{ zoom: stop[0], value: stopValue }, stop[1] || findDefaultFromSpec(props.fieldSpec)]; return [{zoom: stop[0], value: stopValue}, stop[1] || findDefaultFromSpec(this.props.fieldSpec)];
}), })
}; }
} else { }
else {
dataFunc = { dataFunc = {
property: "", property: "",
type: functionType, type: functionType,
base: value.base, base: value.base,
stops: [ stops: [
[{ zoom: 6, value: stopValue }, findDefaultFromSpec(props.fieldSpec)], [{zoom: 6, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec)],
[{ zoom: 10, value: stopValue }, findDefaultFromSpec(props.fieldSpec)], [{zoom: 10, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec)]
], ]
}; }
} }
} else { }
else {
dataFunc = { dataFunc = {
property: "", property: "",
type: functionType, type: functionType,
base: value.base, base: value.base,
stops: [ stops: [
[{ zoom: 6, value: stopValue }, props.value || findDefaultFromSpec(props.fieldSpec)], [{zoom: 6, value: stopValue}, this.props.value || findDefaultFromSpec(this.props.fieldSpec)],
[{ zoom: 10, value: stopValue }, props.value || findDefaultFromSpec(props.fieldSpec)], [{zoom: 10, value: stopValue}, this.props.value || findDefaultFromSpec(this.props.fieldSpec)]
], ]
}; }
} }
props.onChange(props.fieldName, dataFunc); this.props.onChange(this.props.fieldName, dataFunc)
};
const makeElevationFunction = () => {
const expression = [
"interpolate",
["linear"],
["elevation"],
0,
"black",
2000,
"white"
];
props.onChange(props.fieldName, expression);
};
const onMarkEditing = () => {
setIsEditing(true);
};
const onUnmarkEditing = () => {
setIsEditing(false);
};
const propClass =
props.fieldSpec.default === props.value ? "maputnik-default-property" : "maputnik-modified-property";
let specField;
if (dataType === "expression") {
specField = (
<ExpressionProperty
errors={props.errors}
onChange={props.onChange.bind(null, props.fieldName)}
canUndo={canUndo}
onUndo={undoExpression}
onDelete={deleteExpression}
fieldType={props.fieldType}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={props.value}
onFocus={onMarkEditing}
onBlur={onUnmarkEditing}
/>
);
} else if (dataType === "zoom_function") {
specField = (
<ZoomProperty
errors={props.errors}
onChange={props.onChange.bind(null)}
fieldType={props.fieldType}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={props.value}
onDeleteStop={deleteStop}
onAddStop={addStop}
onChangeToDataFunction={makeDataFunction}
onExpressionClick={makeExpression}
/>
);
} else if (dataType === "data_function") {
specField = (
<DataProperty
errors={props.errors}
onChange={props.onChange.bind(null)}
fieldType={props.fieldType}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={props.value}
onDeleteStop={deleteStop}
onAddStop={addStop}
onChangeToZoomFunction={makeZoomFunction}
onExpressionClick={makeExpression}
/>
);
} else {
specField = (
<SpecProperty
errors={props.errors}
onChange={props.onChange.bind(null)}
fieldType={props.fieldType}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={props.value}
onZoomClick={makeZoomFunction}
onDataClick={makeDataFunction}
onExpressionClick={makeExpression}
onElevationClick={makeElevationFunction}
/>
);
} }
return ( onMarkEditing = () => {
<div className={propClass} data-wd-key={"spec-field-container:" + props.fieldName}> this.setState({isEditing: true});
}
onUnmarkEditing = () => {
this.setState({isEditing: false});
}
render() {
const {dataType} = this.state;
const propClass = this.props.fieldSpec.default === this.props.value ? "maputnik-default-property" : "maputnik-modified-property"
let specField;
if (dataType === "expression") {
specField = (
<ExpressionProperty
errors={this.props.errors}
onChange={this.props.onChange.bind(this, this.props.fieldName)}
canUndo={this.canUndo}
onUndo={this.undoExpression}
onDelete={this.deleteExpression}
fieldType={this.props.fieldType}
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={this.props.value}
onFocus={this.onMarkEditing}
onBlur={this.onUnmarkEditing}
/>
);
}
else if (dataType === "zoom_function") {
specField = (
<ZoomProperty
errors={this.props.errors}
onChange={this.props.onChange.bind(this)}
fieldType={this.props.fieldType}
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={this.props.value}
onDeleteStop={this.deleteStop}
onAddStop={this.addStop}
onChangeToDataFunction={this.makeDataFunction}
onExpressionClick={this.makeExpression}
/>
)
}
else if (dataType === "data_function") {
// TODO: Rename to FieldFunction **this file** shouldn't be called that
specField = (
<DataProperty
errors={this.props.errors}
onChange={this.props.onChange.bind(this)}
fieldType={this.props.fieldType}
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={this.props.value}
onDeleteStop={this.deleteStop}
onAddStop={this.addStop}
onChangeToZoomFunction={this.makeZoomFunction}
onExpressionClick={this.makeExpression}
/>
)
}
else {
specField = (
<SpecProperty
errors={this.props.errors}
onChange={this.props.onChange.bind(this)}
fieldType={this.props.fieldType}
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={this.props.value}
onZoomClick={this.makeZoomFunction}
onDataClick={this.makeDataFunction}
onExpressionClick={this.makeExpression}
/>
)
}
return <div className={propClass} data-wd-key={"spec-field-container:"+this.props.fieldName}>
{specField} {specField}
</div> </div>
); }
}; }
export default FieldFunction;
+15 -15
View File
@@ -1,7 +1,8 @@
import React from 'react'
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import Block from "./Block"; import Block from './Block'
import InputString from "./InputString"; import InputString from './InputString'
type FieldIdProps = { type FieldIdProps = {
value: string value: string
@@ -10,19 +11,18 @@ type FieldIdProps = {
error?: {message: string} error?: {message: string}
}; };
const FieldId: React.FC<FieldIdProps> = (props) => { export default class FieldId extends React.Component<FieldIdProps> {
return ( render() {
<Block label="ID" fieldSpec={latest.layer.id} return <Block label="ID" fieldSpec={latest.layer.id}
data-wd-key={props.wdKey}
error={props.error} data-wd-key={this.props.wdKey}
error={this.props.error}
> >
<InputString <InputString
value={props.value} value={this.props.value}
onInput={props.onChange} onInput={this.props.onChange}
data-wd-key={props.wdKey + ".input"} data-wd-key={this.props.wdKey + ".input"}
/> />
</Block> </Block>
); }
}; }
export default FieldId;
+7 -6
View File
@@ -1,11 +1,12 @@
import InputJson, {type InputJsonProps} from "./InputJson"; import React from 'react'
import InputJson, {InputJsonProps} from './InputJson'
type FieldJsonProps = InputJsonProps & {}; type FieldJsonProps = InputJsonProps & {};
const FieldJson: React.FC<FieldJsonProps> = (props) => { export default class FieldJson extends React.Component<FieldJsonProps> {
return <InputJson {...props} />; render() {
}; return <InputJson {...this.props} />
}
export default FieldJson; }
+14 -14
View File
@@ -1,9 +1,9 @@
import React from "react"; import React from 'react'
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import Block from "./Block"; import Block from './Block'
import InputNumber from "./InputNumber"; import InputNumber from './InputNumber'
import { type WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from 'react-i18next';
type FieldMaxZoomInternalProps = { type FieldMaxZoomInternalProps = {
value?: number value?: number
@@ -11,25 +11,25 @@ type FieldMaxZoomInternalProps = {
error?: {message: string} error?: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldMaxZoomInternal: React.FC<FieldMaxZoomInternalProps> = (props) => { class FieldMaxZoomInternal extends React.Component<FieldMaxZoomInternalProps> {
const t = props.t; render() {
return ( const t = this.props.t;
<Block label={t("Max Zoom")} fieldSpec={latest.layer.maxzoom} return <Block label={t("Max Zoom")} fieldSpec={latest.layer.maxzoom}
error={props.error} error={this.props.error}
data-wd-key="max-zoom" data-wd-key="max-zoom"
> >
<InputNumber <InputNumber
allowRange={true} allowRange={true}
value={props.value} value={this.props.value}
onChange={props.onChange} onChange={this.props.onChange}
min={latest.layer.maxzoom.minimum} min={latest.layer.maxzoom.minimum}
max={latest.layer.maxzoom.maximum} max={latest.layer.maxzoom.maximum}
default={latest.layer.maxzoom.maximum} default={latest.layer.maxzoom.maximum}
data-wd-key="max-zoom.input" data-wd-key="max-zoom.input"
/> />
</Block> </Block>
); }
}; }
const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal); const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal);
export default FieldMaxZoom; export default FieldMaxZoom;
+14 -14
View File
@@ -1,9 +1,9 @@
import React from "react"; import React from 'react'
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import Block from "./Block"; import Block from './Block'
import InputNumber from "./InputNumber"; import InputNumber from './InputNumber'
import { type WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from 'react-i18next';
type FieldMinZoomInternalProps = { type FieldMinZoomInternalProps = {
value?: number value?: number
@@ -11,25 +11,25 @@ type FieldMinZoomInternalProps = {
error?: {message: string} error?: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldMinZoomInternal: React.FC<FieldMinZoomInternalProps> = (props) => { class FieldMinZoomInternal extends React.Component<FieldMinZoomInternalProps> {
const t = props.t; render() {
return ( const t = this.props.t;
<Block label={t("Min Zoom")} fieldSpec={latest.layer.minzoom} return <Block label={t("Min Zoom")} fieldSpec={latest.layer.minzoom}
error={props.error} error={this.props.error}
data-wd-key="min-zoom" data-wd-key="min-zoom"
> >
<InputNumber <InputNumber
allowRange={true} allowRange={true}
value={props.value} value={this.props.value}
onChange={props.onChange} onChange={this.props.onChange}
min={latest.layer.minzoom.minimum} min={latest.layer.minzoom.minimum}
max={latest.layer.minzoom.maximum} max={latest.layer.minzoom.maximum}
default={latest.layer.minzoom.minimum} default={latest.layer.minzoom.minimum}
data-wd-key='min-zoom.input' data-wd-key='min-zoom.input'
/> />
</Block> </Block>
); }
}; }
const FieldMinZoom = withTranslation()(FieldMinZoomInternal); const FieldMinZoom = withTranslation()(FieldMinZoomInternal);
export default FieldMinZoom; export default FieldMinZoom;
+9 -10
View File
@@ -1,5 +1,6 @@
import InputMultiInput, {type InputMultiInputProps} from "./InputMultiInput"; import React from 'react'
import Fieldset from "./Fieldset"; import InputMultiInput, {InputMultiInputProps} from './InputMultiInput'
import Fieldset from './Fieldset'
type FieldMultiInputProps = InputMultiInputProps & { type FieldMultiInputProps = InputMultiInputProps & {
@@ -7,12 +8,10 @@ type FieldMultiInputProps = InputMultiInputProps & {
}; };
const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => { export default class FieldMultiInput extends React.Component<FieldMultiInputProps> {
return ( render() {
<Fieldset label={props.label}> return <Fieldset label={this.props.label}>
<InputMultiInput {...props} /> <InputMultiInput {...this.props} />
</Fieldset> </Fieldset>
); }
}; }
export default FieldMultiInput;
+9 -10
View File
@@ -1,5 +1,6 @@
import InputNumber, {type InputNumberProps} from "./InputNumber"; import React from 'react'
import Block from "./Block"; import InputNumber, {InputNumberProps} from './InputNumber'
import Block from './Block'
type FieldNumberProps = InputNumberProps & { type FieldNumberProps = InputNumberProps & {
@@ -10,12 +11,10 @@ type FieldNumberProps = InputNumberProps & {
}; };
const FieldNumber: React.FC<FieldNumberProps> = (props) => { export default class FieldNumber extends React.Component<FieldNumberProps> {
return ( render() {
<Block label={props.label} fieldSpec={props.fieldSpec}> return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputNumber {...props} /> <InputNumber {...this.props} />
</Block> </Block>
); }
}; }
export default FieldNumber;
+9 -10
View File
@@ -1,5 +1,6 @@
import Block from "./Block"; import React from 'react'
import InputSelect, {type InputSelectProps} from "./InputSelect"; import Block from './Block'
import InputSelect, {InputSelectProps} from './InputSelect'
type FieldSelectProps = InputSelectProps & { type FieldSelectProps = InputSelectProps & {
@@ -10,12 +11,10 @@ type FieldSelectProps = InputSelectProps & {
}; };
const FieldSelect: React.FC<FieldSelectProps> = (props) => { export default class FieldSelect extends React.Component<FieldSelectProps> {
return ( render() {
<Block label={props.label} fieldSpec={props.fieldSpec}> return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputSelect {...props} /> <InputSelect {...this.props}/>
</Block> </Block>
); }
}; }
export default FieldSelect;
+21 -23
View File
@@ -1,9 +1,9 @@
import React from "react"; import React from 'react'
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import Block from "./Block"; import Block from './Block'
import InputAutocomplete from "./InputAutocomplete"; import InputAutocomplete from './InputAutocomplete'
import { type WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from 'react-i18next';
type FieldSourceInternalProps = { type FieldSourceInternalProps = {
value?: string value?: string
@@ -13,30 +13,28 @@ type FieldSourceInternalProps = {
error?: {message: string} error?: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldSourceInternal: React.FC<FieldSourceInternalProps> = ({ class FieldSourceInternal extends React.Component<FieldSourceInternalProps> {
onChange = () => {}, static defaultProps = {
sourceIds = [], onChange: () => {},
wdKey, sourceIds: [],
value, }
error,
t render() {
}) => { const t = this.props.t;
return ( return <Block
<Block
label={t("Source")} label={t("Source")}
fieldSpec={latest.layer.source} fieldSpec={latest.layer.source}
error={error} error={this.props.error}
data-wd-key={wdKey} data-wd-key={this.props.wdKey}
> >
<InputAutocomplete <InputAutocomplete
value={value} value={this.props.value}
onChange={onChange} onChange={this.props.onChange}
options={sourceIds?.map((src) => [src, src])} options={this.props.sourceIds?.map(src => [src, src])}
/> />
</Block> </Block>
); }
}; }
const FieldSource = withTranslation()(FieldSourceInternal); const FieldSource = withTranslation()(FieldSourceInternal);
export default FieldSource; export default FieldSource;
+24 -21
View File
@@ -1,39 +1,42 @@
import React from "react"; import React from 'react'
import {latest} from "@maplibre/maplibre-gl-style-spec"; import {latest} from '@maplibre/maplibre-gl-style-spec'
import Block from "./Block"; import Block from './Block'
import InputAutocomplete from "./InputAutocomplete"; import InputAutocomplete from './InputAutocomplete'
import { type WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from 'react-i18next';
type FieldSourceLayerInternalProps = { type FieldSourceLayerInternalProps = {
value?: string value?: string
onChange?(...args: unknown[]): unknown onChange?(...args: unknown[]): unknown
sourceLayerIds?: unknown[] sourceLayerIds?: unknown[]
isFixed?: boolean
error?: {message: string} error?: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = ({ class FieldSourceLayerInternal extends React.Component<FieldSourceLayerInternalProps> {
onChange = () => {}, static defaultProps = {
sourceLayerIds = [], onChange: () => {},
value, sourceLayerIds: [],
error, isFixed: false
t }
}) => {
return ( render() {
<Block const t = this.props.t;
return <Block
label={t("Source Layer")} label={t("Source Layer")}
fieldSpec={latest.layer["source-layer"]} fieldSpec={latest.layer['source-layer']}
data-wd-key="layer-source-layer" data-wd-key="layer-source-layer"
error={error} error={this.props.error}
> >
<InputAutocomplete <InputAutocomplete
value={value} keepMenuWithinWindowBounds={!!this.props.isFixed}
onChange={onChange} value={this.props.value}
options={sourceLayerIds?.map((l) => [l, l])} onChange={this.props.onChange}
options={this.props.sourceLayerIds?.map(l => [l, l])}
/> />
</Block> </Block>
); }
}; }
const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal); const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
export default FieldSourceLayer; export default FieldSourceLayer;
-49
View File
@@ -1,49 +0,0 @@
import Block, { type BlockProps } from "./Block";
import InputSpec, { type FieldSpecType, type InputSpecProps } from "./InputSpec";
import Fieldset, { type FieldsetProps } from "./Fieldset";
function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[] }): typeof Fieldset | typeof Block {
switch(fieldSpec.type) {
case "color":
return Block;
case "enum":
return (Object.keys(fieldSpec.values!).length <= 3 ? Fieldset : Block);
case "boolean":
return Block;
case "array":
return Fieldset;
case "resolvedImage":
return Block;
case "number":
return Block;
case "string":
return Block;
case "formatted":
return Block;
case "padding":
return Block;
case "numberArray":
return Fieldset;
case "colorArray":
return Fieldset;
case "variableAnchorOffsetCollection":
return Fieldset;
default:
console.warn("No such type for: " + fieldSpec.type);
return Block;
}
}
export type FieldSpecProps = InputSpecProps & BlockProps & FieldsetProps;
const FieldSpec: React.FC<FieldSpecProps> = (props) => {
const TypeBlock = getElementFromType(props.fieldSpec!);
return (
<TypeBlock label={props.label} action={props.action} fieldSpec={props.fieldSpec} error={props.error}>
<InputSpec {...props} />
</TypeBlock>
);
};
export default FieldSpec;
+9 -10
View File
@@ -1,5 +1,6 @@
import Block from "./Block"; import React from 'react'
import InputString, {type InputStringProps} from "./InputString"; import Block from './Block'
import InputString, {InputStringProps} from './InputString'
type FieldStringProps = InputStringProps & { type FieldStringProps = InputStringProps & {
name?: string name?: string
@@ -9,12 +10,10 @@ type FieldStringProps = InputStringProps & {
} }
}; };
const FieldString: React.FC<FieldStringProps> = (props) => { export default class FieldString extends React.Component<FieldStringProps> {
return ( render() {
<Block label={props.label} fieldSpec={props.fieldSpec}> return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputString {...props} /> <InputString {...this.props} />
</Block> </Block>
); }
}; }
export default FieldString;
+42 -32
View File
@@ -1,10 +1,10 @@
import React from "react"; import React from 'react'
import {v8} from "@maplibre/maplibre-gl-style-spec";
import Block from "./Block"; import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import InputSelect from "./InputSelect"; import Block from './Block'
import InputString from "./InputString"; import InputSelect from './InputSelect'
import { type WithTranslation, withTranslation } from "react-i18next"; import InputString from './InputString'
import { startCase } from "lodash"; import { WithTranslation, withTranslation } from 'react-i18next';
type FieldTypeInternalProps = { type FieldTypeInternalProps = {
value: string value: string
@@ -14,34 +14,44 @@ type FieldTypeInternalProps = {
disabled?: boolean disabled?: boolean
} & WithTranslation; } & WithTranslation;
const FieldTypeInternal: React.FC<FieldTypeInternalProps> = ({ class FieldTypeInternal extends React.Component<FieldTypeInternalProps> {
t, static defaultProps = {
value, disabled: false,
wdKey, }
onChange,
error, render() {
disabled = false const t = this.props.t;
}) => { return <Block label={t("Type")} fieldSpec={latest.layer.type}
const layerstypes: [string, string][] = Object.keys(v8.layer.type.values || {}).map(v => [v, startCase(v.replace(/-/g, " "))]); data-wd-key={this.props.wdKey}
return ( error={this.props.error}
<Block label={t("Type")} fieldSpec={v8.layer.type}
data-wd-key={wdKey}
error={error}
> >
{disabled && ( {this.props.disabled &&
<InputString value={value} disabled={true} /> <InputString
)} value={this.props.value}
{!disabled && ( disabled={true}
<InputSelect
options={layerstypes}
onChange={onChange}
value={value}
data-wd-key={wdKey + ".select"}
/> />
)} }
{!this.props.disabled &&
<InputSelect
options={[
['background', 'Background'],
['fill', 'Fill'],
['line', 'Line'],
['symbol', 'Symbol'],
['raster', 'Raster'],
['circle', 'Circle'],
['fill-extrusion', 'Fill Extrusion'],
['hillshade', 'Hillshade'],
['heatmap', 'Heatmap'],
]}
onChange={this.props.onChange}
value={this.props.value}
data-wd-key={this.props.wdKey + ".select"}
/>
}
</Block> </Block>
); }
}; }
const FieldType = withTranslation()(FieldTypeInternal); const FieldType = withTranslation()(FieldTypeInternal);
export default FieldType; export default FieldType;
+12 -11
View File
@@ -1,5 +1,6 @@
import InputUrl, {type FieldUrlProps as InputUrlProps} from "./InputUrl"; import React from 'react'
import Block from "./Block"; import InputUrl, {FieldUrlProps as InputUrlProps} from './InputUrl'
import Block from './Block'
type FieldUrlProps = InputUrlProps & { type FieldUrlProps = InputUrlProps & {
@@ -10,12 +11,12 @@ type FieldUrlProps = InputUrlProps & {
}; };
const FieldUrl: React.FC<FieldUrlProps> = (props) => { export default class FieldUrl extends React.Component<FieldUrlProps> {
return ( render () {
<Block label={props.label} fieldSpec={props.fieldSpec}> return (
<InputUrl {...props} /> <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
</Block> <InputUrl {...this.props} />
); </Block>
}; );
}
export default FieldUrl; }
+49 -37
View File
@@ -1,53 +1,65 @@
import React, { type PropsWithChildren, type ReactElement } from "react"; import React, { PropsWithChildren, ReactElement } from 'react'
import classnames from "classnames"; import FieldDocLabel from './FieldDocLabel'
import FieldDocLabel from "./FieldDocLabel"; import Doc from './Doc'
import Doc from "./Doc"; import generateUniqueId from '../libs/document-uid';
import generateUniqueId from "../libs/document-uid";
export type FieldsetProps = PropsWithChildren & { type FieldsetProps = PropsWithChildren & {
label?: string, label?: string,
fieldSpec?: { doc?: string }, fieldSpec?: { doc?: string },
action?: ReactElement, action?: ReactElement,
error?: {message: string}
}; };
type FieldsetState = {
showDoc: boolean
};
const Fieldset: React.FC<FieldsetProps> = (props) => { export default class Fieldset extends React.Component<FieldsetProps, FieldsetState> {
const [showDoc, setShowDoc] = React.useState(false); _labelId: string;
const labelId = React.useRef(generateUniqueId("fieldset_label_"));
const onToggleDoc = (val: boolean) => { constructor (props: FieldsetProps) {
setShowDoc(val); super(props);
}; this._labelId = generateUniqueId(`fieldset_label_`);
this.state = {
showDoc: false,
}
}
return ( onToggleDoc = (val: boolean) => {
<div className="maputnik-input-block" role="group" aria-labelledby={labelId.current}> this.setState({
{props.fieldSpec && ( showDoc: val
});
}
render () {
return <div className="maputnik-input-block" role="group" aria-labelledby={this._labelId}>
{this.props.fieldSpec &&
<div className="maputnik-input-block-label"> <div className="maputnik-input-block-label">
<FieldDocLabel <FieldDocLabel
label={props.label} label={this.props.label}
onToggleDoc={onToggleDoc} onToggleDoc={this.onToggleDoc}
fieldSpec={props.fieldSpec} fieldSpec={this.props.fieldSpec}
/> />
</div> </div>
)} }
{!props.fieldSpec && ( {!this.props.fieldSpec &&
<div className={classnames({ <div className="maputnik-input-block-label">
"maputnik-input-block-label": true, {this.props.label}
"maputnik-input-block--error": props.error
})}>
{props.label}
</div> </div>
)} }
<div className="maputnik-input-block-action">{props.action}</div> <div className="maputnik-input-block-action">
<div className="maputnik-input-block-content">{props.children}</div> {this.props.action}
{props.fieldSpec && ( </div>
<div className="maputnik-doc-inline" style={{ display: showDoc ? "" : "none" }}> <div className="maputnik-input-block-content">
<Doc fieldSpec={props.fieldSpec} /> {this.props.children}
</div>
{this.props.fieldSpec &&
<div
className="maputnik-doc-inline"
style={{display: this.state.showDoc ? '' : 'none'}}
>
<Doc fieldSpec={this.props.fieldSpec} />
</div> </div>
)} }
</div> </div>
); }
}; }
export default Fieldset;
+55 -51
View File
@@ -1,25 +1,23 @@
import React from "react"; import React from 'react'
import { TbMathFunction } from "react-icons/tb"; import {mdiTableRowPlusAfter} from '@mdi/js';
import { PiListPlusBold } from "react-icons/pi"; import {isEqual} from 'lodash';
import {isEqual} from "lodash"; import {ExpressionSpecification, LegacyFilterSpecification, StyleSpecification} from 'maplibre-gl'
import {type ExpressionSpecification, type LegacyFilterSpecification} from "maplibre-gl"; import {latest, migrate, convertFilter} from '@maplibre/maplibre-gl-style-spec'
import {migrate, convertFilter} from "@maplibre/maplibre-gl-style-spec"; import {mdiFunctionVariant} from '@mdi/js';
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import {combiningFilterOps} from "../libs/filterops"; import {combiningFilterOps} from '../libs/filterops'
import InputSelect from "./InputSelect"; import InputSelect from './InputSelect'
import Block from "./Block"; import Block from './Block'
import SingleFilterEditor from "./SingleFilterEditor"; import SingleFilterEditor from './SingleFilterEditor'
import FilterEditorBlock from "./FilterEditorBlock"; import FilterEditorBlock from './FilterEditorBlock'
import InputButton from "./InputButton"; import InputButton from './InputButton'
import Doc from "./Doc"; import Doc from './Doc'
import ExpressionProperty from "./_ExpressionProperty"; import ExpressionProperty from './_ExpressionProperty';
import { type WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from 'react-i18next';
import type { MappedLayerErrors, StyleSpecificationWithId } from "../libs/definitions";
function combiningFilter(props: FilterEditorInternalProps): LegacyFilterSpecification | ExpressionSpecification { function combiningFilter(props: FilterEditorInternalProps): LegacyFilterSpecification | ExpressionSpecification {
const filter = props.filter || ["all"]; const filter = props.filter || ['all'];
if (!Array.isArray(filter)) { if (!Array.isArray(filter)) {
return filter; return filter;
@@ -29,7 +27,7 @@ function combiningFilter(props: FilterEditorInternalProps): LegacyFilterSpecific
let filters = filter.slice(1); let filters = filter.slice(1);
if(combiningFilterOps.indexOf(combiningOp) < 0) { if(combiningFilterOps.indexOf(combiningOp) < 0) {
combiningOp = "all"; combiningOp = 'all';
filters = [filter.slice(0)]; filters = [filter.slice(0)];
} }
@@ -41,7 +39,7 @@ function migrateFilter(filter: LegacyFilterSpecification | ExpressionSpecificati
return (migrate(createStyleFromFilter(filter) as any).layers[0] as any).filter; return (migrate(createStyleFromFilter(filter) as any).layers[0] as any).filter;
} }
function createStyleFromFilter(filter: LegacyFilterSpecification | ExpressionSpecification): StyleSpecificationWithId { function createStyleFromFilter(filter: LegacyFilterSpecification | ExpressionSpecification): StyleSpecification & {id: string} {
return { return {
"id": "tmp", "id": "tmp",
"version": 8, "version": 8,
@@ -50,7 +48,7 @@ function createStyleFromFilter(filter: LegacyFilterSpecification | ExpressionSpe
"sources": { "sources": {
"tmp": { "tmp": {
"type": "geojson", "type": "geojson",
"data": "" "data": ''
} }
}, },
"sprite": "", "sprite": "",
@@ -82,22 +80,22 @@ function checkIfSimpleFilter (filter: LegacyFilterSpecification | ExpressionSpec
} }
function hasCombiningFilter(filter: LegacyFilterSpecification | ExpressionSpecification) { function hasCombiningFilter(filter: LegacyFilterSpecification | ExpressionSpecification) {
return combiningFilterOps.indexOf(filter[0]) >= 0; return combiningFilterOps.indexOf(filter[0]) >= 0
} }
function hasNestedCombiningFilter(filter: LegacyFilterSpecification | ExpressionSpecification) { function hasNestedCombiningFilter(filter: LegacyFilterSpecification | ExpressionSpecification) {
if(hasCombiningFilter(filter)) { if(hasCombiningFilter(filter)) {
return filter.slice(1).map(f => hasCombiningFilter(f as any)).filter(f => f == true).length > 0; return filter.slice(1).map(f => hasCombiningFilter(f as any)).filter(f => f == true).length > 0
} }
return false; return false
} }
type FilterEditorInternalProps = { type FilterEditorInternalProps = {
/** Properties of the vector layer and the available fields */ /** Properties of the vector layer and the available fields */
properties?: {[key:string]: any} properties?: {[key:string]: any}
filter?: any[] filter?: any[]
errors?: MappedLayerErrors errors?: {[key:string]: any}
onChange(value: LegacyFilterSpecification | ExpressionSpecification): void onChange(value: LegacyFilterSpecification | ExpressionSpecification): unknown
} & WithTranslation; } & WithTranslation;
type FilterEditorState = { type FilterEditorState = {
@@ -109,7 +107,7 @@ type FilterEditorState = {
class FilterEditorInternal extends React.Component<FilterEditorInternalProps, FilterEditorState> { class FilterEditorInternal extends React.Component<FilterEditorInternalProps, FilterEditorState> {
static defaultProps = { static defaultProps = {
filter: ["all"], filter: ["all"],
}; }
constructor (props: FilterEditorInternalProps) { constructor (props: FilterEditorInternalProps) {
super(props); super(props);
@@ -121,42 +119,42 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
// Convert filter to combining filter // Convert filter to combining filter
onFilterPartChanged(filterIdx: number, newPart: any[]) { onFilterPartChanged(filterIdx: number, newPart: any[]) {
const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification
newFilter[filterIdx] = newPart; newFilter[filterIdx] = newPart
this.props.onChange(newFilter); this.props.onChange(newFilter)
} }
deleteFilterItem(filterIdx: number) { deleteFilterItem(filterIdx: number) {
const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification
newFilter.splice(filterIdx + 1, 1); newFilter.splice(filterIdx + 1, 1)
this.props.onChange(newFilter); this.props.onChange(newFilter)
} }
addFilterItem = () => { addFilterItem = () => {
const newFilterItem = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilterItem = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification
(newFilterItem as any[]).push(["==", "name", ""]); (newFilterItem as any[]).push(['==', 'name', ''])
this.props.onChange(newFilterItem); this.props.onChange(newFilterItem)
}; }
onToggleDoc = (val: boolean) => { onToggleDoc = (val: boolean) => {
this.setState({ this.setState({
showDoc: val showDoc: val
}); });
}; }
makeFilter = () => { makeFilter = () => {
this.setState({ this.setState({
displaySimpleFilter: true, displaySimpleFilter: true,
}); })
}; }
makeExpression = () => { makeExpression = () => {
const filter = combiningFilter(this.props); const filter = combiningFilter(this.props);
this.props.onChange(migrateFilter(filter)); this.props.onChange(migrateFilter(filter));
this.setState({ this.setState({
displaySimpleFilter: false, displaySimpleFilter: false,
}); })
}; }
static getDerivedStateFromProps(props: Readonly<FilterEditorInternalProps>, state: FilterEditorState) { static getDerivedStateFromProps(props: Readonly<FilterEditorInternalProps>, state: FilterEditorState) {
const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props)); const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props));
@@ -171,7 +169,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
else if (displaySimpleFilter && state.displaySimpleFilter === false) { else if (displaySimpleFilter && state.displaySimpleFilter === false) {
return { return {
valueIsSimpleFilter: true, valueIsSimpleFilter: true,
}; }
} }
else { else {
return { return {
@@ -199,10 +197,12 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
onClick={this.makeExpression} onClick={this.makeExpression}
title={t("Convert to expression")} title={t("Convert to expression")}
> >
<TbMathFunction /> <svg style={{marginRight: "0.2em", width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
<path fill="currentColor" d={mdiFunctionVariant} />
</svg>
{t("Upgrade to expression")} {t("Upgrade to expression")}
</InputButton> </InputButton>
</div>; </div>
} }
else if (displaySimpleFilter) { else if (displaySimpleFilter) {
const filter = combiningFilter(this.props); const filter = combiningFilter(this.props);
@@ -216,7 +216,9 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
title={t("Convert to expression")} title={t("Convert to expression")}
className="maputnik-make-zoom-function" className="maputnik-make-zoom-function"
> >
<TbMathFunction /> <svg style={{width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
<path fill="currentColor" d={mdiFunctionVariant} />
</svg>
</InputButton> </InputButton>
</div> </div>
); );
@@ -238,7 +240,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
} }
</div> </div>
); );
}); })
return ( return (
@@ -269,14 +271,15 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
className="maputnik-add-filter" className="maputnik-add-filter"
onClick={this.addFilterItem} onClick={this.addFilterItem}
> >
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} /> <svg style={{width:"14px", height:"14px", verticalAlign: "text-bottom"}} viewBox="0 0 24 24">
{t("Add filter")} <path fill="currentColor" d={mdiTableRowPlusAfter} />
</svg> {t("Add filter")}
</InputButton> </InputButton>
</div> </div>
<div <div
key="doc" key="doc"
className="maputnik-doc-inline" className="maputnik-doc-inline"
style={{display: this.state.showDoc ? "" : "none"}} style={{display: this.state.showDoc ? '' : 'none'}}
> >
<Doc fieldSpec={fieldSpec} /> <Doc fieldSpec={fieldSpec} />
</div> </div>
@@ -294,6 +297,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
this.props.onChange(defaultFilter); this.props.onChange(defaultFilter);
}} }}
fieldName="filter" fieldName="filter"
fieldSpec={fieldSpec}
value={filter} value={filter}
errors={errors} errors={errors}
onChange={this.props.onChange} onChange={this.props.onChange}
@@ -301,7 +305,7 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
{this.state.valueIsSimpleFilter && {this.state.valueIsSimpleFilter &&
<div className="maputnik-expr-infobox"> <div className="maputnik-expr-infobox">
{t("You've entered an old style filter.")} {t("You've entered an old style filter.")}
{" "} {' '}
<button <button
onClick={this.makeFilter} onClick={this.makeFilter}
className="maputnik-expr-infobox__button" className="maputnik-expr-infobox__button"
+9 -9
View File
@@ -1,7 +1,7 @@
import React, { type PropsWithChildren } from "react"; import React, { PropsWithChildren } from 'react'
import InputButton from "./InputButton"; import InputButton from './InputButton'
import {MdDelete} from "react-icons/md"; import {MdDelete} from 'react-icons/md'
import { type WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from 'react-i18next';
type FilterEditorBlockInternalProps = PropsWithChildren & { type FilterEditorBlockInternalProps = PropsWithChildren & {
onDelete(...args: unknown[]): unknown onDelete(...args: unknown[]): unknown
@@ -11,19 +11,19 @@ class FilterEditorBlockInternal extends React.Component<FilterEditorBlockInterna
render() { render() {
const t = this.props.t; const t = this.props.t;
return <div className="maputnik-filter-editor-block"> return <div className="maputnik-filter-editor-block">
<div className="maputnik-filter-editor-block-content">
{this.props.children}
</div>
<div className="maputnik-filter-editor-block-action"> <div className="maputnik-filter-editor-block-action">
<InputButton <InputButton
className="maputnik-icon-button" className="maputnik-delete-filter"
onClick={this.props.onDelete} onClick={this.props.onDelete}
title={t("Delete filter block")} title={t("Delete filter block")}
> >
<MdDelete /> <MdDelete />
</InputButton> </InputButton>
</div> </div>
</div>; <div className="maputnik-filter-editor-block-content">
{this.props.children}
</div>
</div>
} }
} }
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import IconBase from 'react-icon-base'
export default class IconBackground extends React.Component {
render() {
return (
<IconBase viewBox="0 0 20 20" {...this.props}>
<path d="m 1.821019,10.255581 7.414535,5.020197 c 0.372277,0.25206 0.958697,0.239771 1.30985,-0.02745 L 17.539255,9.926162 C 17.89041,9.658941 17.873288,9.238006 17.501015,8.985946 L 10.08648,3.9657402 C 9.714204,3.7136802 9.127782,3.7259703 8.776627,3.9931918 L 1.782775,9.315365 c -0.3511551,0.267221 -0.3340331,0.688156 0.03824,0.940216 z" />
</IconBase>
)
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import IconBase from 'react-icon-base'
export default class IconCircle extends React.Component {
render() {
return (
<IconBase viewBox="0 0 20 20" {...this.props}>
<path transform="translate(2 2)" d="M7.5,0C11.6422,0,15,3.3578,15,7.5S11.6422,15,7.5,15 S0,11.6422,0,7.5S3.3578,0,7.5,0z M7.5,1.6666c-3.2217,0-5.8333,2.6117-5.8333,5.8334S4.2783,13.3334,7.5,13.3334 s5.8333-2.6117,5.8333-5.8334S10.7217,1.6666,7.5,1.6666z"></path>
</IconBase>
)
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import IconBase from 'react-icon-base'
export default class IconFill extends React.Component {
render() {
return (
<IconBase viewBox="0 0 20 20" {...this.props}>
<path d="M 2.84978,9.763512 9.462149,4.7316391 16.47225,9.478015 9.859886,14.509879 2.84978,9.763512 m -1.028761,0.492069 7.414535,5.020197 c 0.372277,0.25206 0.958697,0.239771 1.30985,-0.02745 L 17.539255,9.926162 C 17.89041,9.658941 17.873288,9.238006 17.501015,8.985946 L 10.08648,3.9657402 C 9.714204,3.7136802 9.127782,3.7259703 8.776627,3.9931918 L 1.782775,9.315365 c -0.3511551,0.267221 -0.3340331,0.688156 0.03824,0.940216 l 0,0 z" />
</IconBase>
)
}
}
+24 -25
View File
@@ -1,33 +1,32 @@
import React from "react"; import React from 'react'
import type {CSSProperties} from "react"; import IconLine from './IconLine'
import { BsBack, BsDiamondFill, BsSunFill } from "react-icons/bs"; import IconFill from './IconFill'
import { MdBubbleChart, MdCircle, MdLocationPin, MdPhoto, MdPriorityHigh } from "react-icons/md"; import IconSymbol from './IconSymbol'
import { IoAnalyticsOutline } from "react-icons/io5"; import IconBackground from './IconBackground'
import { IoMdCube } from "react-icons/io"; import IconCircle from './IconCircle'
import { FaMountain } from "react-icons/fa"; import IconMissing from './IconMissing'
type IconLayerProps = { type IconLayerProps = {
type: string type: string
style?: CSSProperties style?: object
className?: string className?: string
}; };
const IconLayer: React.FC<IconLayerProps> = (props) => { export default class IconLayer extends React.Component<IconLayerProps> {
const iconProps = { style: props.style }; render() {
switch(props.type) { const iconProps = { style: this.props.style }
case "fill-extrusion": return <IoMdCube {...iconProps} />; switch(this.props.type) {
case "raster": return <MdPhoto {...iconProps} />; case 'fill-extrusion': return <IconBackground {...iconProps} />
case "hillshade": return <BsSunFill {...iconProps} />; case 'raster': return <IconFill {...iconProps} />
case "color-relief": return <FaMountain {...iconProps} />; case 'hillshade': return <IconFill {...iconProps} />
case "heatmap": return <MdBubbleChart {...iconProps} />; case 'heatmap': return <IconFill {...iconProps} />
case "fill": return <BsDiamondFill {...iconProps} />; case 'fill': return <IconFill {...iconProps} />
case "background": return <BsBack {...iconProps} />; case 'background': return <IconBackground {...iconProps} />
case "line": return <IoAnalyticsOutline {...iconProps} />; case 'line': return <IconLine {...iconProps} />
case "symbol": return <MdLocationPin {...iconProps} />; case 'symbol': return <IconSymbol {...iconProps} />
case "circle": return <MdCircle {...iconProps} />; case 'circle': return <IconCircle {...iconProps} />
default: return <MdPriorityHigh {...iconProps} />; default: return <IconMissing {...iconProps} />
}
} }
}; }
export default IconLayer;
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import IconBase from 'react-icon-base'
export default class IconLine extends React.Component {
render() {
return (
<IconBase viewBox="0 0 20 20" {...this.props}>
<path d="M 12.34,1.29 C 12.5114,1.1076 12.7497,1.0029 13,1 13.5523,1 14,1.4477 14,2 14.0047,2.2478 13.907,2.4866 13.73,2.66 9.785626,6.5516986 6.6148407,9.7551593 2.65,13.72 2.4793,13.8963 2.2453,13.9971 2,14 1.4477,14 1,13.5523 1,13 0.9953,12.7522 1.093,12.5134 1.27,12.34 4.9761967,8.7018093 9.0356422,4.5930579 12.34,1.29 Z" transform="translate(2,2)" />
</IconBase>
)
}
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import {MdPriorityHigh} from 'react-icons/md'
export default class IconMissing extends React.Component {
render() {
return (
<MdPriorityHigh {...this.props} />
)
}
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react'
import IconBase from 'react-icon-base'
export default class IconSymbol extends React.Component {
render() {
return (
<IconBase viewBox="0 0 20 20" {...this.props}>
<g transform="matrix(1.2718518,0,0,1.2601269,16.559526,-7.4065264)">
<path d="m -9.7959773,11.060163 c -0.3734787,-0.724437 -0.3580577,-1.2147051 -0.00547,-1.8767873 l 8.6034029,-0.019416 c 0.39670292,0.6865644 0.38365934,1.4750693 -0.011097,1.8864953 l -3.1359613,-0.0033 -0.013695,7.1305 c -0.4055357,0.397083 -1.3146432,0.397083 -1.7769191,-0.02274 l 0.030226,-7.104422 z" />
</g>
</IconBase>
)
}
}
+21 -21
View File
@@ -1,29 +1,29 @@
import React from "react"; import React from 'react'
import InputString from "./InputString"; import InputString from './InputString'
import InputNumber from "./InputNumber"; import InputNumber from './InputNumber'
export type InputArrayProps = { export type FieldArrayProps = {
value: (string | number | undefined)[] value: (string | number | undefined)[]
type?: string type?: string
length?: number length?: number
default?: (string | number | undefined)[] default?: (string | number | undefined)[]
onChange?(value: (string | number | undefined)[] | undefined): unknown onChange?(value: (string | number | undefined)[] | undefined): unknown
"aria-label"?: string 'aria-label'?: string
label?: string label?: string
}; };
type InputArrayState = { type FieldArrayState = {
value: (string | number | undefined)[] value: (string | number | undefined)[]
initialPropsValue: unknown[] initialPropsValue: unknown[]
}; }
export default class InputArray extends React.Component<InputArrayProps, InputArrayState> { export default class FieldArray extends React.Component<FieldArrayProps, FieldArrayState> {
static defaultProps = { static defaultProps = {
value: [], value: [],
default: [], default: [],
}; }
constructor (props: InputArrayProps) { constructor (props: FieldArrayProps) {
super(props); super(props);
this.state = { this.state = {
value: this.props.value.slice(0), value: this.props.value.slice(0),
@@ -32,7 +32,7 @@ export default class InputArray extends React.Component<InputArrayProps, InputAr
}; };
} }
static getDerivedStateFromProps(props: Readonly<InputArrayProps>, state: InputArrayState) { static getDerivedStateFromProps(props: Readonly<FieldArrayProps>, state: FieldArrayState) {
const value: any[] = []; const value: any[] = [];
const initialPropsValue = state.initialPropsValue.slice(0); const initialPropsValue = state.initialPropsValue.slice(0);
@@ -44,7 +44,7 @@ export default class InputArray extends React.Component<InputArrayProps, InputAr
value[i] = state.value[i]; value[i] = state.value[i];
initialPropsValue[i] = state.value[i]; initialPropsValue[i] = state.value[i];
} }
}); })
return { return {
value, value,
@@ -54,7 +54,7 @@ export default class InputArray extends React.Component<InputArrayProps, InputAr
isComplete(value: unknown[]) { isComplete(value: unknown[]) {
return Array(this.props.length).fill(null).every((_, i) => { return Array(this.props.length).fill(null).every((_, i) => {
const val = value[i]; const val = value[i]
return !(val === undefined || val === ""); return !(val === undefined || val === "");
}); });
} }
@@ -82,20 +82,20 @@ export default class InputArray extends React.Component<InputArrayProps, InputAr
const containsValues = ( const containsValues = (
value.length > 0 && value.length > 0 &&
!value.every(val => { !value.every(val => {
return (val === "" || val === undefined); return (val === "" || val === undefined)
}) })
); );
const inputs = Array(this.props.length).fill(null).map((_, i) => { const inputs = Array(this.props.length).fill(null).map((_, i) => {
if(this.props.type === "number") { if(this.props.type === 'number') {
return <InputNumber return <InputNumber
key={i} key={i}
default={containsValues || !this.props.default ? undefined : this.props.default[i] as number} default={containsValues || !this.props.default ? undefined : this.props.default[i] as number}
value={value[i] as number} value={value[i] as number}
required={containsValues ? true : false} required={containsValues ? true : false}
onChange={(v) => this.changeValue(i, v)} onChange={(v) => this.changeValue(i, v)}
aria-label={this.props["aria-label"] || this.props.label} aria-label={this.props['aria-label'] || this.props.label}
/>; />
} else { } else {
return <InputString return <InputString
key={i} key={i}
@@ -103,15 +103,15 @@ export default class InputArray extends React.Component<InputArrayProps, InputAr
value={value[i] as string} value={value[i] as string}
required={containsValues ? true : false} required={containsValues ? true : false}
onChange={this.changeValue.bind(this, i)} onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label} aria-label={this.props['aria-label'] || this.props.label}
/>; />
} }
}); })
return ( return (
<div className="maputnik-array"> <div className="maputnik-array">
{inputs} {inputs}
</div> </div>
); )
} }
} }
-18
View File
@@ -1,18 +0,0 @@
import InputAutocomplete from "./InputAutocomplete";
import { mount } from "cypress/react";
const fruits = ["apple", "banana", "cherry"];
describe("<InputAutocomplete />", () => {
it("filters options when typing", () => {
mount(
<InputAutocomplete aria-label="Fruit" options={fruits.map(f => [f, f])} />
);
cy.get("input").focus();
cy.get(".maputnik-autocomplete-menu-item").should("have.length", 3);
cy.get("input").type("ch");
cy.get(".maputnik-autocomplete-menu-item").should("have.length", 1).and("contain", "cherry");
cy.get(".maputnik-autocomplete-menu-item").click();
cy.get("input").should("have.value", "cherry");
});
});
+81 -97
View File
@@ -1,116 +1,100 @@
import React from "react"; import React from 'react'
import classnames from "classnames"; import classnames from 'classnames'
import {useCombobox} from "downshift"; import Autocomplete from 'react-autocomplete'
const MAX_HEIGHT = 140; const MAX_HEIGHT = 140;
export type InputAutocompleteProps = { export type InputAutocompleteProps = {
value?: string value?: string
options?: any[] options: any[]
onChange?(value: string | undefined): unknown onChange(value: string | undefined): unknown
"aria-label"?: string keepMenuWithinWindowBounds?: boolean
'aria-label'?: string
}; };
export default function InputAutocomplete({ export default class InputAutocomplete extends React.Component<InputAutocompleteProps> {
value, state = {
options = [], maxHeight: MAX_HEIGHT
onChange = () => {}, }
"aria-label": ariaLabel,
}: InputAutocompleteProps) {
const [input, setInput] = React.useState(value || "");
const menuRef = React.useRef<HTMLDivElement>(null);
const [maxHeight, setMaxHeight] = React.useState(MAX_HEIGHT);
const filteredItems = React.useMemo(() => { autocompleteMenuEl: HTMLDivElement | null = null;
const lv = input.toLowerCase();
return options.filter((item) => item[0].toLowerCase().includes(lv));
}, [options, input]);
const calcMaxHeight = React.useCallback(() => { static defaultProps = {
if (menuRef.current) { onChange: () => {},
const space = window.innerHeight - menuRef.current.getBoundingClientRect().top; options: [],
setMaxHeight(Math.min(space, MAX_HEIGHT)); }
}
}, []);
const { calcMaxHeight() {
isOpen, if(this.props.keepMenuWithinWindowBounds) {
getMenuProps, const maxHeight = window.innerHeight - this.autocompleteMenuEl!.getBoundingClientRect().top;
getInputProps, const limitedMaxHeight = Math.min(maxHeight, MAX_HEIGHT);
getItemProps,
highlightedIndex, if(limitedMaxHeight != this.state.maxHeight) {
openMenu, this.setState({
} = useCombobox({ maxHeight: limitedMaxHeight
items: filteredItems, })
inputValue: input,
itemToString: (item) => (item ? item[0] : ""),
stateReducer: (_state, action) => {
if (action.type === useCombobox.stateChangeTypes.InputClick) {
return {...action.changes, isOpen: true};
} }
return action.changes;
},
onSelectedItemChange: ({selectedItem}) => {
const v = selectedItem ? selectedItem[0] : "";
setInput(v);
onChange(selectedItem ? selectedItem[0] : undefined);
},
onInputValueChange: ({inputValue: v}) => {
if (typeof v === "string") {
setInput(v);
onChange(v === "" ? undefined : v);
openMenu();
}
},
});
React.useEffect(() => {
if (isOpen) {
calcMaxHeight();
} }
}, [isOpen, calcMaxHeight]); }
React.useEffect(() => { componentDidMount() {
window.addEventListener("resize", calcMaxHeight); this.calcMaxHeight();
return () => window.removeEventListener("resize", calcMaxHeight); }
}, [calcMaxHeight]);
React.useEffect(() => { componentDidUpdate() {
setInput(value || ""); this.calcMaxHeight();
}, [value]); }
return ( onChange(v: string) {
<div className="maputnik-autocomplete"> this.props.onChange(v === "" ? undefined : v);
<input }
{...getInputProps({
"aria-label": ariaLabel, render() {
return <div
ref={(el) => {
this.autocompleteMenuEl = el;
}}
>
<Autocomplete
menuStyle={{
position: "fixed",
overflow: "auto",
maxHeight: this.state.maxHeight,
zIndex: '998'
}}
wrapperProps={{
className: "maputnik-autocomplete",
style: {}
}}
inputProps={{
'aria-label': this.props['aria-label'],
className: "maputnik-string", className: "maputnik-string",
spellCheck: false, spellCheck: false
onFocus: () => openMenu(), }}
})} value={this.props.value}
items={this.props.options}
getItemValue={(item) => item[0]}
onSelect={v => this.onChange(v)}
onChange={(_e, v) => this.onChange(v)}
shouldItemRender={(item, value="") => {
if (typeof(value) === "string") {
return item[0].toLowerCase().indexOf(value.toLowerCase()) > -1
}
return false
}}
renderItem={(item, isHighlighted) => (
<div
key={item[0]}
className={classnames({
"maputnik-autocomplete-menu-item": true,
"maputnik-autocomplete-menu-item-selected": isHighlighted,
})}
>
{item[1]}
</div>
)}
/> />
<div
{...getMenuProps({}, {suppressRefError: true})}
ref={menuRef}
style={{position: "fixed", overflow: "auto", maxHeight, zIndex: 998}}
className="maputnik-autocomplete-menu"
>
{isOpen &&
filteredItems.map((item, index) => (
<div
key={item[0]}
{...getItemProps({
item,
index,
className: classnames("maputnik-autocomplete-menu-item", {
"maputnik-autocomplete-menu-item-selected": highlightedIndex === index,
}),
})}
>
{item[1]}
</div>
))}
</div>
</div> </div>
); }
} }
+3 -3
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from 'react'
import classnames from "classnames"; import classnames from 'classnames'
type InputButtonProps = { type InputButtonProps = {
"data-wd-key"?: string "data-wd-key"?: string
@@ -28,6 +28,6 @@ export default class InputButton extends React.Component<InputButtonProps> {
style={this.props.style} style={this.props.style}
> >
{this.props.children} {this.props.children}
</button>; </button>
} }
} }
+5 -5
View File
@@ -1,4 +1,4 @@
import React from "react"; import React from 'react'
export type InputCheckboxProps = { export type InputCheckboxProps = {
value?: boolean value?: boolean
@@ -9,11 +9,11 @@ export type InputCheckboxProps = {
export default class InputCheckbox extends React.Component<InputCheckboxProps> { export default class InputCheckbox extends React.Component<InputCheckboxProps> {
static defaultProps = { static defaultProps = {
value: false, value: false,
}; }
onChange = () => { onChange = () => {
this.props.onChange(!this.props.value); this.props.onChange(!this.props.value);
}; }
render() { render() {
return <div className="maputnik-checkbox-wrapper"> return <div className="maputnik-checkbox-wrapper">
@@ -27,11 +27,11 @@ export default class InputCheckbox extends React.Component<InputCheckboxProps> {
/> />
<div className="maputnik-checkbox-box"> <div className="maputnik-checkbox-box">
<svg style={{ <svg style={{
display: this.props.value ? "inline" : "none" display: this.props.value ? 'inline' : 'none'
}} className="maputnik-checkbox-icon" viewBox='0 0 32 32'> }} className="maputnik-checkbox-icon" viewBox='0 0 32 32'>
<path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' /> <path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' />
</svg> </svg>
</div> </div>
</div>; </div>
} }
} }
+29 -29
View File
@@ -1,12 +1,12 @@
import React from "react"; import React from 'react'
import Color from "color"; import Color from 'color'
import ChromePicker from "react-color/lib/components/chrome/Chrome"; import ChromePicker from 'react-color/lib/components/chrome/Chrome'
import {type ColorResult} from "react-color"; import {ColorResult} from 'react-color';
import lodash from "lodash"; import lodash from 'lodash';
function formatColor(color: ColorResult): string { function formatColor(color: ColorResult): string {
const rgb = color.rgb; const rgb = color.rgb
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})`; return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})`
} }
export type InputColorProps = { export type InputColorProps = {
@@ -16,14 +16,14 @@ export type InputColorProps = {
doc?: string doc?: string
style?: object style?: object
default?: string default?: string
"aria-label"?: string 'aria-label'?: string
}; };
/*** Number fields with support for min, max and units and documentation*/ /*** Number fields with support for min, max and units and documentation*/
export default class InputColor extends React.Component<InputColorProps> { export default class InputColor extends React.Component<InputColorProps> {
state = { state = {
pickerOpened: false pickerOpened: false
}; }
colorInput: HTMLInputElement | null = null; colorInput: HTMLInputElement | null = null;
constructor (props: InputColorProps) { constructor (props: InputColorProps) {
@@ -39,29 +39,29 @@ export default class InputColor extends React.Component<InputColorProps> {
//but I am too stupid to get it to work together with fixed position //but I am too stupid to get it to work together with fixed position
//and scrollbars so I have to fallback to JavaScript //and scrollbars so I have to fallback to JavaScript
calcPickerOffset = () => { calcPickerOffset = () => {
const elem = this.colorInput; const elem = this.colorInput
if(elem) { if(elem) {
const pos = elem.getBoundingClientRect(); const pos = elem.getBoundingClientRect()
return { return {
top: pos.top, top: pos.top,
left: pos.left + 196, left: pos.left + 196,
}; }
} else { } else {
return { return {
top: 160, top: 160,
left: 555, left: 555,
}; }
} }
}; }
togglePicker = () => { togglePicker = () => {
this.setState({ pickerOpened: !this.state.pickerOpened }); this.setState({ pickerOpened: !this.state.pickerOpened })
}; }
get color() { get color() {
// Catch invalid color. // Catch invalid color.
try { try {
return Color(this.props.value).rgb(); return Color(this.props.value).rgb()
} }
catch(err) { catch(err) {
console.warn("Error parsing color: ", err); console.warn("Error parsing color: ", err);
@@ -74,7 +74,7 @@ export default class InputColor extends React.Component<InputColorProps> {
} }
render() { render() {
const offset = this.calcPickerOffset(); const offset = this.calcPickerOffset()
const currentColor = this.color.object(); const currentColor = this.color.object();
const currentChromeColor = { const currentChromeColor = {
r: currentColor.r, r: currentColor.r,
@@ -82,12 +82,12 @@ export default class InputColor extends React.Component<InputColorProps> {
b: currentColor.b, b: currentColor.b,
// Rename alpha -> a for ChromePicker // Rename alpha -> a for ChromePicker
a: currentColor.alpha! a: currentColor.alpha!
}; }
const picker = <div const picker = <div
className="maputnik-color-picker-offset" className="maputnik-color-picker-offset"
style={{ style={{
position: "fixed", position: 'fixed',
zIndex: 1, zIndex: 1,
left: offset.left, left: offset.left,
top: offset.top, top: offset.top,
@@ -101,14 +101,14 @@ export default class InputColor extends React.Component<InputColorProps> {
onClick={this.togglePicker} onClick={this.togglePicker}
style={{ style={{
zIndex: -1, zIndex: -1,
position: "fixed", position: 'fixed',
top: "0px", top: '0px',
right: "0px", right: '0px',
bottom: "0px", bottom: '0px',
left: "0px", left: '0px',
}} }}
/> />
</div>; </div>
const swatchStyle = { const swatchStyle = {
backgroundColor: this.props.value backgroundColor: this.props.value
@@ -118,11 +118,11 @@ export default class InputColor extends React.Component<InputColorProps> {
{this.state.pickerOpened && picker} {this.state.pickerOpened && picker}
<div className="maputnik-color-swatch" style={swatchStyle}></div> <div className="maputnik-color-swatch" style={swatchStyle}></div>
<input <input
aria-label={this.props["aria-label"]} aria-label={this.props['aria-label']}
spellCheck="false" spellCheck="false"
autoComplete="off" autoComplete="off"
className="maputnik-color" className="maputnik-color"
ref={(input) => {this.colorInput = input;}} ref={(input) => this.colorInput = input}
onClick={this.togglePicker} onClick={this.togglePicker}
style={this.props.style} style={this.props.style}
name={this.props.name} name={this.props.name}
@@ -130,6 +130,6 @@ export default class InputColor extends React.Component<InputColorProps> {
value={this.props.value ? this.props.value : ""} value={this.props.value ? this.props.value : ""}
onChange={(e) => this.onChange(e.target.value)} onChange={(e) => this.onChange(e.target.value)}
/> />
</div>; </div>
} }
} }
+46 -56
View File
@@ -1,67 +1,64 @@
import React from "react"; import React from 'react'
import capitalize from "lodash.capitalize"; import capitalize from 'lodash.capitalize'
import {MdDelete} from "react-icons/md"; import {MdDelete} from 'react-icons/md'
import { type WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from 'react-i18next';
import InputString from "./InputString"; import InputString from './InputString'
import InputNumber from "./InputNumber"; import InputNumber from './InputNumber'
import InputButton from "./InputButton"; import InputButton from './InputButton'
import FieldDocLabel from "./FieldDocLabel"; import FieldDocLabel from './FieldDocLabel'
import InputEnum from "./InputEnum"; import InputEnum from './InputEnum'
import InputUrl from "./InputUrl"; import InputUrl from './InputUrl'
import InputColor from "./InputColor";
export type InputDynamicArrayProps = { export type FieldDynamicArrayProps = {
value?: (string | number | undefined)[] value?: (string | number | undefined)[]
type?: "url" | "number" | "enum" | "string" | "color" type?: 'url' | 'number' | 'enum' | 'string'
default?: (string | number | undefined)[] default?: (string | number | undefined)[]
onChange?(values: (string | number | undefined)[] | undefined): unknown onChange?(values: (string | number | undefined)[] | undefined): unknown
style?: object style?: object
fieldSpec?: { fieldSpec?: {
values?: any values?: any
} }
"aria-label"?: string 'aria-label'?: string
label: string label: string
}; }
type InputDynamicArrayInternalProps = InputDynamicArrayProps & WithTranslation; type FieldDynamicArrayInternalProps = FieldDynamicArrayProps & WithTranslation;
class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInternalProps> { class FieldDynamicArrayInternal extends React.Component<FieldDynamicArrayInternalProps> {
changeValue(idx: number, newValue: string | number | undefined) { changeValue(idx: number, newValue: string | number | undefined) {
const values = this.values.slice(0); const values = this.values.slice(0)
values[idx] = newValue; values[idx] = newValue
if (this.props.onChange) this.props.onChange(values); if (this.props.onChange) this.props.onChange(values)
} }
get values() { get values() {
return this.props.value || this.props.default || []; return this.props.value || this.props.default || []
} }
addValue = () => { addValue = () => {
const values = this.values.slice(0); const values = this.values.slice(0)
if (this.props.type === "number") { if (this.props.type === 'number') {
values.push(0); values.push(0)
} }
else if (this.props.type === "url") { else if (this.props.type === 'url') {
values.push(""); values.push("");
} }
else if (this.props.type === "enum") { else if (this.props.type === 'enum') {
const {fieldSpec} = this.props; const {fieldSpec} = this.props;
const defaultValue = Object.keys(fieldSpec!.values)[0]; const defaultValue = Object.keys(fieldSpec!.values)[0];
values.push(defaultValue); values.push(defaultValue);
} else if (this.props.type === "color") {
values.push("#000000");
} else { } else {
values.push(""); values.push("")
} }
if (this.props.onChange) this.props.onChange(values); if (this.props.onChange) this.props.onChange(values)
}; }
deleteValue(valueIdx: number) { deleteValue(valueIdx: number) {
const values = this.values.slice(0); const values = this.values.slice(0)
values.splice(valueIdx, 1); values.splice(valueIdx, 1)
if (this.props.onChange) this.props.onChange(values.length > 0 ? values : undefined); if (this.props.onChange) this.props.onChange(values.length > 0 ? values : undefined);
} }
@@ -75,42 +72,35 @@ class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInterna
{...i18nProps} {...i18nProps}
/>; />;
let input; let input;
if(this.props.type === "url") { if(this.props.type === 'url') {
input = <InputUrl input = <InputUrl
value={v as string} value={v as string}
onChange={this.changeValue.bind(this, i)} onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label} aria-label={this.props['aria-label'] || this.props.label}
/>; />
} }
else if (this.props.type === "number") { else if (this.props.type === 'number') {
input = <InputNumber input = <InputNumber
value={v as number} value={v as number}
onChange={this.changeValue.bind(this, i)} onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label} aria-label={this.props['aria-label'] || this.props.label}
/>; />
} }
else if (this.props.type === "enum") { else if (this.props.type === 'enum') {
const options = Object.keys(this.props.fieldSpec?.values).map(v => [v, capitalize(v)]); const options = Object.keys(this.props.fieldSpec?.values).map(v => [v, capitalize(v)]);
input = <InputEnum input = <InputEnum
options={options} options={options}
value={v as string} value={v as string}
onChange={this.changeValue.bind(this, i)} onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label} aria-label={this.props['aria-label'] || this.props.label}
/>; />
}
else if (this.props.type === "color") {
input = <InputColor
value={v as string}
onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label}
/>;
} }
else { else {
input = <InputString input = <InputString
value={v as string} value={v as string}
onChange={this.changeValue.bind(this, i)} onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label} aria-label={this.props['aria-label'] || this.props.label}
/>; />
} }
return <div return <div
@@ -124,8 +114,8 @@ class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInterna
<div className="maputnik-array-block-content"> <div className="maputnik-array-block-content">
{input} {input}
</div> </div>
</div>; </div>
}); })
return ( return (
<div className="maputnik-array"> <div className="maputnik-array">
@@ -141,8 +131,8 @@ class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInterna
} }
} }
const InputDynamicArray = withTranslation()(InputDynamicArrayInternal); const FieldDynamicArray = withTranslation()(FieldDynamicArrayInternal);
export default InputDynamicArray; export default FieldDynamicArray;
type DeleteValueInputButtonProps = { type DeleteValueInputButtonProps = {
onClick?(...args: unknown[]): unknown onClick?(...args: unknown[]): unknown
@@ -159,6 +149,6 @@ class DeleteValueInputButton extends React.Component<DeleteValueInputButtonProps
<FieldDocLabel <FieldDocLabel
label={<MdDelete />} label={<MdDelete />}
/> />
</InputButton>; </InputButton>
} }
} }
+11 -11
View File
@@ -1,14 +1,14 @@
import React from "react"; import React from 'react'
import InputSelect from "./InputSelect"; import InputSelect from './InputSelect'
import InputMultiInput from "./InputMultiInput"; import InputMultiInput from './InputMultiInput'
function optionsLabelLength(options: any[]) { function optionsLabelLength(options: any[]) {
let sum = 0; let sum = 0;
options.forEach(([_, label]) => { options.forEach(([_, label]) => {
sum += label.length; sum += label.length
}); })
return sum; return sum
} }
@@ -20,7 +20,7 @@ export type InputEnumProps = {
name?: string name?: string
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
options: any[] options: any[]
"aria-label"?: string 'aria-label'?: string
label?: string label?: string
}; };
@@ -35,15 +35,15 @@ export default class InputEnum extends React.Component<InputEnumProps> {
options={options} options={options}
value={(value || this.props.default)!} value={(value || this.props.default)!}
onChange={onChange} onChange={onChange}
aria-label={this.props["aria-label"] || label} aria-label={this.props['aria-label'] || label}
/>; />
} else { } else {
return <InputSelect return <InputSelect
options={options} options={options}
value={(value || this.props.default)!} value={(value || this.props.default)!}
onChange={onChange} onChange={onChange}
aria-label={this.props["aria-label"] || label} aria-label={this.props['aria-label'] || label}
/>; />
} }
} }
} }
+12 -12
View File
@@ -1,20 +1,20 @@
import React from "react"; import React from 'react'
import InputAutocomplete from "./InputAutocomplete"; import InputAutocomplete from './InputAutocomplete'
export type InputFontProps = { export type FieldFontProps = {
name: string name: string
value?: string[] value?: string[]
default?: string[] default?: string[]
fonts?: unknown[] fonts?: unknown[]
style?: object style?: object
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
"aria-label"?: string 'aria-label'?: string
}; };
export default class InputFont extends React.Component<InputFontProps> { export default class FieldFont extends React.Component<FieldFontProps> {
static defaultProps = { static defaultProps = {
fonts: [] fonts: []
}; }
get values() { get values() {
const out = this.props.value || this.props.default || []; const out = this.props.value || this.props.default || [];
@@ -29,11 +29,11 @@ export default class InputFont extends React.Component<InputFontProps> {
} }
changeFont(idx: number, newValue: string) { changeFont(idx: number, newValue: string) {
const changedValues = this.values.slice(0); const changedValues = this.values.slice(0)
changedValues[idx] = newValue; changedValues[idx] = newValue
const filteredValues = changedValues const filteredValues = changedValues
.filter(v => v !== undefined) .filter(v => v !== undefined)
.filter(v => v !== ""); .filter(v => v !== "")
this.props.onChange(filteredValues); this.props.onChange(filteredValues);
} }
@@ -44,13 +44,13 @@ export default class InputFont extends React.Component<InputFontProps> {
key={i} key={i}
> >
<InputAutocomplete <InputAutocomplete
aria-label={this.props["aria-label"] || this.props.name} aria-label={this.props['aria-label'] || this.props.name}
value={value} value={value}
options={this.props.fonts?.map(f => [f, f])} options={this.props.fonts?.map(f => [f, f])}
onChange={this.changeFont.bind(this, i)} onChange={this.changeFont.bind(this, i)}
/> />
</li>; </li>
}); })
return ( return (
<ul className="maputnik-font"> <ul className="maputnik-font">
+103 -58
View File
@@ -1,98 +1,126 @@
import React from "react"; import React from 'react'
import classnames from "classnames"; import classnames from 'classnames';
import { type WithTranslation, withTranslation } from "react-i18next"; import CodeMirror, { ModeSpec } from 'codemirror';
import { Trans, WithTranslation, withTranslation } from 'react-i18next';
import { type EditorView } from "@codemirror/view"; import 'codemirror/mode/javascript/javascript'
import stringifyPretty from "json-stringify-pretty-compact"; import 'codemirror/addon/lint/lint'
import 'codemirror/addon/edit/matchbrackets'
import 'codemirror/lib/codemirror.css'
import 'codemirror/addon/lint/lint.css'
import stringifyPretty from 'json-stringify-pretty-compact'
import '../libs/codemirror-mgl';
import {createEditor} from "../libs/codemirror-editor-factory";
import type { StylePropertySpecification } from "maplibre-gl";
import type { TransactionSpec } from "@codemirror/state";
export type InputJsonProps = { export type InputJsonProps = {
value: object layer: any
maxHeight?: number
onChange?(...args: unknown[]): unknown
lineNumbers?: boolean
lineWrapping?: boolean
getValue?(data: any): string
gutters?: string[]
className?: string className?: string
onChange(object: object): void
onFocus?(...args: unknown[]): unknown onFocus?(...args: unknown[]): unknown
onBlur?(...args: unknown[]): unknown onBlur?(...args: unknown[]): unknown
lintType: "layer" | "style" | "expression" | "json" onJSONValid?(...args: unknown[]): unknown
spec?: StylePropertySpecification | undefined onJSONInvalid?(...args: unknown[]): unknown
/** mode?: ModeSpec<any>
* When setting this and using search and replace, the editor will scroll to the selected text lint?: boolean | object
* Use this only when the editor is the only element in the page.
*/
withScroll?: boolean
}; };
type InputJsonInternalProps = InputJsonProps & WithTranslation; type InputJsonInternalProps = InputJsonProps & WithTranslation;
type InputJsonState = { type InputJsonState = {
isEditing: boolean isEditing: boolean
showMessage: boolean
prevValue: string prevValue: string
}; };
class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJsonState> { class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJsonState> {
static defaultProps = { static defaultProps = {
lineNumbers: true,
lineWrapping: false,
gutters: ["CodeMirror-lint-markers"],
getValue: (data: any) => {
return stringifyPretty(data, {indent: 2, maxLength: 40});
},
onFocus: () => {}, onFocus: () => {},
onBlur: () => {}, onBlur: () => {},
withScroll: false onJSONInvalid: () => {},
}; onJSONValid: () => {},
_view: EditorView | undefined; }
_keyEvent: string;
_doc: CodeMirror.Editor | undefined;
_el: HTMLDivElement | null = null; _el: HTMLDivElement | null = null;
_cancelNextChange: boolean = false; _cancelNextChange: boolean = false;
constructor(props: InputJsonInternalProps) { constructor(props: InputJsonInternalProps) {
super(props); super(props);
this._keyEvent = "keyboard";
this.state = { this.state = {
isEditing: false, isEditing: false,
prevValue: this.getPrettyJson(this.props.value), showMessage: false,
prevValue: this.props.getValue!(this.props.layer),
}; };
} }
getPrettyJson(data: any) { componentDidMount () {
return stringifyPretty(data, {indent: 2, maxLength: 40}); this._doc = CodeMirror(this._el!, {
value: this.props.getValue!(this.props.layer),
mode: this.props.mode || {
name: "mgl",
},
lineWrapping: this.props.lineWrapping,
tabSize: 2,
theme: 'maputnik',
viewportMargin: Infinity,
lineNumbers: this.props.lineNumbers,
lint: this.props.lint || {
context: "layer"
},
matchBrackets: true,
gutters: this.props.gutters,
scrollbarStyle: "null",
});
this._doc.on('change', this.onChange);
this._doc.on('focus', this.onFocus);
this._doc.on('blur', this.onBlur);
} }
componentDidMount () { onPointerDown = () => {
this._view = createEditor({ this._keyEvent = "pointer";
parent: this._el!,
value: this.getPrettyJson(this.props.value),
lintType: this.props.lintType || "layer",
onChange: (value:string) => this.onChange(value),
onFocus: () => this.onFocus(),
onBlur: () => this.onBlur(),
spec: this.props.spec
});
} }
onFocus = () => { onFocus = () => {
if (this.props.onFocus) this.props.onFocus(); if (this.props.onFocus) this.props.onFocus();
this.setState({ this.setState({
isEditing: true, isEditing: true,
showMessage: (this._keyEvent === "keyboard"),
}); });
}; }
onBlur = () => { onBlur = () => {
this._keyEvent = "keyboard";
if (this.props.onBlur) this.props.onBlur(); if (this.props.onBlur) this.props.onBlur();
this.setState({ this.setState({
isEditing: false, isEditing: false,
showMessage: false,
}); });
}; }
componentWillUnMount () {
this._doc!.off('change', this.onChange);
this._doc!.off('focus', this.onFocus);
this._doc!.off('blur', this.onBlur);
}
componentDidUpdate(prevProps: InputJsonProps) { componentDidUpdate(prevProps: InputJsonProps) {
if (!this.state.isEditing && prevProps.value !== this.props.value) { if (!this.state.isEditing && prevProps.layer !== this.props.layer) {
this._cancelNextChange = true; this._cancelNextChange = true;
const transactionSpec: TransactionSpec = { this._doc!.setValue(
changes: { this.props.getValue!(this.props.layer),
from: 0, )
to: this._view!.state.doc.length,
insert: this.getPrettyJson(this.props.value)
}
};
if (this.props.withScroll) {
transactionSpec.selection = this._view!.state.selection;
transactionSpec.scrollIntoView = true;
}
this._view!.dispatch(transactionSpec);
} }
} }
@@ -100,11 +128,11 @@ class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJso
if (this._cancelNextChange) { if (this._cancelNextChange) {
this._cancelNextChange = false; this._cancelNextChange = false;
this.setState({ this.setState({
prevValue: this._view!.state.doc.toString(), prevValue: this._doc!.getValue(),
}); })
return; return;
} }
const newCode = this._view!.state.doc.toString(); const newCode = this._doc!.getValue();
if (this.state.prevValue !== newCode) { if (this.state.prevValue !== newCode) {
let parsedLayer, err; let parsedLayer, err;
@@ -112,26 +140,43 @@ class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJso
parsedLayer = JSON.parse(newCode); parsedLayer = JSON.parse(newCode);
} catch(_err) { } catch(_err) {
err = _err; err = _err;
console.warn(_err); console.warn(_err)
} }
if (!err) { if (err && this.props.onJSONInvalid) {
if (this.props.onChange) this.props.onChange(parsedLayer); this.props.onJSONInvalid();
}
else {
if (this.props.onChange) this.props.onChange(parsedLayer)
if (this.props.onJSONValid) this.props.onJSONValid();
} }
} }
this.setState({ this.setState({
prevValue: newCode, prevValue: newCode,
}); });
}; }
render() { render() {
return <div className="json-editor" data-wd-key="json-editor" aria-hidden="true" style={{cursor: "text"}}> const t = this.props.t;
const {showMessage} = this.state;
const style = {} as {maxHeight?: number};
if (this.props.maxHeight) {
style.maxHeight = this.props.maxHeight;
}
return <div className="JSONEditor" onPointerDown={this.onPointerDown} aria-hidden="true">
<div className={classnames("JSONEditor__message", {"JSONEditor__message--on": showMessage})}>
<Trans t={t}>
Press <kbd>ESC</kbd> to lose focus
</Trans>
</div>
<div <div
className={classnames("codemirror-container", this.props.className)} className={classnames("codemirror-container", this.props.className)}
ref={(el) => {this._el = el;}} ref={(el) => this._el = el}
style={style}
/> />
</div>; </div>
} }
} }
+11 -11
View File
@@ -1,26 +1,26 @@
import React from "react"; import React from 'react'
import classnames from "classnames"; import classnames from 'classnames'
export type InputMultiInputProps = { export type InputMultiInputProps = {
name?: string name?: string
value: string value: string
options: any[] options: any[]
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
"aria-label"?: string 'aria-label'?: string
}; };
export default class InputMultiInput extends React.Component<InputMultiInputProps> { export default class InputMultiInput extends React.Component<InputMultiInputProps> {
render() { render() {
let options = this.props.options; let options = this.props.options
if(options.length > 0 && !Array.isArray(options[0])) { if(options.length > 0 && !Array.isArray(options[0])) {
options = options.map(v => [v, v]); options = options.map(v => [v, v])
} }
const selectedValue = this.props.value || options[0][0]; const selectedValue = this.props.value || options[0][0]
const radios = options.map(([val, label])=> { const radios = options.map(([val, label])=> {
return <label return <label
key={val} key={val}
className={classnames("maputnik-button", "maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})} className={classnames("maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})}
> >
<input type="radio" <input type="radio"
name={this.props.name} name={this.props.name}
@@ -29,11 +29,11 @@ export default class InputMultiInput extends React.Component<InputMultiInputProp
checked={val === selectedValue} checked={val === selectedValue}
/> />
{label} {label}
</label>; </label>
}); })
return <fieldset className="maputnik-multibutton" aria-label={this.props["aria-label"]}> return <fieldset className="maputnik-multibutton" aria-label={this.props['aria-label']}>
{radios} {radios}
</fieldset>; </fieldset>
} }
} }
+20 -20
View File
@@ -1,5 +1,5 @@
import React, { type BaseSyntheticEvent } from "react"; import React, { BaseSyntheticEvent } from 'react'
import generateUniqueId from "../libs/document-uid"; import generateUniqueId from '../libs/document-uid';
export type InputNumberProps = { export type InputNumberProps = {
value?: number value?: number
@@ -23,22 +23,22 @@ type InputNumberState = {
* This is the value that is currently being edited. It can be an invalid value. * This is the value that is currently being edited. It can be an invalid value.
*/ */
dirtyValue?: number | string | undefined dirtyValue?: number | string | undefined
}; }
export default class InputNumber extends React.Component<InputNumberProps, InputNumberState> { export default class InputNumber extends React.Component<InputNumberProps, InputNumberState> {
static defaultProps = { static defaultProps = {
rangeStep: 1 rangeStep: 1
}; }
_keyboardEvent: boolean = false; _keyboardEvent: boolean = false;
constructor(props: InputNumberProps) { constructor(props: InputNumberProps) {
super(props); super(props)
this.state = { this.state = {
uuid: +generateUniqueId(), uuid: +generateUniqueId(),
editing: false, editing: false,
value: props.value, value: props.value,
dirtyValue: props.value, dirtyValue: props.value,
}; }
} }
static getDerivedStateFromProps(props: Readonly<InputNumberProps>, state: InputNumberState) { static getDerivedStateFromProps(props: Readonly<InputNumberProps>, state: InputNumberState) {
@@ -57,7 +57,7 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
const hasChanged = this.props.value !== value; const hasChanged = this.props.value !== value;
if(this.isValid(value) && hasChanged) { if(this.isValid(value) && hasChanged) {
if (this.props.onChange) this.props.onChange(value); if (this.props.onChange) this.props.onChange(value)
this.setState({ this.setState({
value: value, value: value,
}); });
@@ -70,7 +70,7 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
this.setState({ this.setState({
dirtyValue: newValue === "" ? undefined : newValue, dirtyValue: newValue === "" ? undefined : newValue,
}); })
} }
isValid(v: number | string | undefined) { isValid(v: number | string | undefined) {
@@ -80,18 +80,18 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
const value = +v; const value = +v;
if(isNaN(value)) { if(isNaN(value)) {
return false; return false
} }
if(!isNaN(this.props.min!) && value < this.props.min!) { if(!isNaN(this.props.min!) && value < this.props.min!) {
return false; return false
} }
if(!isNaN(this.props.max!) && value > this.props.max!) { if(!isNaN(this.props.max!) && value > this.props.max!) {
return false; return false
} }
return true; return true
} }
resetValue = () => { resetValue = () => {
@@ -104,14 +104,14 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
// If set value is invalid fall back to the last valid value from props or at last resort the default value // If set value is invalid fall back to the last valid value from props or at last resort the default value
if (!this.isValid(this.state.value)) { if (!this.isValid(this.state.value)) {
if(this.isValid(this.props.value)) { if(this.isValid(this.props.value)) {
this.changeValue(this.props.value); this.changeValue(this.props.value)
this.setState({dirtyValue: this.props.value}); this.setState({dirtyValue: this.props.value});
} else { } else {
this.changeValue(undefined); this.changeValue(undefined);
this.setState({dirtyValue: undefined}); this.setState({dirtyValue: undefined});
} }
} }
}; }
onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => { onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
let value = parseFloat(e.target.value); let value = parseFloat(e.target.value);
@@ -132,7 +132,7 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
value = this.state.value! - step; value = this.state.value! - step;
} }
else { else {
value = this.state.value! + step; value = this.state.value! + step
} }
dirtyValue = value; dirtyValue = value;
} }
@@ -153,7 +153,7 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
this.setState({value, dirtyValue}); this.setState({value, dirtyValue});
if (this.props.onChange) this.props.onChange(value); if (this.props.onChange) this.props.onChange(value);
}; }
render() { render() {
if( if(
@@ -217,18 +217,18 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
}} }}
onBlur={_e => { onBlur={_e => {
this.setState({editing: false}); this.setState({editing: false});
this.resetValue(); this.resetValue()
}} }}
data-wd-key={this.props["data-wd-key"] + "-text"} data-wd-key={this.props["data-wd-key"] + "-text"}
/> />
</div>; </div>
} }
else { else {
const value = this.state.editing ? this.state.dirtyValue : this.state.value; const value = this.state.editing ? this.state.dirtyValue : this.state.value;
return <input return <input
aria-label={this.props["aria-label"]} aria-label={this.props['aria-label']}
spellCheck="false" spellCheck="false"
className="maputnik-number" className="maputnik-number"
placeholder={this.props.default?.toString()} placeholder={this.props.default?.toString()}
@@ -240,7 +240,7 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
onBlur={this.resetValue} onBlur={this.resetValue}
required={this.props.required} required={this.props.required}
data-wd-key={this.props["data-wd-key"]} data-wd-key={this.props["data-wd-key"]}
/>; />
} }
} }
} }
+4 -4
View File
@@ -1,4 +1,4 @@
import React from "react"; import React from 'react'
export type InputSelectProps = { export type InputSelectProps = {
value: string value: string
@@ -7,7 +7,7 @@ export type InputSelectProps = {
style?: object style?: object
onChange(value: string | [string, any]): unknown onChange(value: string | [string, any]): unknown
title?: string title?: string
"aria-label"?: string 'aria-label'?: string
}; };
export default class InputSelect extends React.Component<InputSelectProps> { export default class InputSelect extends React.Component<InputSelectProps> {
@@ -24,9 +24,9 @@ export default class InputSelect extends React.Component<InputSelectProps> {
title={this.props.title} title={this.props.title}
value={this.props.value} value={this.props.value}
onChange={e => this.props.onChange(e.target.value)} onChange={e => this.props.onChange(e.target.value)}
aria-label={this.props["aria-label"]} aria-label={this.props['aria-label']}
> >
{ options.map(([val, label]) => <option key={val} value={val}>{label}</option>) } { options.map(([val, label]) => <option key={val} value={val}>{label}</option>) }
</select>; </select>
} }
} }
+77 -103
View File
@@ -1,26 +1,24 @@
import React, { type ReactElement } from "react"; import React, { ReactElement } from 'react'
import InputColor, { type InputColorProps } from "./InputColor"; import InputColor, { InputColorProps } from './InputColor'
import InputNumber, { type InputNumberProps } from "./InputNumber"; import InputNumber, { InputNumberProps } from './InputNumber'
import InputCheckbox, { type InputCheckboxProps } from "./InputCheckbox"; import InputCheckbox, { InputCheckboxProps } from './InputCheckbox'
import InputString, { type InputStringProps } from "./InputString"; import InputString, { InputStringProps } from './InputString'
import InputArray, { type InputArrayProps } from "./InputArray"; import InputArray, { FieldArrayProps } from './InputArray'
import InputDynamicArray, { type InputDynamicArrayProps } from "./InputDynamicArray"; import InputDynamicArray, { FieldDynamicArrayProps } from './InputDynamicArray'
import InputFont, { type InputFontProps } from "./InputFont"; import InputFont, { FieldFontProps } from './InputFont'
import InputAutocomplete, { type InputAutocompleteProps } from "./InputAutocomplete"; import InputAutocomplete, { InputAutocompleteProps } from './InputAutocomplete'
import InputEnum, { type InputEnumProps } from "./InputEnum"; import InputEnum, { InputEnumProps } from './InputEnum'
import capitalize from "lodash.capitalize"; import capitalize from 'lodash.capitalize'
const iconProperties = ["background-pattern", "fill-pattern", "line-pattern", "fill-extrusion-pattern", "icon-image"]; const iconProperties = ['background-pattern', 'fill-pattern', 'line-pattern', 'fill-extrusion-pattern', 'icon-image']
export type FieldSpecType = "number" | "enum" | "resolvedImage" | "formatted" | "string" | "color" | "boolean" | "array" | "numberArray" | "padding" | "colorArray" | "variableAnchorOffsetCollection"; export type SpecFieldProps = {
export type InputSpecProps = {
onChange?(fieldName: string | undefined, value: number | undefined | (string | number | undefined)[]): unknown onChange?(fieldName: string | undefined, value: number | undefined | (string | number | undefined)[]): unknown
fieldName?: string fieldName?: string
fieldSpec?: { fieldSpec?: {
default?: unknown default?: unknown
type?: FieldSpecType type?: 'number' | 'enum' | 'resolvedImage' | 'formatted' | 'string' | 'color' | 'boolean' | 'array'
minimum?: number minimum?: number
maximum?: number maximum?: number
values?: unknown[] values?: unknown[]
@@ -30,7 +28,8 @@ export type InputSpecProps = {
value?: string | number | unknown[] | boolean value?: string | number | unknown[] | boolean
/** Override the style of the field */ /** Override the style of the field */
style?: object style?: object
"aria-label"?: string 'aria-label'?: string
error?: unknown[]
label?: string label?: string
action?: ReactElement action?: ReactElement
}; };
@@ -38,10 +37,11 @@ export type InputSpecProps = {
/** Display any field from the Maplibre GL style spec and /** Display any field from the Maplibre GL style spec and
* choose the correct field component based on the @{fieldSpec} * choose the correct field component based on the @{fieldSpec}
* to display @{value}. */ * to display @{value}. */
export default class InputSpec extends React.Component<InputSpecProps> { export default class SpecField extends React.Component<SpecFieldProps> {
childNodes() { childNodes() {
const commonProps = { const commonProps = {
error: this.props.error,
fieldSpec: this.props.fieldSpec, fieldSpec: this.props.fieldSpec,
label: this.props.label, label: this.props.label,
action: this.props.action, action: this.props.action,
@@ -51,96 +51,70 @@ export default class InputSpec extends React.Component<InputSpecProps> {
name: this.props.fieldName, name: this.props.fieldName,
"data-wd-key": "spec-field-input:" + this.props.fieldName, "data-wd-key": "spec-field-input:" + this.props.fieldName,
onChange: (newValue: number | undefined | (string | number | undefined)[]) => this.props.onChange!(this.props.fieldName, newValue), onChange: (newValue: number | undefined | (string | number | undefined)[]) => this.props.onChange!(this.props.fieldName, newValue),
"aria-label": this.props["aria-label"], 'aria-label': this.props['aria-label'],
}; }
switch(this.props.fieldSpec?.type) { switch(this.props.fieldSpec?.type) {
case "number": return ( case 'number': return (
<InputNumber <InputNumber
{...commonProps as InputNumberProps} {...commonProps as InputNumberProps}
min={this.props.fieldSpec.minimum} min={this.props.fieldSpec.minimum}
max={this.props.fieldSpec.maximum} max={this.props.fieldSpec.maximum}
/> />
); )
case "enum": { case 'enum': {
const options = Object.keys(this.props.fieldSpec.values || []).map(v => [v, capitalize(v)]); const options = Object.keys(this.props.fieldSpec.values || []).map(v => [v, capitalize(v)])
return <InputEnum return <InputEnum
{...commonProps as Omit<InputEnumProps, "options">} {...commonProps as Omit<InputEnumProps, "options">}
options={options} options={options}
/>; />
}
case 'resolvedImage':
case 'formatted':
case 'string':
if (iconProperties.indexOf(this.props.fieldName!) >= 0) {
const options = this.props.fieldSpec.values || [];
return <InputAutocomplete
{...commonProps as Omit<InputAutocompleteProps, "options">}
options={options.map(f => [f, f])}
/>
} else {
return <InputString
{...commonProps as InputStringProps}
/>
} }
case "resolvedImage": case 'color': return (
case "formatted": <InputColor
case "string": {...commonProps as InputColorProps}
if (iconProperties.indexOf(this.props.fieldName!) >= 0) { />
const options = this.props.fieldSpec.values || []; )
return <InputAutocomplete case 'boolean': return (
{...commonProps as Omit<InputAutocompleteProps, "options">} <InputCheckbox
options={options.map(f => [f, f])} {...commonProps as InputCheckboxProps}
/>; />
)
case 'array':
if(this.props.fieldName === 'text-font') {
return <InputFont
{...commonProps as FieldFontProps}
fonts={this.props.fieldSpec.values}
/>
} else {
if (this.props.fieldSpec.length) {
return <InputArray
{...commonProps as FieldArrayProps}
type={this.props.fieldSpec.value}
length={this.props.fieldSpec.length}
/>
} else { } else {
return <InputString return <InputDynamicArray
{...commonProps as InputStringProps} {...commonProps as FieldDynamicArrayProps}
/>; fieldSpec={this.props.fieldSpec}
type={this.props.fieldSpec.value as FieldDynamicArrayProps['type']}
/>
} }
case "color": return ( }
<InputColor default: return null
{...commonProps as InputColorProps}
/>
);
case "boolean": return (
<InputCheckbox
{...commonProps as InputCheckboxProps}
/>
);
case "array":
if(this.props.fieldName === "text-font") {
return <InputFont
{...commonProps as InputFontProps}
fonts={this.props.fieldSpec.values}
/>;
} else {
if (this.props.fieldSpec.length) {
return <InputArray
{...commonProps as InputArrayProps}
type={this.props.fieldSpec.value}
length={this.props.fieldSpec.length}
/>;
} else {
return <InputDynamicArray
{...commonProps as InputDynamicArrayProps}
fieldSpec={this.props.fieldSpec}
type={this.props.fieldSpec.value as InputDynamicArrayProps["type"]}
/>;
}
}
case "numberArray": return (
<InputDynamicArray
{...commonProps as InputDynamicArrayProps}
fieldSpec={this.props.fieldSpec}
type="number"
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
/>
);
case "colorArray": return (
<InputDynamicArray
{...commonProps as InputDynamicArrayProps}
fieldSpec={this.props.fieldSpec}
type="color"
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
/>
);
case "padding": return (
<InputArray
{...commonProps as InputArrayProps}
type="number"
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
length={4}
/>
);
default:
console.warn(`No proper field input for ${this.props.fieldName} type: ${this.props.fieldSpec?.type}`);
return null;
} }
} }
+11 -11
View File
@@ -1,4 +1,4 @@
import React from "react"; import React from 'react'
export type InputStringProps = { export type InputStringProps = {
"data-wd-key"?: string "data-wd-key"?: string
@@ -11,26 +11,26 @@ export type InputStringProps = {
required?: boolean required?: boolean
disabled?: boolean disabled?: boolean
spellCheck?: boolean spellCheck?: boolean
"aria-label"?: string 'aria-label'?: string
title?: string title?: string
}; };
type InputStringState = { type InputStringState = {
editing: boolean editing: boolean
value?: string value?: string
}; }
export default class InputString extends React.Component<InputStringProps, InputStringState> { export default class InputString extends React.Component<InputStringProps, InputStringState> {
static defaultProps = { static defaultProps = {
onInput: () => {}, onInput: () => {},
}; }
constructor(props: InputStringProps) { constructor(props: InputStringProps) {
super(props); super(props)
this.state = { this.state = {
editing: false, editing: false,
value: props.value || "" value: props.value || ''
}; }
} }
static getDerivedStateFromProps(props: Readonly<InputStringProps>, state: InputStringState) { static getDerivedStateFromProps(props: Readonly<InputStringProps>, state: InputStringState) {
@@ -47,17 +47,17 @@ export default class InputString extends React.Component<InputStringProps, Input
let classes; let classes;
if(this.props.multi) { if(this.props.multi) {
tag = "textarea"; tag = "textarea"
classes = [ classes = [
"maputnik-string", "maputnik-string",
"maputnik-string--multi" "maputnik-string--multi"
]; ]
} }
else { else {
tag = "input"; tag = "input"
classes = [ classes = [
"maputnik-string" "maputnik-string"
]; ]
} }
if(this.props.disabled) { if(this.props.disabled) {
+61 -37
View File
@@ -1,33 +1,57 @@
import React, { type JSX } from "react"; import React from 'react'
import InputString from "./InputString"; import InputString from './InputString'
import SmallError from "./SmallError"; import SmallError from './SmallError'
import { Trans, type WithTranslation, withTranslation } from "react-i18next"; import { Trans, WithTranslation, withTranslation } from 'react-i18next';
import { type TFunction } from "i18next"; import { TFunction } from 'i18next';
import { ErrorType, validate } from "../libs/urlopen";
function errorTypeToJsx(errorType: ErrorType | undefined, t: TFunction): JSX.Element | undefined { function validate(url: string, t: TFunction): JSX.Element | undefined {
switch (errorType) { if (url === "") {
case ErrorType.EmptyHttpsProtocol: return;
return ( }
let error;
const getProtocol = (url: string) => {
try {
const urlObj = new URL(url);
return urlObj.protocol;
}
catch (_err) {
return undefined;
}
};
const protocol = getProtocol(url);
const isSsl = window.location.protocol === "https:";
if (!protocol) {
if (isSsl) {
error = (
<SmallError> <SmallError>
<Trans t={t}>Must provide protocol: <code>https://</code></Trans> <Trans t={t}>Must provide protocol: <code>https://</code></Trans>
</SmallError> </SmallError>
); );
case ErrorType.EmptyHttpOrHttpsProtocol: } else {
return ( error = (
<SmallError> <SmallError>
<Trans t={t}>Must provide protocol: <code>http://</code> or <code>https://</code></Trans> <Trans t={t}>Must provide protocol: <code>http://</code> or <code>https://</code></Trans>
</SmallError> </SmallError>
); );
case ErrorType.CorsError: }
return (
<SmallError>
<Trans t={t}>CORS policy won&apos;t allow fetching resources served over http from https, use a <code>https://</code> domain</Trans>
</SmallError>
);
default:
return undefined;
} }
else if (
protocol &&
protocol === "http:" &&
window.location.protocol === "https:"
) {
error = (
<SmallError>
<Trans t={t}>
CORS policy won&apos;t allow fetching resources served over http from https, use a <code>https://</code> domain
</Trans>
</SmallError>
);
}
return error;
} }
export type FieldUrlProps = { export type FieldUrlProps = {
@@ -39,42 +63,42 @@ export type FieldUrlProps = {
onInput?(...args: unknown[]): unknown onInput?(...args: unknown[]): unknown
multi?: boolean multi?: boolean
required?: boolean required?: boolean
"aria-label"?: string 'aria-label'?: string
type?: string type?: string
className?: string className?: string
}; };
type InputUrlInternalProps = FieldUrlProps & WithTranslation; type FieldUrlInternalProps = FieldUrlProps & WithTranslation;
type InputUrlState = { type FieldUrlState = {
error?: ErrorType error?: React.ReactNode
}; }
class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlState> { class FieldUrlInternal extends React.Component<FieldUrlInternalProps, FieldUrlState> {
static defaultProps = { static defaultProps = {
onInput: () => {}, onInput: () => {},
}; }
constructor (props: InputUrlInternalProps) { constructor (props: FieldUrlInternalProps) {
super(props); super(props);
this.state = { this.state = {
error: validate(props.value), error: validate(props.value, props.t),
}; };
} }
onInput = (url: string) => { onInput = (url: string) => {
this.setState({ this.setState({
error: validate(url), error: validate(url, this.props.t),
}); });
if (this.props.onInput) this.props.onInput(url); if (this.props.onInput) this.props.onInput(url);
}; }
onChange = (url: string) => { onChange = (url: string) => {
this.setState({ this.setState({
error: validate(url), error: validate(url, this.props.t),
}); });
this.props.onChange(url); this.props.onChange(url);
}; }
render () { render () {
return ( return (
@@ -83,13 +107,13 @@ class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlSt
{...this.props} {...this.props}
onInput={this.onInput} onInput={this.onInput}
onChange={this.onChange} onChange={this.onChange}
aria-label={this.props["aria-label"]} aria-label={this.props['aria-label']}
/> />
{errorTypeToJsx(this.state.error, this.props.t)} {this.state.error}
</div> </div>
); );
} }
} }
const InputUrl = withTranslation()(InputUrlInternal); const FieldUrl = withTranslation()(FieldUrlInternal);
export default InputUrl; export default FieldUrl;
+230 -288
View File
@@ -1,199 +1,155 @@
import React, { type JSX } from "react"; import React, {type JSX} from 'react'
import { Wrapper, Button, Menu, MenuItem } from "react-aria-menubutton"; import PropTypes from 'prop-types'
import { Accordion } from "react-accessible-accordion"; import { Wrapper, Button, Menu, MenuItem } from 'react-aria-menubutton'
import { MdMoreVert } from "react-icons/md"; import {Accordion} from 'react-accessible-accordion';
import { IconContext } from "react-icons"; import {MdMoreVert} from 'react-icons/md'
import { type BackgroundLayerSpecification, type LayerSpecification, type SourceSpecification } from "maplibre-gl"; import {BackgroundLayerSpecification, LayerSpecification, SourceSpecification} from 'maplibre-gl';
import { v8 } from "@maplibre/maplibre-gl-style-spec";
import FieldJson from "./FieldJson"; import FieldJson from './FieldJson'
import FilterEditor from "./FilterEditor"; import FilterEditor from './FilterEditor'
import PropertyGroup from "./PropertyGroup"; import PropertyGroup from './PropertyGroup'
import LayerEditorGroup from "./LayerEditorGroup"; import LayerEditorGroup from './LayerEditorGroup'
import FieldType from "./FieldType"; import FieldType from './FieldType'
import FieldId from "./FieldId"; import FieldId from './FieldId'
import FieldMinZoom from "./FieldMinZoom"; import FieldMinZoom from './FieldMinZoom'
import FieldMaxZoom from "./FieldMaxZoom"; import FieldMaxZoom from './FieldMaxZoom'
import FieldComment from "./FieldComment"; import FieldComment from './FieldComment'
import FieldSource from "./FieldSource"; import FieldSource from './FieldSource'
import FieldSourceLayer from "./FieldSourceLayer"; import FieldSourceLayer from './FieldSourceLayer'
import { changeType, changeProperty } from "../libs/layer"; import { changeType, changeProperty } from '../libs/layer'
import { formatLayerId } from "../libs/format"; import layout from '../config/layout.json'
import { type WithTranslation, withTranslation } from "react-i18next"; import {formatLayerId} from '../libs/format';
import { type TFunction } from "i18next"; import { WithTranslation, withTranslation } from 'react-i18next';
import { NON_SOURCE_LAYERS } from "../libs/non-source-layers"; import { TFunction } from 'i18next';
import { type MappedError, type MappedLayerErrors, type OnMoveLayerCallback } from "../libs/definitions";
type MaputnikLayoutGroup = {
id: string;
title: string;
type: string;
fields: string[];
};
function getLayoutForSymbolType(t: TFunction): MaputnikLayoutGroup[] { function getLayoutForType(type: LayerSpecification["type"], t: TFunction) {
const groups: MaputnikLayoutGroup[] = []; return layout[type] ? {
groups.push({ ...layout[type],
title: t("General layout properties"), groups: layout[type].groups.map(group => {
id: "General_layout_properties", return {
type: "properties", ...group,
fields: Object.keys(v8["layout_symbol"]).filter(f => f.startsWith("symbol-")) id: group.title.replace(/ /g, "_"),
}); title: t(group.title)
groups.push({ };
title: t("Text layout properties"), }),
id: "Text_layout_properties", } : layout.invalid;
type: "properties",
fields: Object.keys(v8["layout_symbol"]).filter(f => f.startsWith("text-"))
});
groups.push({
title: t("Icon layout properties"),
id: "Icon_layout_properties",
type: "properties",
fields: Object.keys(v8["layout_symbol"]).filter(f => f.startsWith("icon-"))
});
groups.push({
title: t("Text paint properties"),
id: "Text_paint_properties",
type: "properties",
fields: Object.keys(v8["paint_symbol"]).filter(f => f.startsWith("text-"))
});
groups.push({
title: t("Icon paint properties"),
id: "Icon_paint_properties",
type: "properties",
fields: Object.keys(v8["paint_symbol"]).filter(f => f.startsWith("icon-"))
});
return groups;
} }
function getLayoutForType(type: LayerSpecification["type"], t: TFunction): MaputnikLayoutGroup[] { function layoutGroups(layerType: LayerSpecification["type"], t: TFunction): {id: string, title: string, type: string, fields?: string[]}[] {
if (Object.keys(v8.layer.type.values).indexOf(type) < 0) {
return [];
}
if (type === "symbol") {
return getLayoutForSymbolType(t);
}
const groups: MaputnikLayoutGroup[] = [];
if (Object.keys(v8["paint_" + type]).length > 0) {
groups.push({
title: t("Paint properties"),
id: "Paint_properties",
type: "properties",
fields: Object.keys(v8["paint_" + type]),
});
}
if (Object.keys(v8["layout_" + type]).length > 0) {
groups.push({
title: t("Layout properties"),
id: "Layout_properties",
type: "properties",
fields: Object.keys(v8["layout_" + type])
});
}
return groups;
}
function layoutGroups(layerType: LayerSpecification["type"], t: TFunction): { id: string, title: string, type: string, fields?: string[] }[] {
const layerGroup = { const layerGroup = {
id: "layer", id: 'layer',
title: t("Layer"), title: t('Layer'),
type: "layer" type: 'layer'
}; }
const filterGroup = { const filterGroup = {
id: "filter", id: 'filter',
title: t("Filter"), title: t('Filter'),
type: "filter" type: 'filter'
}; }
const editorGroup = { const editorGroup = {
id: "jsoneditor", id: 'jsoneditor',
title: t("JSON Editor"), title: t('JSON Editor'),
type: "jsoneditor" type: 'jsoneditor'
}; }
return [layerGroup, filterGroup] return [layerGroup, filterGroup]
.concat(getLayoutForType(layerType, t)) .concat(getLayoutForType(layerType, t).groups)
.concat([editorGroup]); .concat([editorGroup])
} }
type LayerEditorInternalProps = { type LayerEditorInternalProps = {
layer: LayerSpecification layer: LayerSpecification
sources: { [key: string]: SourceSpecification & { layers: string[] } } sources: {[key: string]: SourceSpecification}
vectorLayers: { [key: string]: any } vectorLayers: {[key: string]: any}
spec: any spec: object
onLayerChanged(index: number, layer: LayerSpecification): void onLayerChanged(...args: unknown[]): unknown
onLayerIdChange(...args: unknown[]): unknown onLayerIdChange(...args: unknown[]): unknown
onMoveLayer: OnMoveLayerCallback onMoveLayer(...args: unknown[]): unknown
onLayerDestroy(...args: unknown[]): unknown onLayerDestroy(...args: unknown[]): unknown
onLayerCopy(...args: unknown[]): unknown onLayerCopy(...args: unknown[]): unknown
onLayerVisibilityToggle(...args: unknown[]): unknown onLayerVisibilityToggle(...args: unknown[]): unknown
isFirstLayer?: boolean isFirstLayer?: boolean
isLastLayer?: boolean isLastLayer?: boolean
layerIndex: number layerIndex: number
errors?: MappedError[] errors?: any[]
} & WithTranslation; } & WithTranslation;
type LayerEditorState = { type LayerEditorState = {
editorGroups: { [keys: string]: boolean } editorGroups: {[keys:string]: boolean}
}; };
/** Layer editor supporting multiple types of layers. */ /** Layer editor supporting multiple types of layers. */
class LayerEditorInternal extends React.Component<LayerEditorInternalProps, LayerEditorState> { class LayerEditorInternal extends React.Component<LayerEditorInternalProps, LayerEditorState> {
static defaultProps = { static defaultProps = {
onLayerChanged: () => { }, onLayerChanged: () => {},
onLayerIdChange: () => { }, onLayerIdChange: () => {},
onLayerDestroyed: () => { }, onLayerDestroyed: () => {},
}; }
static childContextTypes = {
reactIconBase: PropTypes.object
}
constructor(props: LayerEditorInternalProps) { constructor(props: LayerEditorInternalProps) {
super(props); super(props)
const editorGroups: { [keys: string]: boolean } = {}; //TODO: Clean this up and refactor into function
for (const group of layoutGroups(this.props.layer.type, props.t)) { const editorGroups: {[keys:string]: boolean} = {}
editorGroups[group.title] = true; layoutGroups(this.props.layer.type, props.t).forEach(group => {
} editorGroups[group.title] = true
})
this.state = { editorGroups }; this.state = { editorGroups }
} }
static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) { static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) {
const additionalGroups = { ...state.editorGroups }; const additionalGroups = { ...state.editorGroups }
for (const group of getLayoutForType(props.layer.type, props.t)) { getLayoutForType(props.layer.type, props.t).groups.forEach(group => {
if (!(group.title in additionalGroups)) { if(!(group.title in additionalGroups)) {
additionalGroups[group.title] = true; additionalGroups[group.title] = true
} }
} })
return { return {
editorGroups: additionalGroups editorGroups: additionalGroups
}; };
} }
getChildContext () {
return {
reactIconBase: {
size: 14,
color: '#8e8e8e',
}
}
}
changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) { changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
this.props.onLayerChanged( this.props.onLayerChanged(
this.props.layerIndex, this.props.layerIndex,
changeProperty(this.props.layer, group, property, newValue) changeProperty(this.props.layer, group, property, newValue)
); )
} }
onGroupToggle(groupTitle: string, active: boolean) { onGroupToggle(groupTitle: string, active: boolean) {
const changedActiveGroups = { const changedActiveGroups = {
...this.state.editorGroups, ...this.state.editorGroups,
[groupTitle]: active, [groupTitle]: active,
}; }
this.setState({ this.setState({
editorGroups: changedActiveGroups editorGroups: changedActiveGroups
}); })
} }
renderGroupType(type: string, fields?: string[]): JSX.Element { renderGroupType(type: string, fields?: string[]): JSX.Element {
let comment = ""; let comment = ""
if (this.props.layer.metadata) { if(this.props.layer.metadata) {
comment = (this.props.layer.metadata as any)["maputnik:comment"]; comment = (this.props.layer.metadata as any)['maputnik:comment']
} }
const { errors, layerIndex } = this.props; const {errors, layerIndex} = this.props;
const errorData: MappedLayerErrors = {}; const errorData: {[key in LayerSpecification as string]: {message: string}} = {};
errors!.forEach(error => { errors!.forEach(error => {
if ( if (
error.parsed && error.parsed &&
@@ -204,109 +160,108 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
message: error.parsed.data.message message: error.parsed.data.message
}; };
} }
}); })
let sourceLayerIds; let sourceLayerIds;
const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>; const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
if (Object.prototype.hasOwnProperty.call(this.props.sources, layer.source)) { if(Object.prototype.hasOwnProperty.call(this.props.sources, layer.source)) {
sourceLayerIds = this.props.sources[layer.source].layers; sourceLayerIds = (this.props.sources[layer.source] as any).layers;
} }
switch (type) { switch(type) {
case "layer": return <div> case 'layer': return <div>
<FieldId <FieldId
value={this.props.layer.id} value={this.props.layer.id}
wdKey="layer-editor.layer-id" wdKey="layer-editor.layer-id"
error={errorData.id} error={errorData.id}
onChange={newId => this.props.onLayerIdChange(this.props.layerIndex, this.props.layer.id, newId)} onChange={newId => this.props.onLayerIdChange(this.props.layerIndex, this.props.layer.id, newId)}
/>
<FieldType
disabled={true}
error={errorData.type}
value={this.props.layer.type}
onChange={newType => this.props.onLayerChanged(
this.props.layerIndex,
changeType(this.props.layer, newType)
)}
/>
{this.props.layer.type !== 'background' && <FieldSource
error={errorData.source}
sourceIds={Object.keys(this.props.sources!)}
value={this.props.layer.source}
onChange={v => this.changeProperty(null, 'source', v)}
/>
}
{['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.props.layer.type) < 0 &&
<FieldSourceLayer
error={errorData['source-layer']}
sourceLayerIds={sourceLayerIds}
value={(this.props.layer as any)['source-layer']}
onChange={v => this.changeProperty(null, 'source-layer', v)}
/> />
<FieldType }
disabled={true} <FieldMinZoom
error={errorData.type} error={errorData.minzoom}
value={this.props.layer.type} value={this.props.layer.minzoom}
onChange={newType => this.props.onLayerChanged( onChange={v => this.changeProperty(null, 'minzoom', v)}
this.props.layerIndex, />
changeType(this.props.layer, newType) <FieldMaxZoom
)} error={errorData.maxzoom}
/> value={this.props.layer.maxzoom}
{this.props.layer.type !== "background" && <FieldSource onChange={v => this.changeProperty(null, 'maxzoom', v)}
error={errorData.source} />
sourceIds={Object.keys(this.props.sources!)} <FieldComment
value={this.props.layer.source} error={errorData.comment}
onChange={v => this.changeProperty(null, "source", v)} value={comment}
/> onChange={v => this.changeProperty('metadata', 'maputnik:comment', v == "" ? undefined : v)}
} />
{!NON_SOURCE_LAYERS.includes(this.props.layer.type) && </div>
<FieldSourceLayer case 'filter': return <div>
error={errorData["source-layer"]} <div className="maputnik-filter-editor-wrapper">
sourceLayerIds={sourceLayerIds} <FilterEditor
value={(this.props.layer as any)["source-layer"]}
onChange={v => this.changeProperty(null, "source-layer", v)}
/>
}
<FieldMinZoom
error={errorData.minzoom}
value={this.props.layer.minzoom}
onChange={v => this.changeProperty(null, "minzoom", v)}
/>
<FieldMaxZoom
error={errorData.maxzoom}
value={this.props.layer.maxzoom}
onChange={v => this.changeProperty(null, "maxzoom", v)}
/>
<FieldComment
error={errorData.comment}
value={comment}
onChange={v => this.changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)}
/>
</div>;
case "filter": return <div>
<div className="maputnik-filter-editor-wrapper">
<FilterEditor
errors={errorData}
filter={(this.props.layer as any).filter}
properties={this.props.vectorLayers[(this.props.layer as any)["source-layer"]]}
onChange={f => this.changeProperty(null, "filter", f)}
/>
</div>
</div>;
case "properties":
return <PropertyGroup
errors={errorData} errors={errorData}
layer={this.props.layer} filter={(this.props.layer as any).filter}
groupFields={fields!} properties={this.props.vectorLayers[(this.props.layer as any)['source-layer']]}
spec={this.props.spec} onChange={f => this.changeProperty(null, 'filter', f)}
onChange={this.changeProperty.bind(this)} />
/>; </div>
case "jsoneditor": </div>
return <FieldJson case 'properties':
lintType="layer" return <PropertyGroup
value={this.props.layer} errors={errorData}
onChange={(layer: LayerSpecification) => { layer={this.props.layer}
this.props.onLayerChanged( groupFields={fields!}
this.props.layerIndex, spec={this.props.spec}
layer onChange={this.changeProperty.bind(this)}
); />
}} case 'jsoneditor':
/>; return <FieldJson
default: return <></>; layer={this.props.layer}
onChange={(layer) => {
this.props.onLayerChanged(
this.props.layerIndex,
layer
);
}}
/>
default: return <></>
} }
} }
moveLayer(offset: number) { moveLayer(offset: number) {
this.props.onMoveLayer({ this.props.onMoveLayer({
oldIndex: this.props.layerIndex, oldIndex: this.props.layerIndex,
newIndex: this.props.layerIndex + offset newIndex: this.props.layerIndex+offset
}); })
} }
render() { render() {
const t = this.props.t; const t = this.props.t;
const groupIds: string[] = []; const groupIds: string[] = [];
const layerType = this.props.layer.type; const layerType = this.props.layer.type
const groups = layoutGroups(layerType, t).filter(group => { const groups = layoutGroups(layerType, t).filter(group => {
return !(layerType === "background" && group.type === "source"); return !(layerType === 'background' && group.type === 'source')
}).map(group => { }).map(group => {
const groupId = group.id; const groupId = group.id;
groupIds.push(groupId); groupIds.push(groupId);
@@ -319,103 +274,90 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
onActiveToggle={this.onGroupToggle.bind(this, group.title)} onActiveToggle={this.onGroupToggle.bind(this, group.title)}
> >
{this.renderGroupType(group.type, group.fields)} {this.renderGroupType(group.type, group.fields)}
</LayerEditorGroup>; </LayerEditorGroup>
}); })
const layout = this.props.layer.layout || {}; const layout = this.props.layer.layout || {}
const items: { const items: {[key: string]: {text: string, handler: () => void, disabled?: boolean}} = {
[key: string]: {
text: string,
handler: () => void,
disabled?: boolean,
wdKey?: string
}
} = {
delete: { delete: {
text: t("Delete"), text: t("Delete"),
handler: () => this.props.onLayerDestroy(this.props.layerIndex), handler: () => this.props.onLayerDestroy(this.props.layerIndex)
wdKey: "menu-delete-layer"
}, },
duplicate: { duplicate: {
text: t("Duplicate"), text: t("Duplicate"),
handler: () => this.props.onLayerCopy(this.props.layerIndex), handler: () => this.props.onLayerCopy(this.props.layerIndex)
wdKey: "menu-duplicate-layer"
}, },
hide: { hide: {
text: (layout.visibility === "none") ? t("Show") : t("Hide"), text: (layout.visibility === "none") ? t("Show") : t("Hide"),
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex), handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex)
wdKey: "menu-hide-layer"
}, },
moveLayerUp: { moveLayerUp: {
text: t("Move layer up"), text: t("Move layer up"),
// Not actually used...
disabled: this.props.isFirstLayer, disabled: this.props.isFirstLayer,
handler: () => this.moveLayer(-1), handler: () => this.moveLayer(-1)
wdKey: "menu-move-layer-up"
}, },
moveLayerDown: { moveLayerDown: {
text: t("Move layer down"), text: t("Move layer down"),
// Not actually used...
disabled: this.props.isLastLayer, disabled: this.props.isLastLayer,
handler: () => this.moveLayer(+1), handler: () => this.moveLayer(+1)
wdKey: "menu-move-layer-down"
} }
}; }
function handleSelection(id: string, event: React.SyntheticEvent) { function handleSelection(id: string, event: React.SyntheticEvent) {
event.stopPropagation(); event.stopPropagation();
items[id].handler(); items[id].handler();
} }
return <IconContext.Provider value={{ size: "14px", color: "#8e8e8e" }}> return <section className="maputnik-layer-editor"
<section className="maputnik-layer-editor" role="main"
role="main" aria-label={t("Layer editor")}
aria-label={t("Layer editor")} >
data-wd-key="layer-editor" <header>
> <div className="layer-header">
<header data-wd-key="layer-editor.header"> <h2 className="layer-header__title">
<div className="layer-header"> {t("Layer: {{layerId}}", { layerId: formatLayerId(this.props.layer.id) })}
<h2 className="layer-header__title"> </h2>
{t("Layer")}: {formatLayerId(this.props.layer.id)} <div className="layer-header__info">
</h2> <Wrapper
<div className="layer-header__info"> className='more-menu'
<Wrapper onSelection={handleSelection}
className='more-menu' closeOnSelection={false}
onSelection={(id, event) => handleSelection(id as string, event)} >
closeOnSelection={false} <Button
> id="skip-target-layer-editor"
<Button data-wd-key="skip-target-layer-editor"
id="skip-target-layer-editor" className='more-menu__button'
data-wd-key="skip-target-layer-editor" title={"Layer options"}>
className='more-menu__button' <MdMoreVert className="more-menu__button__svg" />
title={"Layer options"}> </Button>
<MdMoreVert className="more-menu__button__svg" /> <Menu>
</Button> <ul className="more-menu__menu">
<Menu> {Object.keys(items).map((id) => {
<ul className="more-menu__menu"> const item = items[id];
{Object.keys(items).map((id) => { return <li key={id}>
const item = items[id]; <MenuItem value={id} className='more-menu__menu__item'>
return <li key={id}> {item.text}
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}> </MenuItem>
{item.text} </li>
</MenuItem> })}
</li>; </ul>
})} </Menu>
</ul> </Wrapper>
</Menu>
</Wrapper>
</div>
</div> </div>
</div>
</header> </header>
<Accordion <Accordion
allowMultipleExpanded={true} allowMultipleExpanded={true}
allowZeroExpanded={true} allowZeroExpanded={true}
preExpanded={groupIds} preExpanded={groupIds}
> >
{groups} {groups}
</Accordion> </Accordion>
</section> </section>
</IconContext.Provider>;
} }
} }
+19 -7
View File
@@ -1,11 +1,15 @@
import React from "react"; import React from 'react'
import {MdArrowDropDown, MdArrowDropUp} from "react-icons/md"; import Icon from '@mdi/react'
import {
mdiMenuDown,
mdiMenuUp
} from '@mdi/js';
import { import {
AccordionItem, AccordionItem,
AccordionItemHeading, AccordionItemHeading,
AccordionItemButton, AccordionItemButton,
AccordionItemPanel, AccordionItemPanel,
} from "react-accessible-accordion"; } from 'react-accessible-accordion';
type LayerEditorGroupProps = { type LayerEditorGroupProps = {
@@ -26,14 +30,22 @@ export default class LayerEditorGroup extends React.Component<LayerEditorGroupPr
onClick={_e => this.props.onActiveToggle(!this.props.isActive)} onClick={_e => this.props.onActiveToggle(!this.props.isActive)}
> >
<AccordionItemButton className="maputnik-layer-editor-group__button"> <AccordionItemButton className="maputnik-layer-editor-group__button">
<span style={{flexGrow: 1, alignContent: "center"}}>{this.props.title}</span> <span style={{flexGrow: 1}}>{this.props.title}</span>
<MdArrowDropUp size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--up"></MdArrowDropUp> <Icon
<MdArrowDropDown size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"></MdArrowDropDown> path={mdiMenuUp}
size={1}
className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--up"
/>
<Icon
path={mdiMenuDown}
size={1}
className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"
/>
</AccordionItemButton> </AccordionItemButton>
</AccordionItemHeading> </AccordionItemHeading>
<AccordionItemPanel> <AccordionItemPanel>
{this.props.children} {this.props.children}
</AccordionItemPanel> </AccordionItemPanel>
</AccordionItem>; </AccordionItem>
} }
} }
+76 -103
View File
@@ -1,39 +1,27 @@
import React, {type JSX} from "react"; import React, {type JSX} from 'react'
import classnames from "classnames"; import classnames from 'classnames'
import lodash from "lodash"; import lodash from 'lodash';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import LayerListGroup from "./LayerListGroup"; import LayerListGroup from './LayerListGroup'
import LayerListItem from "./LayerListItem"; import LayerListItem from './LayerListItem'
import ModalAdd from "./modals/ModalAdd"; import ModalAdd from './ModalAdd'
import type {LayerSpecification, SourceSpecification} from "maplibre-gl"; import {SortEndHandler, SortableContainer} from 'react-sortable-hoc';
import generateUniqueId from "../libs/document-uid"; import type {LayerSpecification} from 'maplibre-gl';
import { findClosestCommonPrefix, layerPrefix } from "../libs/layer"; import generateUniqueId from '../libs/document-uid';
import { type WithTranslation, withTranslation } from "react-i18next"; import { findClosestCommonPrefix, layerPrefix } from '../libs/layer';
import { type MappedError, type OnMoveLayerCallback } from "../libs/definitions"; import { WithTranslation, withTranslation } from 'react-i18next';
type LayerListContainerProps = { type LayerListContainerProps = {
layers: LayerSpecification[] layers: LayerSpecification[]
selectedLayerIndex: number selectedLayerIndex: number
onLayersChange(layers: LayerSpecification[]): unknown onLayersChange(layers: LayerSpecification[]): unknown
onLayerSelect(index: number): void; onLayerSelect(...args: unknown[]): unknown
onLayerDestroy?(...args: unknown[]): unknown onLayerDestroy?(...args: unknown[]): unknown
onLayerCopy(...args: unknown[]): unknown onLayerCopy(...args: unknown[]): unknown
onLayerVisibilityToggle(...args: unknown[]): unknown onLayerVisibilityToggle(...args: unknown[]): unknown
sources: Record<string, SourceSpecification & {layers: string[]}>; sources: object
errors: MappedError[] errors: any[]
}; };
type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation; type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation;
@@ -48,9 +36,9 @@ type LayerListContainerState = {
class LayerListContainerInternal extends React.Component<LayerListContainerInternalProps, LayerListContainerState> { class LayerListContainerInternal extends React.Component<LayerListContainerInternalProps, LayerListContainerState> {
static defaultProps = { static defaultProps = {
onLayerSelect: () => {}, onLayerSelect: () => {},
}; }
selectedItemRef: React.RefObject<any>; selectedItemRef: React.RefObject<any>;
scrollContainerRef: React.RefObject<HTMLElement | null>; scrollContainerRef: React.RefObject<HTMLElement>;
constructor(props: LayerListContainerInternalProps) { constructor(props: LayerListContainerInternalProps) {
super(props); super(props);
@@ -65,7 +53,7 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
isOpen: { isOpen: {
add: false, add: false,
} }
}; }
} }
toggleModal(modalName: string) { toggleModal(modalName: string) {
@@ -78,74 +66,74 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
...this.state.isOpen, ...this.state.isOpen,
[modalName]: !this.state.isOpen[modalName] [modalName]: !this.state.isOpen[modalName]
} }
}); })
} }
toggleLayers = () => { toggleLayers = () => {
let idx = 0; let idx = 0
const newGroups: {[key:string]: boolean} = {}; const newGroups: {[key:string]: boolean} = {}
this.groupedLayers().forEach(layers => { this.groupedLayers().forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id); const groupPrefix = layerPrefix(layers[0].id)
const lookupKey = [groupPrefix, idx].join("-"); const lookupKey = [groupPrefix, idx].join('-')
if (layers.length > 1) { if (layers.length > 1) {
newGroups[lookupKey] = this.state.areAllGroupsExpanded; newGroups[lookupKey] = this.state.areAllGroupsExpanded
} }
layers.forEach((_layer) => { layers.forEach((_layer) => {
idx += 1; idx += 1
}); })
}); });
this.setState({ this.setState({
collapsedGroups: newGroups, collapsedGroups: newGroups,
areAllGroupsExpanded: !this.state.areAllGroupsExpanded areAllGroupsExpanded: !this.state.areAllGroupsExpanded
}); })
}; }
groupedLayers(): (LayerSpecification & {key: string})[][] { groupedLayers(): (LayerSpecification & {key: string})[][] {
const groups = []; const groups = []
const layerIdCount = new Map(); const layerIdCount = new Map();
for (let i = 0; i < this.props.layers.length; i++) { for (let i = 0; i < this.props.layers.length; i++) {
const origLayer = this.props.layers[i]; const origLayer = this.props.layers[i];
const previousLayer = this.props.layers[i-1]; const previousLayer = this.props.layers[i-1]
layerIdCount.set(origLayer.id, layerIdCount.set(origLayer.id,
layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0 layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0
); );
const layer = { const layer = {
...origLayer, ...origLayer,
key: `layers-list-${origLayer.id}-${layerIdCount.get(origLayer.id)}`, key: `layers-list-${origLayer.id}-${layerIdCount.get(origLayer.id)}`,
}; }
if(previousLayer && layerPrefix(previousLayer.id) == layerPrefix(layer.id)) { if(previousLayer && layerPrefix(previousLayer.id) == layerPrefix(layer.id)) {
const lastGroup = groups[groups.length - 1]; const lastGroup = groups[groups.length - 1]
lastGroup.push(layer); lastGroup.push(layer)
} else { } else {
groups.push([layer]); groups.push([layer])
} }
} }
return groups; return groups
} }
toggleLayerGroup(groupPrefix: string, idx: number) { toggleLayerGroup(groupPrefix: string, idx: number) {
const lookupKey = [groupPrefix, idx].join("-"); const lookupKey = [groupPrefix, idx].join('-')
const newGroups = { ...this.state.collapsedGroups }; const newGroups = { ...this.state.collapsedGroups }
if(lookupKey in this.state.collapsedGroups) { if(lookupKey in this.state.collapsedGroups) {
newGroups[lookupKey] = !this.state.collapsedGroups[lookupKey]; newGroups[lookupKey] = !this.state.collapsedGroups[lookupKey]
} else { } else {
newGroups[lookupKey] = false; newGroups[lookupKey] = false
} }
this.setState({ this.setState({
collapsedGroups: newGroups collapsedGroups: newGroups
}); })
} }
isCollapsed(groupPrefix: string, idx: number) { isCollapsed(groupPrefix: string, idx: number) {
const collapsed = this.state.collapsedGroups[[groupPrefix, idx].join("-")]; const collapsed = this.state.collapsedGroups[[groupPrefix, idx].join('-')]
return collapsed === undefined ? true : collapsed; return collapsed === undefined ? true : collapsed
} }
shouldComponentUpdate (nextProps: LayerListContainerProps, nextState: LayerListContainerState) { shouldComponentUpdate (nextProps: LayerListContainerProps, nextState: LayerListContainerState) {
@@ -177,7 +165,7 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
const out = { const out = {
...props ...props
} as LayerListContainerProps & { layers?: any }; } as LayerListContainerProps & { layers?: any };
delete out["layers"]; delete out['layers'];
return out; return out;
} }
@@ -200,7 +188,7 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
const options = { const options = {
root: this.scrollContainerRef.current, root: this.scrollContainerRef.current,
threshold: 1.0 threshold: 1.0
}; }
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
observer.unobserve(target); observer.unobserve(target);
if (entries.length > 0 && entries[0].intersectionRatio < 1) { if (entries.length > 0 && entries[0].intersectionRatio < 1) {
@@ -215,25 +203,25 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
render() { render() {
const listItems: JSX.Element[] = []; const listItems: JSX.Element[] = []
let idx = 0; let idx = 0
const layersByGroup = this.groupedLayers(); const layersByGroup = this.groupedLayers();
layersByGroup.forEach(layers => { layersByGroup.forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id); const groupPrefix = layerPrefix(layers[0].id)
if(layers.length > 1) { if(layers.length > 1) {
const grp = <LayerListGroup const grp = <LayerListGroup
data-wd-key={[groupPrefix, idx].join("-")} data-wd-key={[groupPrefix, idx].join('-')}
aria-controls={layers.map(l => l.key).join(" ")} aria-controls={layers.map(l => l.key).join(" ")}
key={`group-${groupPrefix}-${idx}`} key={`group-${groupPrefix}-${idx}`}
title={groupPrefix} title={groupPrefix}
isActive={!this.isCollapsed(groupPrefix, idx) || idx === this.props.selectedLayerIndex} isActive={!this.isCollapsed(groupPrefix, idx) || idx === this.props.selectedLayerIndex}
onActiveToggle={this.toggleLayerGroup.bind(this, groupPrefix, idx)} onActiveToggle={this.toggleLayerGroup.bind(this, groupPrefix, idx)}
/>; />
listItems.push(grp); listItems.push(grp)
} }
layers.forEach((layer, idxInGroup) => { layers.forEach((layer, idxInGroup) => {
const groupIdx = findClosestCommonPrefix(this.props.layers, idx); const groupIdx = findClosestCommonPrefix(this.props.layers, idx)
const layerError = this.props.errors.find(error => { const layerError = this.props.errors.find(error => {
return ( return (
@@ -250,10 +238,11 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
const listItem = <LayerListItem const listItem = <LayerListItem
className={classnames({ className={classnames({
"maputnik-layer-list-item-collapsed": layers.length > 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex, 'maputnik-layer-list-item-collapsed': layers.length > 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex,
"maputnik-layer-list-item-group-last": idxInGroup == layers.length - 1 && layers.length > 1, 'maputnik-layer-list-item-group-last': idxInGroup == layers.length - 1 && layers.length > 1,
"maputnik-layer-list-item--error": !!layerError 'maputnik-layer-list-item--error': !!layerError
})} })}
index={idx}
key={layer.key} key={layer.key}
id={layer.key} id={layer.key}
layerId={layer.id} layerId={layer.id}
@@ -266,17 +255,16 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
onLayerCopy={this.props.onLayerCopy.bind(this)} onLayerCopy={this.props.onLayerCopy.bind(this)}
onLayerVisibilityToggle={this.props.onLayerVisibilityToggle.bind(this)} onLayerVisibilityToggle={this.props.onLayerVisibilityToggle.bind(this)}
{...additionalProps} {...additionalProps}
/>; />
listItems.push(listItem); listItems.push(listItem)
idx += 1; idx += 1
}); })
}); })
const t = this.props.t; const t = this.props.t;
return <section return <section
className="maputnik-layer-list" className="maputnik-layer-list"
data-wd-key="layer-list"
role="complementary" role="complementary"
aria-label={t("Layers list")} aria-label={t("Layers list")}
ref={this.scrollContainerRef} ref={this.scrollContainerRef}
@@ -286,10 +274,10 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
layers={this.props.layers} layers={this.props.layers}
sources={this.props.sources} sources={this.props.sources}
isOpen={this.state.isOpen.add} isOpen={this.state.isOpen.add}
onOpenToggle={this.toggleModal.bind(this, "add")} onOpenToggle={this.toggleModal.bind(this, 'add')}
onLayersChange={this.props.onLayersChange} onLayersChange={this.props.onLayersChange}
/> />
<header className="maputnik-layer-list-header" data-wd-key="layer-list.header"> <header className="maputnik-layer-list-header">
<span className="maputnik-layer-list-header-title">{t("Layers")}</span> <span className="maputnik-layer-list-header-title">{t("Layers")}</span>
<span className="maputnik-space" /> <span className="maputnik-space" />
<div className="maputnik-default-property"> <div className="maputnik-default-property">
@@ -310,7 +298,7 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
<div className="maputnik-default-property"> <div className="maputnik-default-property">
<div className="maputnik-multibutton"> <div className="maputnik-multibutton">
<button <button
onClick={this.toggleModal.bind(this, "add")} onClick={this.toggleModal.bind(this, 'add')}
data-wd-key="layer-list:add-layer" data-wd-key="layer-list:add-layer"
className="maputnik-button maputnik-button-selected"> className="maputnik-button maputnik-button-selected">
{t("Add Layer")} {t("Add Layer")}
@@ -326,40 +314,25 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
{listItems} {listItems}
</ul> </ul>
</div> </div>
</section>; </section>
} }
} }
const LayerListContainer = withTranslation()(LayerListContainerInternal); const LayerListContainer = withTranslation()(LayerListContainerInternal);
const LayerListContainerSortable = SortableContainer((props: LayerListContainerProps) => <LayerListContainer {...props} />)
type LayerListProps = LayerListContainerProps & { type LayerListProps = LayerListContainerProps & {
onMoveLayer: OnMoveLayerCallback onMoveLayer: SortEndHandler
}; };
const LayerList: React.FC<LayerListProps> = (props) => { export default class LayerList extends React.Component<LayerListProps> {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); render() {
return <LayerListContainerSortable
const handleDragEnd = (event: DragEndEvent) => { {...this.props}
const {active, over} = event; helperClass='sortableHelper'
if (!over) return; onSortEnd={this.props.onMoveLayer.bind(this)}
useDragHandle={true}
const oldIndex = props.layers.findIndex(layer => layer.id === active.id); shouldCancelStart={() => false}
const newIndex = props.layers.findIndex(layer => layer.id === over.id); />
}
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { }
props.onMoveLayer({oldIndex, newIndex});
}
};
const layerIds = props.layers.map(layer => layer.id);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={layerIds} strategy={verticalListSortingStrategy}>
<LayerListContainer {...props} />
</SortableContext>
</DndContext>
);
};
export default LayerList;

Some files were not shown because too many files have changed in this diff Show More