Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 019d1c3dc9 chore(deps-dev): Bump vite from 7.3.2 to 8.1.3
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.2 to 8.1.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.3/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 13:54:03 +00:00
151 changed files with 10156 additions and 9105 deletions
+18 -29
View File
@@ -117,21 +117,17 @@ jobs:
with:
node-version-file: '.nvmrc'
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Playwright run
run: npm run test-e2e
- name: Cypress run
uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1
with:
build: npm run build
start: npm run start
browser: chrome
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ${{ github.workspace }}/coverage/coverage-final.json
files: ${{ github.workspace }}/.nyc_output/out.json
verbose: true
- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: playwright-report/
retention-days: 7
e2e-tests-docker:
name: "E2E tests using chrome and docker"
@@ -145,21 +141,14 @@ jobs:
with:
node-version-file: '.nvmrc'
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Build docker image
run: docker build -t maputnik .
- name: Start maputnik container
run: docker run -d --network host --name maputnik maputnik --port=8888
- name: Wait for maputnik to be ready
run: |
for i in $(seq 1 60); do
if curl -sSf http://localhost:8888/ > /dev/null; then
echo "maputnik is up"; exit 0
fi
sleep 1
done
echo "maputnik did not start in time"; docker logs maputnik; exit 1
- name: Playwright run
run: npm run test-e2e
env:
E2E_NO_WEBSERVER: "1"
- 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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ${{ github.workspace }}/.nyc_output/out.json
verbose: true
+1 -5
View File
@@ -33,14 +33,10 @@ node_modules
public
/errorShots
/old
/cypress/screenshots
/dist/
/desktop/version.go
# Playwright
/test-results/
/playwright-report/
/playwright/.cache/
# IDE
.vscode/
.idea/
+4 -5
View File
@@ -4,13 +4,12 @@
"check-coverage": false,
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [
"e2e/**/*.*",
"cypress/**/*.*",
"**/*.d.ts",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.browser.test.tsx",
"**/*.cy.tsx",
"**/*.cy.ts",
"./coverage/**",
"./e2e/**",
"./cypress/**",
"./dist/**",
"node_modules"
],
+6 -109
View File
@@ -24,127 +24,24 @@ The project type checked and built with:
npm run build
```
Install the Playwright browser (first time only):
To run the tests make sure that xvfb is installed:
```
npx playwright install --with-deps chromium
apt install xvfb
```
Then run the end-to-end tests (Playwright starts the dev server automatically):
Run the development server in the background with Vite:
```
npm run test
nohup npm run start &
```
Run the unit tests with Vitest:
Then start the Cypress tests with:
```
npm run test-unit
xvfb-run -a npm run test
```
## Pull Requests
- Pull requests should update `CHANGELOG.md` with a short description of the change.
## Testing
### Prefer end-to-end tests
Most of this codebase is React components, and they are only reachable from an
end-to-end test. E2E coverage is the primary signal.
Reach for a unit test only for pure logic that e2e cannot cheaply reach (parsers,
sorting, watchers, stores). Before writing one, check whether e2e already covers
the file — a unit test that duplicates existing e2e coverage adds test code and
almost no coverage:
```
npx nyc report --reporter=text --include="src/libs/style.ts"
```
Do **not** merge the Vitest (v8) and e2e (istanbul) coverage reports locally. They
produce conflicting statement maps for the same files and the combined percentage
is meaningless. Codecov merges the two uploads server-side; that is the number to
trust. Locally, read them separately:
- e2e: `npx playwright test` then `npx nyc report --reporter=text-summary` (reads `.nyc_output/`)
- unit: `npx vitest run --coverage` (writes `coverage/`)
### E2E layering
Three layers, and the boundaries matter:
- `e2e/playwright-helper.ts` — generic, app-agnostic browser actions. **The only
file allowed to import `@playwright/test`** (besides `e2e/utils/fixtures.ts`).
- `e2e/maputnik-driver.ts` — domain actions (layers, filters, functions, the
style). Knows nothing about `page` or Playwright.
- `e2e/modal-driver.ts` — actions scoped to a modal, exposed as `when.modal.*`.
Specs get a driver at describe scope and assert fluently:
```ts
describe("layer editor", () => {
const { given, get, when, then } = new MaputnikDriver();
...
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [{ id, type: "fill" }] });
});
```
New UI interactions belong in a driver, not inline in a spec.
### Writing assertions
- `shouldDeepNestedInclude` is a recursive partial match (`toMatchObject`): nested
objects are matched as subsets, arrays and primitives must match exactly
(including array length).
- Assert against the whole style, not an extracted slice. Avoid
`get.styleFromLocalStorage().then(style => style.layers.find(...))` — it moves
test logic into the test. Compare the real object instead.
- `Query.then()` is lazy and returns a new `Query`, **not** a Promise. `await
get.styleFromLocalStorage()` hangs forever. Use `.get()` to await it directly,
or pass the Query to `then(...)`.
### One behaviour per test
If a test needs comments narrating "and now this…", it is several tests. Split it,
and hoist the shared setup into a nested `describe` + `beforeEach`.
### Test ids
Test ids use the `data-wd-key` attribute and are read via `get.elementByTestId`.
The `Input*` components already accept `data-wd-key` and render it on the real
`<input>`; the `Field*` wrappers forward it through their `{...props}` spread. So
passing `data-wd-key` to a `Field*` component is usually enough. Do **not** also
add it to `Block`/`Fieldset` — the id then matches two elements and locators fail
in strict mode.
Note `InputNumber` renders `<key>-text` and `<key>-range` when `allowRange` is set,
and `<key>` otherwise.
### Input commit semantics (common source of "the value didn't save")
- `InputString` only fires its `onChange` on **blur** or **Enter**. Typing alone
fires `onInput`. A driver that calls `fill()` must then call `blur()`, or the
value never reaches the style.
- `InputNumber` commits on every change; no blur needed.
- The autocomplete inputs (layer source, add-layer source) are controlled
downshift comboboxes. Keystroke typing is dropped/reordered — `{selectall}` then
typing `raster` yields `"exampleaster"`. Use `fill()`, which dispatches a single
input event, then pick from the filtered menu.
- CodeMirror auto-closes brackets and quotes, and types over its own closers, so
inserting a well-formed JSON fragment stays well-formed. To break JSON on
purpose, insert a bare word.
### Fixtures
Style fixtures live in `e2e/fixtures/`. A new one must be registered in two places
in `maputnik-driver.ts`: the list in `given.setupMockBackedResponses` and the
`styleFileByKey` map in `when.setStyle`.
### Verify a new test can fail
A test that passes for the wrong reason is worse than no test. After writing one,
mutate the expected value and confirm it fails. This has caught real mistakes
(e.g. a driver that never committed its input, so the assertion was matching a
value written by the *previous* step).
-1
View File
@@ -1,7 +1,6 @@
## main
### ✨ Features and improvements
- Replace Cypress with Playwright for end-to-end tests and Vitest browser mode (Playwright provider) for component tests; drop the `@shellygo/cypress-test-utils` helper in favour of a `MaputnikDriver` page object
- _...Add new stuff here..._
### 🐞 Bug fixes
+8 -27
View File
@@ -79,45 +79,26 @@ npm run sort-styles
```
## Tests
For E2E testing we use [Cypress](https://www.cypress.io/)
### End-to-end tests
[Cypress](https://www.cypress.io/) doesn't start a server so you'll need to start one manually by running `npm run start`.
For E2E testing we use [Playwright](https://playwright.dev/). The tests live in the [`e2e`](/e2e) directory and drive the app through the `MaputnikDriver` page object.
The first time you run the tests, install the browser:
```
npx playwright install chromium
```
Playwright automatically starts the dev server (`npm run start`) for you, so you can just run:
Now open a terminal and run the following using *chrome*:
```
npm run test
```
Some useful options:
or *firefox*:
```
# see the tests run in a headed browser
npm run test -- --headed
# run a single spec / filter by title
npm run test -- e2e/map.spec.ts
npm run test -- -g "zoom level"
# open the interactive UI mode
npx playwright test --ui
npm run test -- --browser firefox
```
Running the E2E tests also produces a code-coverage report in `coverage/` (collected via istanbul instrumentation of the dev server).
See the following docs for more info: (Launching Browsers)[https://docs.cypress.io/guides/guides/launching-browsers]
### Unit & component tests
Unit tests and component tests run with [Vitest](https://vitest.dev/); component tests (`*.browser.test.tsx`) use Vitest's browser mode with the Playwright provider.
You can also see the tests as they run or select which suites to run by executing:
```
npm run test-unit
npm run cy:open
```
## Release process
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig } from "cypress";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
export default defineConfig({
env: {
codeCoverage: {
exclude: "cypress/**/*.*",
},
},
e2e: {
specPattern: "e2e/**/*.spec.ts",
setupNodeEvents(on, config) {
// implement node event listeners here
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;
},
baseUrl: "http://localhost:8888",
scrollBehavior: "center",
retries: {
runMode: 2,
openMode: 0,
},
},
component: {
devServer: {
framework: "react",
bundler: "vite",
},
},
});
+37
View File
@@ -0,0 +1,37 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
+12
View File
@@ -0,0 +1,12 @@
<!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
@@ -0,0 +1,37 @@
// ***********************************************************
// 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 />)
+22
View File
@@ -0,0 +1,22 @@
// ***********************************************************
// This example support/e2e.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 "@cypress/code-coverage/support";
import "cypress-plugin-tab";
import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')
-60
View File
@@ -1,60 +0,0 @@
import { test, expect, describe, beforeEach } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
import tokens from "../src/config/tokens.json" with { type: "json" };
describe("access tokens", () => {
const { given, when } = new MaputnikDriver();
const tileJson = {
tilejson: "2.2.0",
tiles: ["https://example.local/{z}/{x}/{y}.pbf"],
minzoom: 0,
maxzoom: 14,
};
beforeEach(async () => {
await given.setupMockBackedResponses();
});
test("uses the thunderforest token for a thunderforest source", async () => {
await given.interceptAndMockResponse({
method: "GET",
url: /tile\.thunderforest\.com\/.*/,
response: tileJson,
alias: "thunderforest",
});
await when.setStyle("access_tokens");
const request = await when.waitForResponse("thunderforest");
expect(request.url()).toContain(`apikey=${tokens.thunderforest}`);
});
test("uses the locationiq token for a locationiq source", async () => {
await given.interceptAndMockResponse({
method: "GET",
url: /tiles\.locationiq\.com\/.*/,
response: tileJson,
alias: "locationiq",
});
await when.setStyle("access_tokens");
const request = await when.waitForResponse("locationiq");
expect(request.url()).toContain(`key=${tokens.locationiq}`);
});
test("appends the stadia token as a query parameter", async () => {
await given.interceptAndMockResponse({
method: "GET",
url: /tiles\.stadiamaps\.com\/.*/,
response: tileJson,
alias: "stadia",
});
await when.setStyle("access_tokens");
const request = await when.waitForResponse("stadia");
expect(request.url()).toContain("?api_key=stadia-test-token");
});
});
+25 -31
View File
@@ -1,48 +1,42 @@
import { test, describe, beforeEach } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
describe("accessibility", () => {
const { given, get, when, then } = new MaputnikDriver();
const test = it;
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
});
describe("accessibility", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
describe("skip links", () => {
beforeEach(async () => {
await when.setStyle("layer");
beforeEach(() => {
when.setStyle("layer");
});
test("skip link to layer list", async () => {
test("skip link to layer list", () => {
const selector = "root:skip:layer-list";
await then(get.elementByTestId(selector)).shouldExist();
await when.tab();
await then(get.elementByTestId(selector)).shouldBeFocused();
await when.click(selector);
await then(get.skipTargetLayerList()).shouldBeFocused();
then(get.elementByTestId(selector)).shouldExist();
when.tab();
then(get.elementByTestId(selector)).shouldBeFocused();
when.click(selector);
then(get.skipTargetLayerList()).shouldBeFocused();
});
test("skip link to layer editor", async () => {
test("skip link to layer editor", () => {
const selector = "root:skip:layer-editor";
await then(get.elementByTestId(selector)).shouldExist();
await then(get.elementByTestId("skip-target-layer-editor")).shouldExist();
await when.tab();
await when.tab();
await then(get.elementByTestId(selector)).shouldBeFocused();
await when.click(selector);
await then(get.skipTargetLayerEditor()).shouldBeFocused();
then(get.elementByTestId(selector)).shouldExist();
then(get.elementByTestId("skip-target-layer-editor")).shouldExist();
when.tab().tab();
then(get.elementByTestId(selector)).shouldBeFocused();
when.click(selector);
then(get.skipTargetLayerEditor()).shouldBeFocused();
});
test("skip link to map view", async () => {
test("skip link to map view", () => {
const selector = "root:skip:map-view";
await then(get.elementByTestId(selector)).shouldExist();
await when.tab();
await when.tab();
await when.tab();
await then(get.elementByTestId(selector)).shouldBeFocused();
await when.click(selector);
await then(get.canvas()).shouldBeFocused();
then(get.elementByTestId(selector)).shouldExist();
when.tab().tab().tab();
then(get.elementByTestId(selector)).shouldBeFocused();
when.click(selector);
then(get.canvas()).shouldBeFocused();
});
});
});
+12 -15
View File
@@ -1,23 +1,20 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
const test = it;
describe("code editor", () => {
const { given, get, when, then } = new MaputnikDriver();
const { beforeAndAfter, when, get, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
test("open code editor", () => {
when.click("nav:code-editor");
then(get.element(".maputnik-code-editor")).shouldExist();
});
test("open code editor", async () => {
await when.click("nav:code-editor");
await then(get.element(".maputnik-code-editor")).shouldExist();
});
test("closes code editor", async () => {
await when.click("nav:code-editor");
await then(get.element(".maputnik-code-editor")).shouldExist();
await when.click("nav:code-editor");
await then(get.element(".maputnik-code-editor")).shouldNotExist();
test("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();
});
});
-22
View File
@@ -1,22 +0,0 @@
{
"version": 8,
"name": "Access token style",
"metadata": {
"maputnik:stadia_access_token": "stadia-test-token"
},
"sources": {
"thunderforest_transport": {
"type": "vector",
"url": "https://tile.thunderforest.com/thunderforest.transport-v2.json?apikey={key}"
},
"stadia_outdoors": {
"type": "vector",
"url": "https://tiles.stadiamaps.com/data/openmaptiles.json"
},
"locationiq": {
"type": "vector",
"url": "https://tiles.locationiq.com/v3/pbf/tiles.json?key={key}"
}
},
"layers": []
}
+99 -63
View File
@@ -1,90 +1,126 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
const test = it;
describe("history", () => {
const { given, get, when, then } = new MaputnikDriver();
const { beforeAndAfter, when, get, then } = new MaputnikDriver();
beforeAndAfter();
const undoKeyCombo = process.platform === "darwin" ? "{meta}z" : "{ctrl}z";
const redoKeyCombo = process.platform === "darwin" ? "{meta}{shift}z" : "{ctrl}y";
let undoKeyCombo: string;
let redoKeyCombo: string;
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
before(() => {
const isMac = get.isMac();
undoKeyCombo = isMac ? "{meta}z" : "{ctrl}z";
redoKeyCombo = isMac ? "{meta}{shift}z" : "{ctrl}y";
});
test("undo/redo", async () => {
await when.setStyle("geojson");
await when.modal.open();
test("undo/redo", () => {
when.setStyle("geojson");
when.modal.open();
await when.modal.fillLayers({
when.modal.fillLayers({
id: "step 1",
type: "background",
});
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "step 1", type: "background" }],
});
await when.modal.open();
await when.modal.fillLayers({
id: "step 2",
type: "background",
});
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{ id: "step 1", type: "background" },
{ id: "step 2", type: "background" },
{
id: "step 1",
type: "background",
},
],
});
await when.typeKeys(undoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "step 1", type: "background" }],
});
await when.typeKeys(undoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
await when.typeKeys(redoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "step 1", type: "background" }],
});
await when.typeKeys(redoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{ id: "step 1", type: "background" },
{ id: "step 2", type: "background" },
],
});
});
test("should not redo after undo and value change", async () => {
await when.setStyle("geojson");
await when.modal.open();
await when.modal.fillLayers({
id: "step 1",
type: "background",
});
await when.modal.open();
await when.modal.fillLayers({
when.modal.open();
when.modal.fillLayers({
id: "step 2",
type: "background",
});
await when.typeKeys(undoKeyCombo);
await when.typeKeys(undoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "step 1",
type: "background",
},
{
id: "step 2",
type: "background",
},
],
});
await when.modal.open();
await when.modal.fillLayers({
when.typeKeys(undoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "step 1",
type: "background",
},
],
});
when.typeKeys(undoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
when.typeKeys(redoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "step 1",
type: "background",
},
],
});
when.typeKeys(redoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "step 1",
type: "background",
},
{
id: "step 2",
type: "background",
},
],
});
});
test("should not redo after undo and value change", () => {
when.setStyle("geojson");
when.modal.open();
when.modal.fillLayers({
id: "step 1",
type: "background",
});
when.modal.open();
when.modal.fillLayers({
id: "step 2",
type: "background",
});
when.typeKeys(undoKeyCombo);
when.typeKeys(undoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
when.modal.open();
when.modal.fillLayers({
id: "step 3",
type: "background",
});
await when.typeKeys(redoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "step 3", type: "background" }],
when.typeKeys(redoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "step 3",
type: "background",
},
],
});
});
});
+19 -20
View File
@@ -1,38 +1,37 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
describe("i18n", () => {
const { given, get, when, then } = new MaputnikDriver();
const test = it;
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
});
describe("i18n", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
describe("language detector", () => {
test("English", async () => {
await when.visit("?lng=en");
await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en");
test("English", () => {
const url = "?lng=en";
when.visit(url);
then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en");
});
test("Japanese", async () => {
await when.visit("?lng=ja");
await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja");
test("Japanese", () => {
const url = "?lng=ja";
when.visit(url);
then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja");
});
});
describe("language switcher", () => {
beforeEach(async () => {
await when.setStyle("layer");
beforeEach(() => {
when.setStyle("layer");
});
test("the language switcher switches to Japanese", async () => {
test("the language switcher switches to Japanese", () => {
const selector = "maputnik-lang-select";
await then(get.elementByTestId(selector)).shouldExist();
await when.select(selector, "ja");
await then(get.elementByTestId(selector)).shouldHaveValue("ja");
then(get.elementByTestId(selector)).shouldExist();
when.select(selector, "ja");
then(get.elementByTestId(selector)).shouldHaveValue("ja");
await then(get.elementByTestId("nav:settings")).shouldHaveText("スタイル設定");
then(get.elementByTestId("nav:settings")).shouldHaveText("スタイル設定");
});
});
});
+35 -40
View File
@@ -1,66 +1,61 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
const test = it;
describe("keyboard", () => {
const { given, get, when, then } = new MaputnikDriver();
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
});
const { beforeAndAfter, given, when, get, then } = new MaputnikDriver();
beforeAndAfter();
describe("shortcuts", () => {
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("");
beforeEach(() => {
given.setupMockBackedResponses();
when.setStyle("");
});
test("ESC should unfocus", async () => {
test("ESC should unfocus", () => {
const targetSelector = "maputnik-select";
await when.focus(targetSelector);
await then(get.elementByTestId(targetSelector)).shouldBeFocused();
await when.typeKeys("{esc}");
await then(get.elementByTestId(targetSelector)).shouldNotBeFocused();
when.focus(targetSelector);
then(get.elementByTestId(targetSelector)).shouldBeFocused();
when.typeKeys("{esc}");
then(get.elementByTestId(targetSelector)).shouldNotBeFocused();
});
test("'?' should show shortcuts modal", async () => {
await when.typeKeys("?");
await then(get.elementByTestId("modal:shortcuts")).shouldBeVisible();
test("'?' should show shortcuts modal", () => {
when.typeKeys("?");
then(get.elementByTestId("modal:shortcuts")).shouldBeVisible();
});
test("'o' should show open modal", async () => {
await when.typeKeys("o");
await then(get.elementByTestId("modal:open")).shouldBeVisible();
test("'o' should show open modal", () => {
when.typeKeys("o");
then(get.elementByTestId("modal:open")).shouldBeVisible();
});
test("'e' should show export modal", async () => {
await when.typeKeys("e");
await then(get.elementByTestId("modal:export")).shouldBeVisible();
test("'e' should show export modal", () => {
when.typeKeys("e");
then(get.elementByTestId("modal:export")).shouldBeVisible();
});
test("'d' should show sources modal", async () => {
await when.typeKeys("d");
await then(get.elementByTestId("modal:sources")).shouldBeVisible();
test("'d' should show sources modal", () => {
when.typeKeys("d");
then(get.elementByTestId("modal:sources")).shouldBeVisible();
});
test("'s' should show settings modal", async () => {
await when.typeKeys("s");
await then(get.elementByTestId("modal:settings")).shouldBeVisible();
test("'s' should show settings modal", () => {
when.typeKeys("s");
then(get.elementByTestId("modal:settings")).shouldBeVisible();
});
test("'i' should change map to inspect mode", async () => {
await when.typeKeys("i");
await then(get.inputValue("maputnik-select")).shouldEqual("inspect");
test("'i' should change map to inspect mode", () => {
when.typeKeys("i");
then(get.inputValue("maputnik-select")).shouldEqual("inspect");
});
test("'m' should focus map", async () => {
await when.typeKeys("m");
await then(get.canvas()).shouldBeFocused();
test("'m' should focus map", () => {
when.typeKeys("m");
then(get.canvas()).shouldBeFocused();
});
test("'!' should show debug modal", async () => {
await when.typeKeys("!");
await then(get.elementByTestId("modal:debug")).shouldBeVisible();
test("'!' should show debug modal", () => {
when.typeKeys("!");
then(get.elementByTestId("modal:debug")).shouldBeVisible();
});
});
});
+170 -478
View File
@@ -1,98 +1,98 @@
import { v1 as uuid } from "uuid";
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
import { v1 as uuid } from "uuid";
const test = it;
describe("layer editor", () => {
const { given, get, when, then } = new MaputnikDriver();
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
await when.modal.open();
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(() => {
when.setStyle("both");
when.modal.open();
});
async function createBackground() {
function createBackground() {
const id = uuid();
await when.selectWithin("add-layer.layer-type", "background");
await when.setValue("add-layer.layer-id.input", "background:" + id);
when.selectWithin("add-layer.layer-type", "background");
when.setValue("add-layer.layer-id.input", "background:" + id);
await when.click("add-layer");
when.click("add-layer");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + id, type: "background" }],
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + id,
type: "background",
},
],
});
return id;
}
test("expand/collapse", async () => {
const bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
test("expand/collapse");
test("id", () => {
const bgId = createBackground();
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldBeVisible();
await when.toggleGroupInLayerEditor("Layer");
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldNotBeVisible();
await when.toggleGroupInLayerEditor("Layer");
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldBeVisible();
});
test("id", async () => {
const bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
when.click("layer-list-item:background:" + bgId);
const id = uuid();
await when.setValue("layer-editor.layer-id.input", "foobar:" + id);
await when.click("min-zoom");
when.setValue("layer-editor.layer-id.input", "foobar:" + id);
when.click("min-zoom");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "foobar:" + id, type: "background" }],
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "foobar:" + id,
type: "background",
},
],
});
});
describe("source", () => {
test("should show error when the source is invalid", async () => {
await when.modal.fillLayers({
test("should show error when the source is invalid", () => {
when.modal.fillLayers({
type: "circle",
layer: "invalid",
});
await then(
get.element(".maputnik-input-block--error .maputnik-input-block-label")
).shouldHaveCss("color", "rgb(207, 74, 74)");
then(get.element(".maputnik-input-block--error .maputnik-input-block-label")).shouldHaveCss("color", "rgb(207, 74, 74)");
});
});
describe("min-zoom", () => {
let bgId: string;
beforeEach(async () => {
bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await when.setValue("min-zoom.input-text", "1");
await when.click("layer-editor.layer-id");
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue("min-zoom.input-text", "1");
when.click("layer-editor.layer-id");
});
test("should update min-zoom in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
test("should update min-zoom in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
minzoom: 1,
},
],
});
});
test("when clicking next layer should update style on local storage", async () => {
await when.type("min-zoom.input-text", "{backspace}");
await when.click("max-zoom.input-text");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
});
});
test("the range slider adjusts min-zoom", async () => {
await when.focus("min-zoom.input-range");
await when.typeKeys("{rightarrow}");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", minzoom: 2 }],
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,
},
],
});
});
});
@@ -100,16 +100,22 @@ describe("layer editor", () => {
describe("max-zoom", () => {
let bgId: string;
beforeEach(async () => {
bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await when.setValue("max-zoom.input-text", "1");
await when.click("layer-editor.layer-id");
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue("max-zoom.input-text", "1");
when.click("layer-editor.layer-id");
});
test("should update style in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", maxzoom: 1 }],
test("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
maxzoom: 1,
},
],
});
});
});
@@ -118,34 +124,41 @@ describe("layer editor", () => {
let bgId: string;
const comment = "42";
beforeEach(async () => {
bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await when.setValue("layer-comment.input", comment);
await when.click("layer-editor.layer-id");
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue("layer-comment.input", comment);
when.click("layer-editor.layer-id");
});
test("should update style in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
metadata: { "maputnik:comment": comment },
metadata: {
"maputnik:comment": comment,
},
},
],
});
});
describe("when unsetting", () => {
beforeEach(async () => {
await when.clear("layer-comment.input");
await when.click("min-zoom.input-text");
beforeEach(() => {
when.clear("layer-comment.input");
when.click("min-zoom.input-text");
});
test("should update style in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background" }],
test("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
},
],
});
});
});
@@ -153,452 +166,131 @@ describe("layer editor", () => {
describe("color", () => {
let bgId: string;
beforeEach(async () => {
bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await when.click("spec-field:background-color");
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.click("spec-field:background-color");
});
test("should update style in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background" }],
});
});
test("typing a hex value updates the paint color", async () => {
await when.setColorValue("background-color", "#ff0000");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", paint: { "background-color": "#ff0000" } }],
test("should update style in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "background:" + bgId,
type: "background",
},
],
});
});
});
describe("opacity", () => {
let bgId: string;
beforeEach(async () => {
bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await when.type("spec-field-input:background-opacity", "0.");
beforeEach(() => {
bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.type("spec-field-input:background-opacity", "0.");
});
test("should keep '.' in the input field", async () => {
await then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0.");
test("should keep '.' in the input field", () => {
then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0.");
});
test("should revert to a valid value when focus out", async () => {
await when.click("layer-list-item:background:" + bgId);
await then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0");
test("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", () => {
let id: string;
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "fill", layer: "example" });
await when.addFilter();
});
test("should add a filter item", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", filter: ["all", ["==", "name", ""]] }],
});
});
test("should change the filter operator", async () => {
await when.selectFilterOperator("!=");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["all", ["!=", "name", ""]] }],
});
});
test("should extend the compound filter with a second item", async () => {
await when.addFilter();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["all", ["==", "name", ""], ["==", "name", ""]] }],
});
});
test("should change the combining operator", async () => {
await when.selectFilterCombiningOperator("any");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["any", ["==", "name", ""]] }],
});
});
test("should delete a filter item", async () => {
await when.addFilter();
await when.deleteFilterItem();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["all", ["==", "name", ""]] }],
});
});
describe("when converted to an expression", () => {
beforeEach(async () => {
await when.convertFilterToExpression();
});
test("should migrate the filter to an expression", async () => {
// A single-item "all" collapses to the bare comparison when migrated.
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["==", ["get", "name"], ""] }],
});
});
test("should restore the default filter when the expression is deleted", async () => {
await when.deleteFilterExpression();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["all"] }],
});
});
});
});
describe("functions", () => {
let id: string;
describe("zoom function", () => {
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await when.makeZoomFunction("circle-radius");
});
test("should convert the property to a zoom function", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } },
],
});
});
test("should add a stop", async () => {
await when.addFunctionStop("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }],
});
});
test("should delete the first stop", async () => {
// A function needs more than two stops, otherwise deleting one collapses
// it back into a plain value.
await when.addFunctionStop("circle-radius");
await when.deleteFunctionStop("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }],
});
});
test("should set the base", async () => {
await when.setFunctionBase("circle-radius", "2");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { base: 2, stops: [[6, 5], [10, 5]] } } }],
});
});
test("should edit the zoom of a stop", async () => {
await when.setFunctionStopValue("circle-radius", "Zoom", 0, "3");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { stops: [[3, 5], [10, 5]] } } }],
});
});
test("should edit the output value of a stop", async () => {
await when.setFunctionStopValue("circle-radius", "Output value", 0, "9");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { stops: [[6, 9], [10, 5]] } } }],
});
});
test("should convert to an expression", async () => {
await when.makeExpression("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 5, 10, 5] } }],
});
});
describe("when converted to a data function", () => {
beforeEach(async () => {
// Any non-interpolate scale turns the zoom function into a data one.
await when.selectFunctionType("circle-radius", "categorical");
});
test("should carry the stops over as zoom/value pairs", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
paint: {
"circle-radius": {
property: "",
type: "exponential",
stops: [[{ zoom: 6, value: 0 }, 5], [{ zoom: 10, value: 0 }, 5]],
},
},
},
],
});
});
test("should convert back to a zoom function", async () => {
await when.selectFunctionType("circle-radius", "interpolate");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }],
});
});
});
});
describe("data function", () => {
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await when.setValue("spec-field-input:circle-blur", "1");
await when.makeDataFunction("circle-blur");
});
test("should convert the property to a data function", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
type: "circle",
source: "example",
paint: {
"circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1]],
},
},
},
],
});
});
test("should add a stop", async () => {
await when.addFunctionStop("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
paint: {
"circle-blur": {
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
},
},
},
],
});
});
test("should delete the first stop", async () => {
await when.addFunctionStop("circle-blur");
await when.deleteFunctionStop("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
paint: {
"circle-blur": {
stops: [[{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
},
},
},
],
});
});
test("should set the property", async () => {
await when.setFunctionProperty("circle-blur", "myprop");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": { property: "myprop" } } }],
});
});
test("should set the default", async () => {
await when.setFunctionDefault("circle-blur", "0.5");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": { default: 0.5 } } }],
});
});
test("should edit the input value of a stop", async () => {
await when.setFunctionStopValue("circle-blur", "Input value", 0, "7");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
paint: {
"circle-blur": {
stops: [[{ zoom: 6, value: 7 }, 1], [{ zoom: 10, value: 0 }, 1]],
},
},
},
],
});
});
test("should change the function type", async () => {
await when.selectFunctionType("circle-blur", "categorical");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": { type: "categorical" } } }],
});
});
});
describe("expression", () => {
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await when.setValue("spec-field-input:circle-blur", "1");
await when.makeExpression("circle-blur");
});
test("should wrap the property value in a literal expression", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": ["literal", 1] } }],
});
});
test("should restore the plain value when reverted", async () => {
await when.undoExpression("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": 1 } }],
});
});
test("should fall back to the spec default when deleted", async () => {
await when.deleteExpression("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": 0 } }],
});
});
});
test("expand/collapse");
test("compound filter");
});
describe("layout", () => {
test("text-font", async () => {
await when.setStyle("font");
await when.collapseGroupInLayerEditor();
await when.collapseGroupInLayerEditor(1);
await when.collapseGroupInLayerEditor(2);
await when.clickWithin("spec-field:text-font", ".maputnik-autocomplete input");
await then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible();
await then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3);
test("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", () => {
let id: string;
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "fill", layer: "example" });
});
test("expand/collapse", async () => {
await then(get.elementByTestId("spec-field:fill-color")).shouldBeVisible();
await when.toggleGroupInLayerEditor("Paint properties");
await then(get.elementByTestId("spec-field:fill-color")).shouldNotBeVisible();
await when.toggleGroupInLayerEditor("Paint properties");
await then(get.elementByTestId("spec-field:fill-color")).shouldBeVisible();
});
test("color", async () => {
await when.setColorValue("fill-color", "#ff0000");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", paint: { "fill-color": "#ff0000" } }],
});
});
test("pattern", async () => {
await when.setStringValue("fill-pattern", "some-pattern");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", paint: { "fill-pattern": "some-pattern" } }],
});
});
test("opacity", async () => {
await when.setValue("spec-field-input:fill-opacity", "0.4");
await when.click("layer-editor.layer-id");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", paint: { "fill-opacity": 0.4 } }],
});
});
test("expand/collapse");
test("color");
test("pattern");
test("opacity");
});
describe("json-editor", () => {
test("add", async () => {
const id = await when.modal.fillLayers({
test("add", () => {
const id = when.modal.fillLayers({
type: "circle",
layer: "example",
});
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example" }],
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "circle",
source: "example",
},
],
});
await when.clickByText('"source"');
await when.typeKeys('"');
const sourceText = get.elementByText('"source"');
await then(get.element(".cm-lint-marker-error")).shouldExist();
sourceText.click();
sourceText.type("\"");
then(get.element(".cm-lint-marker-error")).shouldExist();
});
test("expand/collapse", async () => {
const bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await then(get.element(".cm-content")).shouldBeVisible();
test("expand/collapse");
test("modify");
await when.toggleGroupInLayerEditor("JSON Editor");
await then(get.element(".cm-content")).shouldNotBeVisible();
test("parse error", () => {
const bgId = createBackground();
await when.toggleGroupInLayerEditor("JSON Editor");
await then(get.element(".cm-content")).shouldBeVisible();
});
when.click("layer-list-item:background:" + bgId);
when.collapseGroupInLayerEditor();
when.collapseGroupInLayerEditor(1);
then(get.element(".cm-lint-marker-error")).shouldNotExist();
test("modify", async () => {
const bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await when.appendToJsonEditorLine('"background"', ',\n"minzoom": 5');
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", minzoom: 5 }],
});
});
test("parse error", async () => {
const bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await when.collapseGroupInLayerEditor();
await when.collapseGroupInLayerEditor(1);
await then(get.element(".cm-lint-marker-error")).shouldNotExist();
// Inject an invalid token (CodeMirror auto-closes brackets/quotes, so a
// bare word reliably breaks the JSON) and expect a lint error.
await when.appendTextInJsonEditor("zzz");
await then(get.element(".cm-lint-marker-error")).shouldExist();
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", () => {
test("should keep layer header visible when scrolling properties", async () => {
// Setup: Create a layer with many properties (e.g. symbol layer)
await when.modal.fillLayers({
test("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",
});
await when.wait(500);
when.wait(500);
const header = get.elementByTestId("layer-editor.header");
await then(header).shouldBeVisible();
then(header).shouldBeVisible();
await when.scrollToBottom(get.element(".maputnik-scroll-container"));
await when.wait(200);
get.element(".maputnik-scroll-container").scrollTo("bottom", { ensureScrollable: false });
when.wait(200);
await then(header).shouldBeVisible();
await then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible();
then(header).shouldBeVisible();
then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible();
});
});
});
+410 -232
View File
@@ -1,278 +1,412 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
const test = it;
describe("layers list", () => {
const { given, get, when, then } = new MaputnikDriver();
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
await when.modal.open();
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(() => {
when.setStyle("both");
when.modal.open();
});
describe("ops", () => {
let id: string;
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "background" });
beforeEach(() => {
id = when.modal.fillLayers({
type: "background",
});
});
test("should update layers in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "background" }],
test("should update layers in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
},
],
});
});
describe("when clicking delete", () => {
beforeEach(async () => {
await when.click("layer-list-item:" + id + ":delete");
beforeEach(() => {
when.click("layer-list-item:" + id + ":delete");
});
test("should empty layers in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("should empty layers in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [],
});
});
});
describe("when clicking duplicate", () => {
beforeEach(async () => {
await when.click("layer-list-item:" + id + ":copy");
beforeEach(() => {
when.click("layer-list-item:" + id + ":copy");
});
test("should add copy layer in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("should add copy layer in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{ id: id + "-copy", type: "background" },
{ id, type: "background" },
{
id: id + "-copy",
type: "background",
},
{
id: id,
type: "background",
},
],
});
});
});
describe("when clicking hide", () => {
beforeEach(async () => {
await when.click("layer-list-item:" + id + ":toggle-visibility");
beforeEach(() => {
when.click("layer-list-item:" + id + ":toggle-visibility");
});
test("should update visibility to none in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "background", layout: { visibility: "none" } }],
test("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(async () => {
await when.click("layer-list-item:" + id + ":toggle-visibility");
beforeEach(() => {
when.click("layer-list-item:" + id + ":toggle-visibility");
});
test("should update visibility to visible in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "background", layout: { visibility: "visible" } }],
test("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(async () => {
await when.modal.open();
secondId = await when.modal.fillLayers({
beforeEach(() => {
when.modal.open();
secondId = when.modal.fillLayers({
id: "second-layer",
type: "background",
});
});
test("should show the selected layer in the editor", async () => {
await when.realClick("layer-list-item:" + secondId);
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId);
await when.realClick("layer-list-item:" + id);
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id);
test("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", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "background" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "background" }],
test("add", () => {
const id = when.modal.fillLayers({
type: "background",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
},
],
});
});
test("modify", async () => {
const id = await when.modal.fillLayers({ type: "background" });
await when.click("layer-list-item:" + id);
await when.setValue("spec-field-input:background-opacity", "0.4");
await when.click("layer-editor.layer-id");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "background", paint: { "background-opacity": 0.4 } }],
});
});
describe("modify", () => {});
});
describe("fill", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example" }],
test("add", () => {
const id = when.modal.fillLayers({
type: "fill",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "fill",
source: "example",
},
],
});
});
test("change source", async () => {
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
await when.changeLayerSource("raster");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "raster" }],
});
});
// TODO: Change source
test("change source");
});
describe("line", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "line", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "line", source: "example" }],
test("add", () => {
const id = when.modal.fillLayers({
type: "line",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "line",
source: "example",
},
],
});
});
test("groups", async () => {
const id1 = await when.modal.fillLayers({ id: "aa", type: "line", layer: "example" });
test("groups", () => {
when.modal.open();
const id1 = when.modal.fillLayers({
id: "aa",
type: "line",
layer: "example",
});
await when.modal.open();
const id2 = await when.modal.fillLayers({ id: "aa-2", type: "line", layer: "example" });
when.modal.open();
const id2 = when.modal.fillLayers({
id: "aa-2",
type: "line",
layer: "example",
});
await when.modal.open();
const id3 = await when.modal.fillLayers({ id: "b", type: "line", layer: "example" });
when.modal.open();
const id3 = when.modal.fillLayers({
id: "b",
type: "line",
layer: "example",
});
await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
await then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible();
await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
await when.click("layer-list-group:aa-0");
await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
await then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible();
await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
await when.click("layer-list-item:" + id2);
await when.click("skip-target-layer-editor");
await when.click("menu-move-layer-down");
await then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
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" },
{
id: "aa",
type: "line",
source: "example",
},
{
id: "b",
type: "line",
source: "example",
},
{
id: "aa-2",
type: "line",
source: "example",
},
],
});
});
});
describe("symbol", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "symbol", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "symbol", source: "example" }],
test("add", () => {
const id = when.modal.fillLayers({
type: "symbol",
layer: "example",
});
});
test("should show spec info when hovering and clicking single line property", async () => {
await when.modal.fillLayers({ type: "symbol", layer: "example" });
await when.hover("spec-field-container:text-rotate");
await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
await when.click("field-doc-button-Rotate", 0);
await then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the ");
});
test("should show spec info when hovering and clicking multi line property", async () => {
await when.modal.fillLayers({ type: "symbol", layer: "example" });
await when.hover("spec-field-container:text-offset");
await then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible();
await when.click("field-doc-button-Offset", 0);
await then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance");
});
test("should hide spec info when clicking a second time", async () => {
await when.modal.fillLayers({ type: "symbol", layer: "example" });
await when.hover("spec-field-container:text-rotate");
await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
await when.click("field-doc-button-Rotate", 0);
await when.wait(200);
await when.click("field-doc-button-Rotate", 0);
await then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible();
});
});
describe("raster", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "raster", layer: "raster" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "raster", source: "raster" }],
});
});
});
describe("circle", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example" }],
});
});
});
describe("fill extrusion", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "fill-extrusion", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill-extrusion", source: "example" }],
});
});
});
describe("hillshade", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "hillshade", source: "example" }],
});
});
test("set hillshade illumination direction array", async () => {
const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
await when.collapseGroupInLayerEditor();
await when.collapseGroupInLayerEditor(1);
await when.setValueToPropertyArray("spec-field:hillshade-illumination-direction", "1");
await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "2");
await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "3");
await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "4");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
type: "hillshade",
id: id,
type: "symbol",
source: "example",
paint: { "hillshade-illumination-direction": [1, 2, 3, 4] },
},
],
});
});
test("set hillshade highlight color array", async () => {
const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
await when.collapseGroupInLayerEditor();
await when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue");
await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00");
await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 1)");
test("should show spec info when hovering and clicking single line property", () => {
when.modal.fillLayers({
type: "symbol",
layer: "example",
});
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
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 ");
});
test("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");
});
test("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", () => {
test("add", () => {
const id = when.modal.fillLayers({
type: "raster",
layer: "raster",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
id: id,
type: "raster",
source: "raster",
},
],
});
});
});
describe("circle", () => {
test("add", () => {
const id = when.modal.fillLayers({
type: "circle",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "circle",
source: "example",
},
],
});
});
});
describe("fill extrusion", () => {
test("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", () => {
test("add", () => {
const id = when.modal.fillLayers({
type: "hillshade",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "hillshade",
source: "example",
},
],
});
});
test("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-highlight-color": ["blue", "#00ff00", "rgba(255, 255, 0, 1)"],
},
"hillshade-illumination-direction": [ 1, 2, 3, 4 ]
}
},
],
});
});
test("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)" ]
}
},
],
});
@@ -280,85 +414,129 @@ describe("layers list", () => {
});
describe("color-relief", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "color-relief", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "color-relief", source: "example" }],
test("add", () => {
const id = when.modal.fillLayers({
type: "color-relief",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "color-relief",
source: "example",
},
],
});
});
test("adds elevation expression when clicking the elevation button", async () => {
await when.modal.fillLayers({ type: "color-relief", layer: "example" });
await when.collapseGroupInLayerEditor();
await when.click("make-elevation-function");
await then(
get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line")
).shouldBeVisible();
test("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", () => {
test("simple", async () => {
await when.setStyle("geojson");
test("simple", () => {
when.setStyle("geojson");
await when.modal.open();
await when.modal.fillLayers({ id: "foo", type: "background" });
when.modal.open();
when.modal.fillLayers({
id: "foo",
type: "background",
});
await when.modal.open();
await when.modal.fillLayers({ id: "foo_bar", type: "background" });
when.modal.open();
when.modal.fillLayers({
id: "foo_bar",
type: "background",
});
await when.modal.open();
await when.modal.fillLayers({ id: "foo_bar_baz", type: "background" });
when.modal.open();
when.modal.fillLayers({
id: "foo_bar_baz",
type: "background",
});
await then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
await then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible();
await then(get.elementByTestId("layer-list-item:foo_bar_baz")).shouldNotBeVisible();
await when.click("layer-list-group:foo-0");
await then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
await then(get.elementByTestId("layer-list-item:foo_bar")).shouldBeVisible();
await then(get.elementByTestId("layer-list-item:foo_bar_baz")).shouldBeVisible();
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", () => {
test("move layer should update local storage", async () => {
const firstId = await when.modal.fillLayers({ id: "a", type: "background" });
await when.modal.open();
const secondId = await when.modal.fillLayers({ id: "b", type: "background" });
await when.modal.open();
const thirdId = await when.modal.fillLayers({ id: "c", type: "background" });
test("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",
});
await when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId);
when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{ id: secondId, type: "background" },
{ id: thirdId, type: "background" },
{ id: firstId, type: "background" },
{
id: secondId,
type: "background",
},
{
id: thirdId,
type: "background",
},
{
id: firstId,
type: "background",
},
],
});
});
});
describe("sticky header", () => {
test("should keep header visible when scrolling layer list", async () => {
test("should keep header visible when scrolling layer list", () => {
// Setup: Create multiple layers to enable scrolling
// The modal is already open (beforeEach) for the first layer.
await when.modal.fillLayers({ id: "layer-0", type: "background" });
for (let i = 1; i < 20; i++) {
await when.modal.open();
await when.modal.fillLayers({ id: `layer-${i}`, type: "background" });
for (let i = 0; i < 20; i++) {
when.modal.open();
when.modal.fillLayers({
id: `layer-${i}`,
type: "background",
});
}
await when.wait(500);
when.wait(500);
const header = get.elementByTestId("layer-list.header");
await then(header).shouldBeVisible();
then(header).shouldBeVisible();
// Scroll the layer list container
await when.scrollToBottom(get.elementByTestId("layer-list"));
await when.wait(200);
await then(header).shouldBeVisible();
await then(get.elementByTestId("layer-list:add-layer")).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();
});
});
});
+43 -41
View File
@@ -1,67 +1,69 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
const test = it;
describe("map", () => {
const { given, get, when, then } = new MaputnikDriver();
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
});
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
describe("zoom level", () => {
test("via url", async () => {
test("via url", () => {
const zoomLevel = 12.37;
await when.setStyle("geojson", zoomLevel);
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + zoomLevel);
when.setStyle("geojson", zoomLevel);
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
"Zoom: " + zoomLevel
);
});
test("via map controls", async () => {
test("via map controls", () => {
const zoomLevel = 12.37;
await when.setStyle("geojson", zoomLevel);
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
await when.clickZoomIn();
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + (zoomLevel + 1));
when.setStyle("geojson", zoomLevel);
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
when.clickZoomIn();
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
"Zoom: " + (zoomLevel + 1)
);
});
test("via style file definition", async () => {
await when.setStyle("zoom_7_center_0_51");
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + 7);
await then(get.locationHash()).shouldInclude("#7/51/0");
test("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
await when.openASecondStyleWithDifferentZoomAndCenter();
await then(get.locationHash()).shouldInclude("#7/51/0");
when.openASecondStyleWithDifferentZoomAndCenter();
then(get.locationHash().should("contain", "#7/51/0"));
});
});
describe("search", () => {
test("should exist", async () => {
await then(get.searchControl()).shouldBeVisible();
test("should exist", () => {
then(get.searchControl()).shouldBeVisible();
});
});
describe("popup", () => {
beforeEach(async () => {
await when.setStyle("rectangles");
await then(get.locationHash()).shouldExist();
beforeEach(() => {
when.setStyle("rectangles");
then(get.locationHash().should("exist"));
});
test("should open on feature click", () => {
when.clickCenter("maplibre:map");
then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
});
test("should open on feature click", async () => {
await when.clickCenter("maplibre:map");
await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
});
test("should open a second feature after closing popup", async () => {
await when.clickCenter("maplibre:map");
await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
await when.closePopup();
await then(get.elementByTestId("feature-layer-popup")).shouldNotExist();
await when.clickCenter("maplibre:map");
await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
test("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();
});
});
});
+56
View File
@@ -0,0 +1,56 @@
/// <reference types="cypress-real-events" />
import { CypressHelper } from "@shellygo/cypress-test-utils";
import "cypress-real-events/support";
export default class MaputnikCypressHelper {
private helper = new CypressHelper({ defaultDataAttribute: "data-wd-key" });
public given = {
...this.helper.given,
};
public get = {
locationHash: (): Cypress.Chainable<string> => cy.location("hash"),
...this.helper.get,
};
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,
};
public beforeAndAfter = this.helper.beforeAndAfter;
}
+215 -301
View File
@@ -1,56 +1,122 @@
import { PlaywrightHelper } from "./playwright-helper";
import { ModalDriver } from "./modal-driver";
/// <reference types="cypress-plugin-tab" />
import { CypressHelper } from "@shellygo/cypress-test-utils";
import { Assertable, then } from "@shellygo/cypress-test-utils/assertable";
import MaputnikCypressHelper from "./maputnik-cypress-helper";
import ModalDriver from "./modal-driver";
const baseUrl = "http://localhost:8888/";
const isMac = process.platform === "darwin";
/**
* The maputnik-specific driver. It builds on the generic {@link PlaywrightHelper}
* — spreading its `given`/`when`/`get` primitives and adding domain concepts
* (loading a style, the add-layer modal, the JSON editor, …). All Playwright
* access goes through the helper; the driver never touches `page` directly.
*/
const styleFromWindow = (win: Window) => {
const styleId = win.localStorage.getItem("maputnik:latest_style");
const styleItemKey = `maputnik:style:${styleId}`;
const styleItem = win.localStorage.getItem(styleItemKey);
if (!styleItem) throw new Error("Could not get styleItem from localStorage");
const obj = JSON.parse(styleItem);
return obj;
};
export class MaputnikAssertable<T> extends Assertable<T> {
shouldEqualToStoredStyle = () =>
then(
new CypressHelper().get.window().then((win: Window) => {
const style = styleFromWindow(win);
then(this.chainable).shouldDeepNestedInclude(style);
})
);
}
export class MaputnikDriver {
private readonly helper = new PlaywrightHelper();
private readonly modalDriver = new ModalDriver();
private helper = new MaputnikCypressHelper();
private modalDriver = new ModalDriver();
then = this.helper.then;
public beforeAndAfter = () => {
beforeEach(() => {
this.given.setupMockBackedResponses();
this.when.setStyle("both");
});
};
/** Reads the maputnik style currently persisted in localStorage. */
private async readStoredStyle(): Promise<any> {
const styleId = await this.helper.get.localStorageItem("maputnik:latest_style");
const styleItem = await this.helper.get.localStorageItem(`maputnik:style:${styleId}`);
if (!styleItem) throw new Error("Could not get styleItem from localStorage");
return JSON.parse(styleItem);
}
public then = (chainable: Cypress.Chainable<any>) =>
new MaputnikAssertable(chainable);
public given = {
...this.helper.given,
setupMockBackedResponses: async () => {
const styleFixtures = [
"example-style.json",
"example-layer-style.json",
"geojson-style.json",
"raster-style.json",
"geojson-raster-style.json",
"rectangles-style.json",
"example-style-with-fonts.json",
"example-style-with-zoom-7-and-center-0-51.json",
"example-style-with-zoom-5-and-center-50-50.json",
"access-token-style.json",
];
for (const fixture of styleFixtures) {
await this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + fixture,
response: { fixture },
alias: fixture === "example-style.json" ? "example-style.json" : undefined,
});
}
await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.local\//, response: [] });
await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.com\//, response: [] });
await this.helper.given.interceptAndMockResponse({
setupMockBackedResponses: () => {
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "example-style.json",
response: {
fixture: "example-style.json",
},
alias: "example-style.json",
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "example-layer-style.json",
response: {
fixture: "example-layer-style.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "geojson-style.json",
response: {
fixture: "geojson-style.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "raster-style.json",
response: {
fixture: "raster-style.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "geojson-raster-style.json",
response: {
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({
method: "GET",
url: "*example.local/*",
response: [],
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: "*example.com/*",
response: [],
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: "https://www.glyph-server.com/*",
response: ["Font 1", "Font 2", "Font 3"],
@@ -60,299 +126,147 @@ export class MaputnikDriver {
public when = {
...this.helper.when,
modal: this.modalDriver.when,
setStyle: async (
styleProperties:
| "geojson"
| "raster"
| "both"
| "layer"
| "rectangles"
| "font"
| "zoom_7_center_0_51"
| "access_tokens"
| "",
doWithin: (selector: string, fn: () => void) => {
this.helper.when.doWithin(fn, selector);
},
tab: () => this.helper.get.element("body").tab(),
waitForExampleFileResponse: () => {
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: () => {
this.helper.given.fixture("example-style.json", "example-style.json");
this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input");
this.helper.when.wait(200);
},
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: (
styleProperties: "geojson" | "raster" | "both" | "layer" | "rectangles" | "font" | "zoom_7_center_0_51" | "",
zoom?: number
) => {
const styleFileByKey: Record<string, string> = {
geojson: "geojson-style.json",
raster: "raster-style.json",
both: "geojson-raster-style.json",
layer: "example-layer-style.json",
rectangles: "rectangles-style.json",
font: "example-style-with-fonts.json",
zoom_7_center_0_51: "example-style-with-zoom-7-and-center-0-51.json",
access_tokens: "access-token-style.json",
};
const url = new URL(baseUrl);
if (styleProperties && styleFileByKey[styleProperties]) {
url.searchParams.set("style", baseUrl + styleFileByKey[styleProperties]);
switch (styleProperties) {
case "geojson":
url.searchParams.set("style", baseUrl + "geojson-style.json");
break;
case "raster":
url.searchParams.set("style", baseUrl + "raster-style.json");
break;
case "both":
url.searchParams.set("style", baseUrl + "geojson-raster-style.json");
break;
case "layer":
url.searchParams.set("style", baseUrl + "example-layer-style.json");
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) {
url.hash = `${zoom}/41.3805/2.1635`;
}
await this.helper.when.visit(url.toString());
this.helper.when.visit(url.toString());
if (styleProperties) {
this.helper.when.acceptConfirm();
}
// when methods should not include assertions
const toolbarLink = this.helper.get.elementByTestId("toolbar:link");
await toolbarLink.scrollIntoViewIfNeeded();
await this.then(toolbarLink).shouldBeVisible();
toolbarLink.scrollIntoView();
toolbarLink.should("be.visible");
},
openASecondStyleWithDifferentZoomAndCenter: async () => {
await this.helper.when.clickButtonByName("Open");
const input = this.helper.get.elementByTestId("modal:open.url.input");
await input.fill("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json");
await input.press("Enter");
typeKeys: (keys: string) => this.helper.get.element("body").type(keys),
clickZoomIn: () => {
this.helper.get.element(".maplibregl-ctrl-zoom-in").click();
},
chooseExampleFile: async () => {
await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone");
await this.helper.when.wait(200);
selectWithin: (selector: string, value: string) => {
this.when.doWithin(selector, () => {
this.helper.get.element("select").select(value);
});
},
/** Picks the example style through the browser's native file chooser. */
chooseExampleFileFromPicker: async () => {
await this.helper.when.chooseFileFromPicker("example-style.json", "modal:open.dropzone");
await this.helper.when.wait(200);
select: (selector: string, value: string) => {
this.helper.get.elementByTestId(selector).select(value);
},
dropExampleFile: async () => {
await this.helper.when.dropFileByFixture("example-style.json", "modal:open.dropzone");
await this.helper.when.wait(200);
focus: (selector: string) => {
this.helper.when.focus(selector);
},
clickZoomIn: async () => {
await this.helper.get.element(".maplibregl-ctrl-zoom-in").click();
setValue: (selector: string, text: string) => {
this.helper.get
.elementByTestId(selector)
.clear()
.type(text, { parseSpecialCharSequences: false });
},
closePopup: async () => {
await this.helper.get.element(".maplibregl-popup-close-button").click();
setValueToPropertyArray: (selector: string, value: string) => {
this.when.doWithin(selector, () => {
this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true });
});
},
collapseGroupInLayerEditor: async (index = 0) => {
await this.helper.get.element(".maputnik-layer-editor-group__button").nth(index).click();
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 });
});
},
/** Expands/collapses a layer-editor group by its title, e.g. "Paint properties". */
toggleGroupInLayerEditor: async (title: string) => {
await this.helper.when.click("layer-editor-group:" + title);
closePopup: () => {
this.helper.get.element(".maplibregl-popup-close-button").click();
},
/**
* Picks a source for the selected layer from the source autocomplete.
* The autocomplete is a controlled (downshift) input, so the value has to be
* filled rather than typed key by key, then chosen from the filtered menu.
*/
changeLayerSource: async (sourceId: string) => {
const input = this.helper.get.elementByTestId("layer-editor.layer-source").locator("input");
await input.fill(sourceId);
await this.helper.get.element(".maputnik-autocomplete-menu-item").first().click();
collapseGroupInLayerEditor: (index = 0) => {
this.helper.get.element(".maputnik-layer-editor-group__button").eq(index).realClick();
},
appendTextInJsonEditor: async (text: string) => {
await this.helper.get.element(".cm-line").first().click();
// Move to the very start of the document so the inserted text breaks the
// root JSON structure (CodeMirror auto-closes brackets otherwise).
await this.helper.when.typeKeys("{home}");
await this.helper.when.typeText(text);
appendTextInJsonEditor: (text: string) => {
this.helper.get.element(".cm-line").first().click().type(text, { parseSpecialCharSequences: false });
},
setTextInJsonEditor: async (text: string) => {
await this.helper.get.element(".cm-line").first().click();
await this.helper.when.typeKeys("{selectall}");
await this.helper.when.typeText(text);
},
setValueToPropertyArray: async (selector: string, value: string) => {
const input = this.helper.get.elementByTestId(selector).locator(".maputnik-array-block-content input").last();
await input.focus();
await this.helper.when.typeKeys("{selectall}");
await this.helper.when.typeText(value);
},
addValueToPropertyArray: async (selector: string, value: string) => {
const block = this.helper.get.elementByTestId(selector);
await block.locator(".maputnik-array-add-value").click();
const input = block.locator(".maputnik-array-block-content input").last();
await input.focus();
await this.helper.when.typeKeys("{selectall}");
await this.helper.when.typeText(value);
},
makeZoomFunction: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.scrollIntoViewIfNeeded();
await container.locator(".maputnik-make-zoom-function").last().click({ force: true });
},
makeDataFunction: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.scrollIntoViewIfNeeded();
await container.locator(".maputnik-make-data-function").click({ force: true });
},
addFunctionStop: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator(".maputnik-add-stop").first().click({ force: true });
},
deleteFunctionStop: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator(".maputnik-delete-stop").first().click({ force: true });
},
/** Turns the property into a raw style expression. */
makeExpression: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.scrollIntoViewIfNeeded();
// In the plain spec field the expression button shares the zoom-function
// class and comes first; inside a function editor it has its own test id.
const inFunctionEditor = container.locator("[data-wd-key='convert-to-expression']");
const button =
(await inFunctionEditor.count()) > 0
? inFunctionEditor
: container.locator(".maputnik-make-zoom-function").first();
await button.click({ force: true });
},
/** Reverts an expression back to a plain value. */
undoExpression: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator("[data-wd-key='undo-expression']").click({ force: true });
},
/** Removes an expression, restoring the property's spec default. */
deleteExpression: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator("[data-wd-key='delete-expression']").click({ force: true });
},
/** Picks the function scale (categorical/interval/exponential/identity/interpolate). */
selectFunctionType: async (fieldName: string, type: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator("[data-wd-key='function-type'] select").selectOption(type);
},
/** Sets the "Base" input of a zoom/data function. */
setFunctionBase: async (fieldName: string, value: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator("[data-wd-key='function-base'] input").fill(value);
},
/**
* Sets the data property a data function keys off of. This is an InputString,
* which only commits its value on blur, so typing alone is not enough.
*/
setFunctionProperty: async (fieldName: string, value: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
const input = container.locator("[data-wd-key='function-property'] input");
await input.fill(value);
await input.blur();
},
/** Sets the fallback value used when a feature has no matching stop. */
setFunctionDefault: async (fieldName: string, value: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator("[data-wd-key='function-default'] input").fill(value);
},
/** Edits one cell of a function's stop table ("Zoom", "Input value" or "Output value"). */
setFunctionStopValue: async (fieldName: string, column: string, index: number, value: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator(`[aria-label="${column}"]`).nth(index).fill(value);
},
addFilter: async () => {
const button = this.helper.get.elementByTestId("layer-filter-button");
await button.scrollIntoViewIfNeeded();
await button.click({ force: true });
},
selectFilterOperator: async (value: string) => {
await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value);
},
/** Chooses how the filter items combine: all / none / any. */
selectFilterCombiningOperator: async (value: string) => {
await this.helper.when.selectWithin("filter-combining-operator", value);
},
deleteFilterItem: async (index = 0) => {
await this.helper.get
.element(".maputnik-filter-editor-block-action .maputnik-icon-button")
.nth(index)
.click();
},
/** Converts the simple filter editor into a raw expression editor. */
convertFilterToExpression: async () => {
await this.helper.when.click("filter-convert-to-expression");
},
/**
* Deletes the filter expression, restoring the simple filter editor.
* The filter group precedes the paint group, so its button comes first.
*/
deleteFilterExpression: async () => {
await this.helper.get.element("[data-wd-key='delete-expression']").first().click();
},
setColorValue: async (fieldName: string, value: string) => {
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator(".maputnik-color");
await input.fill(value);
},
/** Sets a plain string spec field (e.g. a pattern), which has no dedicated input test id. */
setStringValue: async (fieldName: string, value: string) => {
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator("input.maputnik-string");
await input.fill(value);
await input.blur();
},
/**
* Appends text to the end of the JSON editor line holding `lineText`.
* CodeMirror types over its own auto-inserted closing quotes/brackets, so a
* well-formed fragment stays well-formed.
*/
appendToJsonEditorLine: async (lineText: string, text: string) => {
await this.helper.when.clickByText(lineText);
await this.helper.when.typeKeys("{end}");
await this.helper.when.typeText(text);
},
waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"),
/** Fill localStorage until we get a QuotaExceededError. */
fillLocalStorage: () => this.helper.when.fillLocalStorageUntilQuota("maputnik:fill-"),
setTextInJsonEditor: (text: string) => {
this.helper.get.element(".cm-line").first().click().clear().type(text, { parseSpecialCharSequences: false });
}
};
public get = {
...this.helper.get,
isMac: () => isMac,
canvas: () => this.helper.get.element("canvas"),
searchControl: () => this.helper.get.element(".maplibregl-ctrl-geocoder"),
skipTargetLayerList: () => this.helper.get.elementByTestId("skip-target-layer-list"),
skipTargetLayerEditor: () => this.helper.get.elementByTestId("skip-target-layer-editor"),
styleFromLocalStorage: () => this.helper.query(() => this.readStoredStyle()),
fixture: (name: string) => this.helper.readFixture(name),
responseBody: (alias: string) => {
// Our mocked style responses always return the matching fixture.
const name = alias.endsWith(".json") ? alias : `${alias}.json`;
return this.helper.readFixture(name);
isMac: () => {
return Cypress.platform === "darwin";
},
exampleFileUrl: () => baseUrl + "example-style.json",
styleFromLocalStorage: () =>
this.helper.get.window().then((win) => styleFromWindow(win)),
exampleFileUrl: () => {
return baseUrl + "example-style.json";
},
skipTargetLayerList: () =>
this.helper.get.elementByTestId("skip-target-layer-list"),
skipTargetLayerEditor: () =>
this.helper.get.elementByTestId("skip-target-layer-editor"),
canvas: () => this.helper.get.element("canvas"),
searchControl: () => this.helper.get.element(".maplibregl-ctrl-geocoder")
};
}
+26 -64
View File
@@ -1,78 +1,40 @@
import { v1 as uuid } from "uuid";
import { PlaywrightHelper } from "./playwright-helper";
import MaputnikCypressHelper from "./maputnik-cypress-helper";
export class ModalDriver {
private readonly helper = new PlaywrightHelper();
export default class ModalDriver {
private helper = new MaputnikCypressHelper();
public when = {
fillLayers: async (opts: { type: string; layer?: string; id?: string }) => {
const { when, get, then } = this.helper;
const id = opts.id ?? `${opts.type}:${uuid()}`;
await when.select("add-layer.layer-type.select", opts.type);
await when.type("add-layer.layer-id.input", id);
if (opts.layer) {
const input = get.elementByTestId("add-layer.layer-source-block").locator("input");
await input.click();
await input.fill(opts.layer);
// The source input is a controlled downshift combobox; wait for React to
// settle on the typed value before submitting.
await then(input).shouldHaveValue(opts.layer);
// Close the autocomplete menu so it does not intercept the add button.
await get.elementByTestId("add-layer.layer-id.input").click();
fillLayers: (opts: { type: string; layer?: string; id?: string }) => {
// Having logic in test code is an anti pattern.
// This should be split to multiple single responsibility functions
const type = opts.type;
const layer = opts.layer;
let id;
if (opts.id) {
id = opts.id;
} else {
id = `${type}:${uuid()}`;
}
await when.click("add-layer");
this.helper.when.selectOption("add-layer.layer-type.select", type);
this.helper.when.type("add-layer.layer-id.input", id);
if (layer) {
this.helper.when.doWithin(() => {
this.helper.get.element("input").clear().type(layer!);
}, "add-layer.layer-source-block");
}
this.helper.when.click("add-layer");
return id;
},
open: async () => {
await this.helper.when.click("layer-list:add-layer");
open: () => {
this.helper.when.click("layer-list:add-layer");
},
close: async (key: string) => {
await this.helper.when.click(key + ".close-modal");
},
/**
* Adds a source of the given type from the sources modal, keeping whatever
* defaults that type's editor prefills.
*/
addSource: async (sourceId: string, sourceType: string) => {
const { when } = this.helper;
await when.setValue("modal:sources.add.source_id", sourceId);
await when.select("modal:sources.add.source_type", sourceType);
await when.click("modal:sources.add.add_source");
await when.wait(200);
},
/** Adds one of the predefined public sources listed in the sources modal. */
addPublicSource: async (index = 0) => {
await this.helper.get.element(".maputnik-public-source-select").nth(index).click();
},
deleteFirstActiveSource: async () => {
await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click();
},
/** Fills one number box of a coordinate pair in the image/video source editor. */
setCoordinateValue: async (index: number, value: string) => {
const input = this.helper.get
.elementByTestId("modal:sources")
.locator(".maputnik-array input")
.nth(index);
await input.fill(value);
await input.blur();
},
exportCreateHtml: async () => {
await this.helper.get.element(".maputnik-modal-export-buttons button").last().click();
},
exportSaveStyle: async () => {
await this.helper.stubSaveFilePicker();
await this.helper.get.element(".maputnik-modal-export-buttons button").first().click();
close: (key: string) => {
this.helper.when.click(key + ".close-modal");
},
};
}
+277 -397
View File
@@ -1,142 +1,103 @@
import { test, expect, describe, beforeEach } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
import tokens from "../src/config/tokens.json" with { type: "json" };
import tokens from "../src/config/tokens.json" with {type: "json"};
const test = it;
describe("modals", () => {
const { given, get, when, then } = new MaputnikDriver();
const { beforeAndAfter, when, get, given, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
await when.setStyle("");
beforeEach(() => {
when.setStyle("");
});
describe("open", () => {
beforeEach(async () => {
await when.click("nav:open");
beforeEach(() => {
when.click("nav:open");
});
test("close", async () => {
await when.modal.close("modal:open");
await then(get.elementByTestId("modal:open")).shouldNotExist();
test("close", () => {
when.modal.close("modal:open");
then(get.elementByTestId("modal:open")).shouldNotExist();
});
test("upload", async () => {
await when.chooseExampleFile();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
test("upload", () => {
when.chooseExampleFile();
then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
});
test("upload via drag and drop", async () => {
await when.dropExampleFile();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
test("upload via drag and drop", () => {
when.dropExampleFile();
then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
});
describe("when click open url", () => {
beforeEach(async () => {
beforeEach(() => {
const styleFileUrl = get.exampleFileUrl();
await when.setValue("modal:open.url.input", styleFileUrl);
await when.click("modal:open.url.button");
await when.wait(200);
when.setValue("modal:open.url.input", styleFileUrl);
when.click("modal:open.url.button");
when.wait(200);
});
test("load from url", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json"));
});
});
describe("without the File System Access API", () => {
test("upload via the file chooser", async () => {
await given.noFileSystemAccessApi();
await when.setStyle("");
await when.click("nav:open");
await when.chooseExampleFileFromPicker();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
test("load from url", () => {
then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
});
});
});
describe("shortcuts", () => {
test("open/close", async () => {
await when.setStyle("");
await when.typeKeys("?");
await when.modal.close("modal:shortcuts");
await then(get.elementByTestId("modal:shortcuts")).shouldNotExist();
test("open/close", () => {
when.setStyle("");
when.typeKeys("?");
when.modal.close("modal:shortcuts");
then(get.elementByTestId("modal:shortcuts")).shouldNotExist();
});
});
describe("export", () => {
beforeEach(async () => {
await when.click("nav:export");
beforeEach(() => {
when.click("nav:export");
});
test("close", async () => {
await when.modal.close("modal:export");
await then(get.elementByTestId("modal:export")).shouldNotExist();
test("close", () => {
when.modal.close("modal:export");
then(get.elementByTestId("modal:export")).shouldNotExist();
});
test("download HTML and save the style", async () => {
// Generate the standalone HTML export (triggers a file download).
await when.modal.exportCreateHtml();
await then(get.elementByTestId("modal:export")).shouldExist();
// Saving the style closes the export modal.
await when.modal.exportSaveStyle();
await then(get.elementByTestId("modal:export")).shouldNotExist();
});
// TODO: Work out how to download a file and check the contents
test("download");
});
describe("sources", () => {
beforeEach(async () => {
await when.setStyle("layer");
await when.click("nav:sources");
beforeEach(() => {
when.setStyle("layer");
when.click("nav:sources");
});
test("active sources are listed and can be deleted", async () => {
await when.setStyle("both");
await when.click("nav:sources");
const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length;
await when.modal.deleteFirstActiveSource();
await then(
get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length)
).shouldEqual(before - 1);
});
test("active sources");
test("public source");
test("public source", async () => {
await when.modal.addPublicSource();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
openmaptiles: {
type: "vector",
url: `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`,
},
},
});
});
test("add new source", async () => {
test("add new source", () => {
const sourceId = "n1z2v3r";
await when.setValue("modal:sources.add.source_id", sourceId);
await when.select("modal:sources.add.source_type", "tile_vector");
await when.select("modal:sources.add.scheme_type", "tms");
await when.click("modal:sources.add.add_source");
await when.wait(200);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: { [sourceId]: { scheme: "tms" } },
when.setValue("modal:sources.add.source_id", sourceId);
when.select("modal:sources.add.source_type", "tile_vector");
when.select("modal:sources.add.scheme_type", "tms");
when.click("modal:sources.add.add_source");
when.wait(200);
then(
get.styleFromLocalStorage().then((style) => style.sources[sourceId])
).shouldInclude({
scheme: "tms",
});
});
test("add new pmtiles source", async () => {
test("add new pmtiles source", () => {
const sourceId = "pmtilestest";
await when.setValue("modal:sources.add.source_id", sourceId);
await when.select("modal:sources.add.source_type", "pmtiles_vector");
await when.setValue(
"modal:sources.add.source_url",
"https://data.source.coop/protomaps/openstreetmap/v4.pmtiles"
);
await when.click("modal:sources.add.add_source");
await when.click("modal:sources.add.add_source");
await when.wait(200);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
when.setValue("modal:sources.add.source_id", sourceId);
when.select("modal:sources.add.source_type", "pmtiles_vector");
when.setValue("modal:sources.add.source_url", "https://data.source.coop/protomaps/openstreetmap/v4.pmtiles");
when.click("modal:sources.add.add_source");
when.click("modal:sources.add.add_source");
when.wait(200);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
pmtilestest: {
type: "vector",
@@ -146,432 +107,351 @@ describe("modals", () => {
});
});
test("add new raster source", async () => {
test("add new raster source", () => {
const sourceId = "rastertest";
await when.setValue("modal:sources.add.source_id", sourceId);
await when.select("modal:sources.add.source_type", "tile_raster");
await when.select("modal:sources.add.scheme_type", "xyz");
await when.setValue("modal:sources.add.tile_size", "128");
await when.click("modal:sources.add.add_source");
await when.wait(200);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: { [sourceId]: { tileSize: 128 } },
});
});
test("add new geojson url source", async () => {
await when.modal.addSource("geojsonurl", "geojson_url");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
geojsonurl: { type: "geojson", data: "http://localhost:3000/geojson.json" },
},
});
});
test("add new geojson json source", async () => {
await when.modal.addSource("geojsonjson", "geojson_json");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
geojsonjson: { type: "geojson", cluster: false, data: "" },
},
});
});
test("add new tilejson vector source", async () => {
await when.modal.addSource("tilejsonvector", "tilejson_vector");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
tilejsonvector: { type: "vector", url: "http://localhost:3000/tilejson.json" },
},
});
});
test("add new tilejson raster source", async () => {
await when.modal.addSource("tilejsonraster", "tilejson_raster");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
tilejsonraster: { type: "raster", url: "http://localhost:3000/tilejson.json" },
},
});
});
test("add new tilejson raster-dem source", async () => {
await when.modal.addSource("tilejsonrasterdem", "tilejson_raster-dem");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
tilejsonrasterdem: { type: "raster-dem", url: "http://localhost:3000/tilejson.json" },
},
});
});
test("add new tile xyz raster-dem source", async () => {
await when.modal.addSource("tilexyzrasterdem", "tilexyz_raster-dem");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
tilexyzrasterdem: {
type: "raster-dem",
tiles: ["http://localhost:3000/{x}/{y}/{z}.png"],
minzoom: 0,
maxzoom: 14,
tileSize: 512,
},
},
});
});
test("add new image source", async () => {
await when.modal.addSource("imagesource", "image");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
imagesource: {
type: "image",
url: "http://localhost:3000/image.png",
coordinates: [[0, 0], [0, 0], [0, 0], [0, 0]],
},
},
});
});
test("add new video source", async () => {
await when.modal.addSource("videosource", "video");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
videosource: {
type: "video",
urls: ["http://localhost:3000/movie.mp4"],
coordinates: [[0, 0], [0, 0], [0, 0], [0, 0]],
},
},
});
});
test("edit the corner coordinates of an image source", async () => {
const sourceId = "imagecoords";
await when.setValue("modal:sources.add.source_id", sourceId);
await when.select("modal:sources.add.source_type", "image");
// The first corner is the first two number boxes of the coordinate arrays.
await when.modal.setCoordinateValue(0, "1");
await when.modal.setCoordinateValue(1, "2");
await when.click("modal:sources.add.add_source");
await when.wait(200);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
[sourceId]: { type: "image", coordinates: [[1, 2], [0, 0], [0, 0], [0, 0]] },
},
when.setValue("modal:sources.add.source_id", sourceId);
when.select("modal:sources.add.source_type", "tile_raster");
when.select("modal:sources.add.scheme_type", "xyz");
when.setValue("modal:sources.add.tile_size", "128");
when.click("modal:sources.add.add_source");
when.wait(200);
then(
get.styleFromLocalStorage().then((style) => style.sources[sourceId])
).shouldInclude({
tileSize: 128,
});
});
});
describe("inspect", () => {
test("toggle", async () => {
test("toggle", () => {
// There is no assertion in this test
await when.setStyle("geojson");
await when.select("maputnik-select", "inspect");
when.setStyle("geojson");
when.select("maputnik-select", "inspect");
});
});
describe("style settings", () => {
beforeEach(async () => {
await when.click("nav:settings");
beforeEach(() => {
when.click("nav:settings");
});
describe("when click name filed spec information", () => {
beforeEach(async () => {
await when.click("field-doc-button-Name");
beforeEach(() => {
when.click("field-doc-button-Name");
});
test("should show the spec information", async () => {
await then(get.elementsText("spec-field-doc")).shouldInclude("name for the style");
test("should show the spec information", () => {
then(get.elementsText("spec-field-doc")).shouldInclude(
"name for the style"
);
});
});
describe("when set name and click owner", () => {
beforeEach(async () => {
await when.setValue("modal:settings.name", "foobar");
await when.click("modal:settings.owner");
await when.wait(200);
beforeEach(() => {
when.setValue("modal:settings.name", "foobar");
when.click("modal:settings.owner");
when.wait(200);
});
test("show name specifications", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("show name specifications", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
name: "foobar",
});
});
});
describe("when set owner and click name", () => {
beforeEach(async () => {
await when.setValue("modal:settings.owner", "foobar");
await when.click("modal:settings.name");
await when.wait(200);
beforeEach(() => {
when.setValue("modal:settings.owner", "foobar");
when.click("modal:settings.name");
when.wait(200);
});
test("should update owner in local storage", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("should update owner in local storage", () => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
owner: "foobar",
});
});
});
test("sprite url", async () => {
await when.setTextInJsonEditor('"http://example.com"');
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("sprite url", () => {
when.setTextInJsonEditor("\"http://example.com\"");
when.click("modal:settings.name");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sprite: "http://example.com",
});
});
test("sprite object", async () => {
await when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }]));
test("sprite object", () => {
when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }]));
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
when.click("modal:settings.name");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sprite: [{ id: "1", url: "2" }],
});
});
test("glyphs url", async () => {
test("glyphs url", () => {
const glyphsUrl = "http://example.com/{fontstack}/{range}.pbf";
await when.setValue("modal:settings.glyphs", glyphsUrl);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
when.setValue("modal:settings.glyphs", glyphsUrl);
when.click("modal:settings.name");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
glyphs: glyphsUrl,
});
});
test("maptiler access token", async () => {
test("maptiler access token", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:openmaptiles_access_token": apiKey },
when.setValue(
"modal:settings.maputnik:openmaptiles_access_token",
apiKey
);
when.click("modal:settings.name");
then(
get.styleFromLocalStorage().then((style) => style.metadata)
).shouldInclude({
"maputnik:openmaptiles_access_token": apiKey,
});
});
test("thunderforest access token", async () => {
test("thunderforest access token", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:thunderforest_access_token": apiKey },
});
when.setValue(
"modal:settings.maputnik:thunderforest_access_token",
apiKey
);
when.click("modal:settings.name");
then(
get.styleFromLocalStorage().then((style) => style.metadata)
).shouldInclude({ "maputnik:thunderforest_access_token": apiKey });
});
test("stadia access token", async () => {
test("stadia access token", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:stadia_access_token": apiKey },
});
when.setValue(
"modal:settings.maputnik:stadia_access_token",
apiKey
);
when.click("modal:settings.name");
then(
get.styleFromLocalStorage().then((style) => style.metadata)
).shouldInclude({ "maputnik:stadia_access_token": apiKey });
});
test("locationiq access token", async () => {
test("locationiq access token", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:locationiq_access_token": apiKey },
});
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 });
});
test("map view defaults", async () => {
await when.setValue("modal:settings.zoom", "4");
await when.setValue("modal:settings.bearing", "12");
await when.setValue("modal:settings.pitch", "30");
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
zoom: 4,
bearing: 12,
pitch: 30,
});
test("style projection mercator", () => {
when.select("modal:settings.projection", "mercator");
then(
get.styleFromLocalStorage().then((style) => style.projection)
).shouldInclude({ type: "mercator" });
});
test("light intensity", async () => {
await when.setValue("modal:settings.light-intensity", "0.7");
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
light: { intensity: 0.7 },
});
test("style projection globe", () => {
when.select("modal:settings.projection", "globe");
then(
get.styleFromLocalStorage().then((style) => style.projection)
).shouldInclude({ type: "globe" });
});
test("terrain source and exaggeration", async () => {
await when.setValue("modal:settings.maputnik:terrain_source", "terrain");
await when.setValue("modal:settings.terrain-exaggeration", "1.5");
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
terrain: { source: "terrain", exaggeration: 1.5 },
});
test("style projection vertical-perspective", () => {
when.select("modal:settings.projection", "vertical-perspective");
then(
get.styleFromLocalStorage().then((style) => style.projection)
).shouldInclude({ type: "vertical-perspective" });
});
test("transition delay and duration", async () => {
await when.setValue("modal:settings.transition-delay", "100");
await when.setValue("modal:settings.transition-duration", "500");
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
transition: { delay: 100, duration: 500 },
});
});
test("style renderer", () => {
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");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
"ol"
);
test("style projection mercator", async () => {
await when.select("modal:settings.projection", "mercator");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
projection: { type: "mercator" },
});
});
test("style projection globe", async () => {
await when.select("modal:settings.projection", "globe");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
projection: { type: "globe" },
});
});
test("style projection vertical-perspective", async () => {
await when.select("modal:settings.projection", "vertical-perspective");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
projection: { type: "vertical-perspective" },
});
});
test("style renderer", async () => {
await when.select("modal:settings.maputnik:renderer", "ol");
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol");
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
when.click("modal:settings.name");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:renderer": "ol" },
});
});
test("include API key when change renderer", async () => {
await when.click("modal:settings.close-modal");
await when.click("nav:open");
await when.clickByAttribute("aria-label", "MapTiler Basic");
await when.wait(1000);
await when.click("nav:settings");
await when.select("modal:settings.maputnik:renderer", "mlgljs");
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("mlgljs");
test("include API key when change renderer", () => {
await when.select("modal:settings.maputnik:renderer", "ol");
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol");
when.click("modal:settings.close-modal");
when.click("nav:open");
await given.intercept(
/https:\/\/api\.maptiler\.com\/tiles\/v3-openmaptiles\/tiles\.json\?key=.*/,
"tileRequest",
"GET"
get.elementByAttribute("aria-label", "MapTiler Basic").should("exist").click();
when.wait(1000);
when.click("nav:settings");
when.select("modal:settings.maputnik:renderer", "mlgljs");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
"mlgljs"
);
await when.select("modal:settings.maputnik:renderer", "mlgljs");
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("mlgljs");
const request = await when.waitForResponse("tileRequest");
expect(request.url()).toContain(
`https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`
when.select("modal:settings.maputnik:renderer", "ol");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
"ol"
);
given.intercept("https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=*", "tileRequest", "GET");
when.select("modal:settings.maputnik:renderer", "mlgljs");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
"mlgljs"
);
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
});
});
describe("add layer", () => {
beforeEach(async () => {
await when.setStyle("layer");
await when.modal.open();
beforeEach(() => {
when.setStyle("layer");
when.modal.open();
});
test("shows duplicate id error", async () => {
await when.setValue("add-layer.layer-id.input", "background");
await when.click("add-layer");
await then(get.elementByTestId("modal:add-layer")).shouldExist();
await then(get.element(".maputnik-modal-error")).shouldContainText("Layer ID already exists");
test("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", () => {
test("toggle");
});
describe("global state", () => {
beforeEach(async () => {
await when.click("nav:global-state");
beforeEach(() => {
when.click("nav:global-state");
});
test("add variable", async () => {
await when.wait(100);
await when.click("global-state-add-variable");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("add variable", () => {
when.wait(100);
when.click("global-state-add-variable");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key1: { default: "value" } },
});
});
test("add multiple variables", async () => {
await when.click("global-state-add-variable");
await when.click("global-state-add-variable");
await when.click("global-state-add-variable");
await when.wait(100);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("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" } },
});
});
test("remove variable", async () => {
await when.click("global-state-add-variable");
await when.click("global-state-add-variable");
await when.click("global-state-add-variable");
await when.click("global-state-remove-variable", 0);
await when.wait(100);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("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" } },
});
});
test("edit variable key", async () => {
await when.click("global-state-add-variable");
await when.wait(100);
await when.setValue("global-state-variable-key:0", "mykey");
await when.typeKeys("{enter}");
await when.wait(100);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("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" } },
});
});
test("edit variable value", async () => {
await when.click("global-state-add-variable");
await when.wait(100);
await when.setValue("global-state-variable-value:0", "myvalue");
await when.typeKeys("{enter}");
await when.wait(100);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
test("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", () => {
test("not visible when no errors", async () => {
await then(get.element("maputnik-message-panel-error")).shouldNotExist();
test("not visible when no errors", () => {
then(get.element("maputnik-message-panel-error")).shouldNotExist();
});
test("visible on style error", async () => {
await when.modal.open();
await when.modal.fillLayers({
test("visible on style error", () => {
when.modal.open();
when.modal.fillLayers({
type: "circle",
layer: "invalid",
});
await then(get.element(".maputnik-message-panel-error")).shouldBeVisible();
then(get.element(".maputnik-message-panel-error")).shouldBeVisible();
});
});
describe("Handle localStorage QuotaExceededError", () => {
test("handles quota exceeded error when opening style from URL", async () => {
test("handles quota exceeded error when opening style from URL", () => {
// Clear localStorage to start fresh
await when.clearLocalStorage();
await when.fillLocalStorage();
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
await when.click("nav:open");
await when.setValue("modal:open.url.input", get.exampleFileUrl());
await when.click("modal:open.url.button");
when.click("nav:open");
when.setValue("modal:open.url.input", get.exampleFileUrl());
when.click("modal:open.url.button");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json"));
await then(get.styleFromLocalStorage()).shouldExist();
then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
then(get.styleFromLocalStorage()).shouldExist();
});
});
});
-453
View File
@@ -1,453 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, type Locator, type Page, type Request } from "@playwright/test";
import { currentPage, recordCoverageChunk } from "./utils/fixtures";
const DATA_ATTRIBUTE = "data-wd-key";
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const isMac = process.platform === "darwin";
function testIdSelector(testId: string): string {
return `[${DATA_ATTRIBUTE}="${testId}"]`;
}
/** Retries `assertion` until it stops throwing */
async function retry(
assertion: () => Promise<void> | void,
timeout = 10000,
interval = 100
): Promise<void> {
const start = Date.now();
let lastError: unknown;
while (true) {
try {
await assertion();
return;
} catch (error) {
lastError = error;
if (Date.now() - start > timeout) throw lastError;
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
}
/**
* A lazily-evaluated value (e.g. the style in localStorage). Assertions on a
* Query re-read the value until they pass.
*/
class Query<T> {
readonly __query = true as const;
constructor(private readonly getter: () => Promise<T>) {}
get(): Promise<T> {
return this.getter();
}
then<U>(mapper: (value: T) => U | Promise<U>): Query<U> {
return new Query<U>(async () => mapper(await this.getter()));
}
}
function isQuery(target: unknown): target is Query<unknown> {
return typeof target === "object" && target !== null && (target as Query<unknown>).__query === true;
}
function isLocator(target: unknown): target is Locator {
return (
typeof target === "object" &&
target !== null &&
typeof (target as Locator).count === "function" &&
typeof (target as Locator).boundingBox === "function"
);
}
/**
* Asserts that `actual` recursively contains everything in `expected`: nested
* objects are matched as subsets, while arrays and primitives must match exactly.
*/
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown> | unknown[]): void {
expect(actual).toMatchObject(expected);
}
/**
* Fluent, auto-retrying assertions over a Playwright Locator or a lazily
* evaluated value/Query. This is the generic base that the maputnik-specific
* assertable extends.
*/
export class Assertable<T> {
constructor(private readonly target: T) {}
private locator(): Locator {
if (!isLocator(this.target)) throw new Error("Expected a Locator target for this assertion");
return this.target;
}
protected async assertValue(assertion: (value: any) => void): Promise<void> {
const target = this.target;
if (isQuery(target)) {
await retry(async () => assertion(await target.get()));
} else {
assertion(await (target as any));
}
}
// Element assertions (auto-retrying via Playwright web-first assertions).
shouldBeVisible = () => expect(this.locator().first()).toBeVisible();
// Some testids resolve to many elements that are always rendered but hidden
// (e.g. per-field documentation panels); "not visible" means none is visible.
shouldNotBeVisible = () => expect(this.locator().filter({ visible: true })).toHaveCount(0);
shouldExist = async () => {
if (isLocator(this.target)) {
await expect(this.locator().first()).toBeAttached();
} else {
await this.assertValue((value) => expect(value).toBeTruthy());
}
};
shouldNotExist = () => expect(this.locator()).toHaveCount(0);
shouldBeFocused = () => expect(this.locator().first()).toBeFocused();
shouldNotBeFocused = () => expect(this.locator().first()).not.toBeFocused();
shouldHaveValue = (value: string) => expect(this.locator().first()).toHaveValue(value);
shouldContainText = async (text: string) => {
const locator = this.locator();
// Prefer the visible element when a testid resolves to several (only the
// open documentation panel is visible; the rest are hidden in the DOM).
const target = (await locator.count()) > 1 ? locator.filter({ visible: true }).first() : locator.first();
await expect(target).toContainText(text);
};
shouldHaveText = (text: string) => expect(this.locator().first()).toHaveText(text);
shouldHaveLength = (length: number) => expect(this.locator()).toHaveCount(length);
shouldHaveCss = (property: string, value: string) => expect(this.locator().first()).toHaveCSS(property, value);
// Value assertions (auto-retrying for Query targets).
shouldEqual = (value: any) => this.assertValue((actual) => expect(actual).toBe(value));
shouldInclude = (value: any) =>
this.assertValue((actual) => {
if (typeof value === "object" && value !== null) {
expect(actual).toMatchObject(value);
} else {
expect(String(actual)).toContain(String(value));
}
});
shouldDeepNestedInclude = (value: Record<string, unknown> | unknown[]) =>
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
}
async function typeSequence(page: Page, text: string): Promise<void> {
const tokens = text.match(/\{[^}]+\}|[^{]+/g) ?? [];
const modifierMap: Record<string, string> = { meta: "Meta", ctrl: "Control", shift: "Shift", alt: "Alt" };
const namedKeys: Record<string, string> = {
esc: "Escape",
enter: "Enter",
backspace: "Backspace",
del: "Delete",
tab: "Tab",
home: "Home",
end: "End",
rightarrow: "ArrowRight",
};
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (!token.startsWith("{") || !token.endsWith("}")) {
await page.keyboard.type(token);
continue;
}
const name = token.slice(1, -1).toLowerCase();
if (name === "selectall") {
await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
} else if (namedKeys[name]) {
await page.keyboard.press(namedKeys[name]);
} else if (modifierMap[name]) {
const modifiers = [modifierMap[name]];
let j = i + 1;
while (j < tokens.length && /^\{(meta|ctrl|shift|alt)\}$/i.test(tokens[j])) {
modifiers.push(modifierMap[tokens[j].slice(1, -1).toLowerCase()]);
j++;
}
const key = tokens[j] ?? "";
await page.keyboard.press([...modifiers, key].join("+"));
i = j;
}
}
}
async function centerOf(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox();
if (!box) throw new Error("Element has no bounding box");
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/**
* This is where all plywright-specific test helpers live.
* It is used by the MaputnikDriver to implement the Maputnik-specific test helpers.
*/
export class PlaywrightHelper {
private readonly recordedRequests = new Map<string, Request[]>();
private get page(): Page {
return currentPage();
}
private testId(testId: string): Locator {
return this.page.locator(testIdSelector(testId));
}
/** Reads and parses a JSON fixture from the fixtures directory. */
public readFixture(name: string): any {
return JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, name), "utf-8"));
}
/** Wraps a lazily-evaluated value so assertions on it auto-retry. */
public query<T>(getter: () => Promise<T>): Query<T> {
return new Query<T>(getter);
}
/** Stubs the File System Access "save" picker so file saves complete headlessly. */
public stubSaveFilePicker(): Promise<void> {
return this.page.evaluate(() => {
(window as any).showSaveFilePicker = async () => ({
createWritable: async () => ({ write: async () => {}, close: async () => {} }),
});
});
}
/** Entry point for fluent assertions over a Locator or a value/Query. */
public then = <T>(target: T): Assertable<T> => new Assertable(target);
public given = {
/**
* Removes the File System Access API so the app falls back to a plain
* <input type="file">, the way Firefox and Safari behave. Must be called
* before the page under test is loaded.
*/
noFileSystemAccessApi: async () => {
await this.page.addInitScript(() => {
delete (window as any).showOpenFilePicker;
delete (window as any).showSaveFilePicker;
});
},
intercept: async (pattern: RegExp, alias: string, _method = "GET") => {
this.recordedRequests.set(alias, []);
await this.page.route(pattern, (route) => {
this.recordedRequests.get(alias)!.push(route.request());
route.continue();
});
},
interceptAndMockResponse: async (options: {
method?: string;
url: string | RegExp;
response: unknown | { fixture: string };
alias?: string;
}) => {
const { url, response, alias } = options;
if (alias) this.recordedRequests.set(alias, []);
await this.page.route(url, (route) => {
if (alias) this.recordedRequests.get(alias)!.push(route.request());
const body =
response && typeof response === "object" && "fixture" in (response as any)
? this.readFixture((response as { fixture: string }).fixture)
: response;
route.fulfill({ json: body });
});
},
};
public when = {
visit: async (url: string) => {
// Snapshot coverage before navigating, since a full page load resets it.
await recordCoverageChunk(this.page);
await this.page.goto(url);
},
wait: (ms: number) => this.page.waitForTimeout(ms),
tab: () => this.page.keyboard.press("Tab"),
typeKeys: (keys: string) => typeSequence(this.page, keys),
/** Types raw text into the focused element (no "{key}" sequence parsing). */
typeText: (text: string) => this.page.keyboard.type(text),
clickButtonByName: async (name: string) => {
await this.page.getByRole("button", { name }).click();
},
click: async (testId: string, index = 0) => {
// Documentation buttons are wrapped in a <label>/.maputnik-doc-target that
// Playwright treats as intercepting the click; bypass the check for them.
const force = testId.startsWith("field-doc-button-");
await this.testId(testId).nth(index).click({ force });
},
realClick: async (testId: string) => {
await this.testId(testId).click();
},
hover: async (testId: string) => {
await this.testId(testId).hover();
},
focus: async (testId: string) => {
await this.testId(testId).focus();
},
clear: async (testId: string) => {
await this.testId(testId).clear();
},
select: async (testId: string, value: string) => {
await this.testId(testId).selectOption(value);
},
selectWithin: async (parentTestId: string, value: string) => {
await this.testId(parentTestId).locator("select").selectOption(value);
},
clickWithin: async (parentTestId: string, selector: string) => {
await this.testId(parentTestId).locator(selector).first().click();
},
clickByText: async (text: string) => {
await this.page.getByText(text).click();
},
clickByAttribute: async (attribute: string, value: string) => {
await this.page.locator(`[${attribute}="${value}"]`).click();
},
scrollToBottom: async (element: Locator) => {
await element.evaluate((el) => el.scrollTo(0, el.scrollHeight));
},
setValue: async (testId: string, text: string) => {
const input = this.testId(testId);
await input.fill("");
await input.fill(text);
},
type: async (testId: string, text: string) => {
await this.testId(testId).focus();
// Place the caret at the start of the field, so a leading "{backspace}"
// is a no-op rather than clearing an already-committed value.
await this.page.keyboard.press("Home");
await typeSequence(this.page, text);
},
dragAndDropWithWait: async (source: string, target: string) => {
const from = await centerOf(this.testId(source));
const to = await centerOf(this.testId(target));
await this.page.mouse.move(from.x, from.y);
await this.page.mouse.down();
await this.page.mouse.move(from.x, from.y + 10);
await this.page.mouse.move(to.x, to.y, { steps: 10 });
await this.page.waitForTimeout(100);
await this.page.mouse.up();
},
clickCenter: async (testId: string) => {
const { x, y } = await centerOf(this.testId(testId));
await this.page.mouse.move(x, y);
await this.page.mouse.down();
await this.page.waitForTimeout(200);
await this.page.mouse.up();
},
/**
* Opens a fixture through the File System Access API, which raises no
* "filechooser" event and so has to be stubbed. For the <input type="file">
* fallback that browsers without the API use, see chooseFileFromPicker.
*/
openFileByFixture: async (fixture: string, buttonTestId: string) => {
const content = JSON.stringify(this.readFixture(fixture));
await this.page.evaluate((fileContent) => {
(window as any).showOpenFilePicker = async () => [
{ getFile: async () => ({ text: async () => fileContent }) },
];
}, content);
await this.testId(buttonTestId).click();
},
/**
* Clicks a control that opens the browser's native file chooser and answers
* it with a fixture. Only works on the <input type="file"> path — the File
* System Access API does not raise a "filechooser" event, so pair this with
* given.noFileSystemAccessApi().
*/
chooseFileFromPicker: async (fixture: string, triggerTestId: string) => {
const content = JSON.stringify(this.readFixture(fixture));
const [fileChooser] = await Promise.all([
this.page.waitForEvent("filechooser"),
this.testId(triggerTestId).click(),
]);
await fileChooser.setFiles({
name: fixture,
mimeType: "application/json",
buffer: Buffer.from(content),
});
},
dropFileByFixture: async (fixture: string, dropzoneTestId: string) => {
const content = JSON.stringify(this.readFixture(fixture));
const dataTransfer = await this.page.evaluateHandle((fileContent) => {
const dt = new DataTransfer();
dt.items.add(new File([fileContent], "example-style.json", { type: "application/json" }));
return dt;
}, content);
const dropzone = this.testId(dropzoneTestId);
await dropzone.dispatchEvent("dragenter", { dataTransfer });
await dropzone.dispatchEvent("dragover", { dataTransfer });
await dropzone.dispatchEvent("drop", { dataTransfer });
},
waitForResponse: async (alias: string) => {
const requests = this.recordedRequests.get(alias);
if (!requests) throw new Error(`No intercept registered for alias "${alias}"`);
await retry(async () => {
if (requests.length === 0) throw new Error(`No request recorded for alias "${alias}"`);
});
return requests[requests.length - 1];
},
clearLocalStorage: () => this.page.evaluate(() => window.localStorage.clear()),
/** Writes to localStorage under `keyPrefix` until a QuotaExceededError is hit. */
fillLocalStorageUntilQuota: (keyPrefix: string) =>
this.page.evaluate((prefix) => {
let chunkSize = 1000;
const chunk = new Array(chunkSize).join("x");
let index = 0;
while (true) {
try {
window.localStorage.setItem(`${prefix}${index++}`, chunk);
} catch (e: any) {
if (e.name === "QuotaExceededError") {
if (chunkSize <= 1) return;
chunkSize /= 2;
continue;
}
throw e; // Unexpected error
}
}
}, keyPrefix),
};
public get = {
element: (selector: string) => this.page.locator(selector),
localStorageItem: (key: string) =>
this.page.evaluate((k) => window.localStorage.getItem(k), key),
elementByTestId: (testId: string) => this.testId(testId),
inputValue: (testId: string) => new Query<string>(() => this.testId(testId).first().inputValue()),
elementsText: (testId: string) => new Query<string>(() => this.testId(testId).first().innerText()),
locationHash: () => new Query<string>(async () => new URL(this.page.url()).hash),
};
}
-12
View File
@@ -1,12 +0,0 @@
import fs from "node:fs";
import path from "node:path";
/**
* Clears the istanbul coverage output directory before the e2e run so stale
* coverage from previous runs is not merged into the report.
*/
export default function globalSetup(): void {
const dir = path.resolve(process.cwd(), ".nyc_output");
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });
}
-23
View File
@@ -1,23 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
/**
* Merges the per-test istanbul coverage chunks collected in `.nyc_output` into a
* report (configured by `.nycrc.json`) once the whole e2e run has finished.
*/
export default function globalTeardown(): void {
const dir = path.resolve(process.cwd(), ".nyc_output");
const hasCoverage = fs.existsSync(dir) && fs.readdirSync(dir).some((f) => f.endsWith(".json"));
if (!hasCoverage) {
console.warn("No coverage data collected; skipping coverage report.");
return;
}
try {
execFileSync("npx", ["nyc", "report"], { stdio: "inherit" });
} catch (error) {
// Don't fail the whole run if the report can't be generated (e.g. when
// running against a container whose source paths differ from the host).
console.warn("Failed to generate coverage report:", error);
}
}
-82
View File
@@ -1,82 +0,0 @@
import { test, expect, type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
let activePage: Page | undefined;
const coverageChunks: unknown[] = [];
/** The page for the currently running test. Throws if used outside a test. */
export function currentPage(): Page {
if (!activePage) {
throw new Error("No active page: a MaputnikDriver method was called outside of a running test.");
}
return activePage;
}
const OUTPUT_DIR = path.resolve(process.cwd(), ".nyc_output");
/**
* Reads the istanbul coverage object (injected by vite-plugin-istanbul) from the
* given page. Returns `null` when the page has not been instrumented.
*/
async function readCoverage(page: Page): Promise<unknown | null> {
try {
return await page.evaluate(() => (window as unknown as { __coverage__?: unknown }).__coverage__ ?? null);
} catch {
// Page might be navigating/closed.
return null;
}
}
/**
* Persists a coverage chunk to `.nyc_output` so that `nyc report` can merge it.
* istanbul-lib-coverage (used by nyc) sums the hit counts across every file it
* finds, so writing one file per chunk is enough to accumulate coverage across
* navigations and tests.
*/
export function writeCoverage(coverage: unknown, id: string): void {
if (!coverage) return;
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(path.join(OUTPUT_DIR, `playwright-${id}.json`), JSON.stringify(coverage));
}
/** Records a coverage snapshot (called before navigations, which reset __coverage__). */
export async function recordCoverageChunk(page: Page): Promise<void> {
const chunk = await readCoverage(page);
if (chunk) coverageChunks.push(chunk);
}
/**
* Auto fixture that binds the current test's page for the (page-lazy)
* MaputnikDriver, auto-accepts confirm dialogs, and writes the istanbul
* coverage collected during the test to `.nyc_output`.
*/
const extendedTest = test.extend<{ maputnikPage: void }>({
maputnikPage: [
async ({ page }, use, testInfo) => {
activePage = page;
coverageChunks.length = 0;
// Accept confirm dialogs (e.g. the "replace current style" prompt). These
// are dismissed by default, which would cancel loading a style via URL.
page.on("dialog", (dialog) => dialog.accept().catch(() => undefined));
await use();
const finalCoverage = await readCoverage(page);
if (finalCoverage) coverageChunks.push(finalCoverage);
coverageChunks.forEach((chunk, index) => writeCoverage(chunk, `${testInfo.testId}-${index}`));
coverageChunks.length = 0;
activePage = undefined;
},
{ auto: true },
],
});
const describe = extendedTest.describe;
const beforeEach = extendedTest.beforeEach;
export {
expect,
describe,
extendedTest as test,
beforeEach,
};
+1 -3
View File
@@ -32,11 +32,9 @@ export default defineConfig({
"@stylistic": stylisticTs
},
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true, extraHOCs: ["withTranslation"] }
{ allowConstantExport: true }
],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
+2814 -846
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -11,10 +11,10 @@
"build-linux": "tsc && vite build --mode=desktop && cd desktop && make bin/linux/maputnik",
"i18n:extract": "npx i18next-cli extract",
"lint": "eslint",
"test": "playwright test",
"test-e2e": "playwright test",
"test": "cypress run",
"test-unit": "vitest",
"test-unit-ci": "vitest run --coverage --reporter=json",
"cy:open": "cypress open",
"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"
},
@@ -98,9 +98,10 @@
}
},
"devDependencies": {
"@cypress/code-coverage": "^4.0.3",
"@eslint/js": "^10.0.1",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@playwright/test": "^1.61.1",
"@shellygo/cypress-test-utils": "^6.0.6",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/codemirror": "^5.60.17",
"@types/color": "^4.2.1",
@@ -126,6 +127,8 @@
"@vitejs/plugin-react": "5.2",
"@vitest/coverage-v8": "^4.1.10",
"cors": "^2.8.6",
"cypress": "^15.18.0",
"cypress-plugin-tab": "^2.0.0",
"eslint": "^10.6.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -133,7 +136,6 @@
"i18next-cli": "^1.65.0",
"istanbul": "^0.4.5",
"istanbul-lib-coverage": "^3.2.2",
"nyc": "^18.0.0",
"postcss": "^8.5.16",
"react-hot-loader": "^4.13.1",
"sass": "^1.101.0",
@@ -143,7 +145,7 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"uuid": "^14.0.1",
"vite": "^7.3.2",
"vite": "^8.1.3",
"vite-plugin-istanbul": "^9.0.1",
"vitest": "^4.1.10"
}
-46
View File
@@ -1,46 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
const isCI = !!process.env.CI;
// When the app is already served elsewhere (e.g. the docker e2e job) set
// E2E_NO_WEBSERVER=1 so Playwright does not start its own dev server.
const useExternalServer = !!process.env.E2E_NO_WEBSERVER;
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:8888/";
export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.spec.ts",
globalSetup: "./e2e/utils/e2e-setup.ts",
globalTeardown: "./e2e/utils/e2e-teardown.ts",
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
reporter: isCI ? [["list"], ["html", { open: "never" }]] : "list",
use: {
baseURL,
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
launchOptions: {
// Allow WebGL (maplibre) to fall back to software rendering in headless.
args: [
"--disable-gpu",
"--enable-features=AllowSwiftShaderFallback,AllowSoftwareGLFallbackDueToCrashes",
"--enable-unsafe-swiftshader",
],
},
},
},
],
webServer: useExternalServer
? undefined
: {
command: "npm run start",
url: "http://localhost:8888/maputnik/",
reuseExistingServer: !isCI,
timeout: 120000,
},
});
+598 -641
View File
File diff suppressed because it is too large Load Diff
+34 -30
View File
@@ -1,5 +1,5 @@
import React from "react";
import { ScrollContainer } from "./ScrollContainer";
import ScrollContainer from "./ScrollContainer";
import { type WithTranslation, withTranslation } from "react-i18next";
import { IconContext } from "react-icons";
@@ -13,38 +13,42 @@ type AppLayoutInternalProps = {
modals?: React.ReactNode
} & WithTranslation;
const AppLayoutInternal: React.FC<AppLayoutInternalProps> = (props) => {
document.body.dir = props.i18n.dir();
class AppLayoutInternal extends React.Component<AppLayoutInternalProps> {
return <IconContext.Provider value={{size: "14px"}}>
<div className="maputnik-layout">
{props.toolbar}
<div className="maputnik-layout-main">
{props.codeEditor && <div className="maputnik-layout-code-editor">
<ScrollContainer>
{props.codeEditor}
</ScrollContainer>
</div>
}
{!props.codeEditor && <>
<div className="maputnik-layout-list">
{props.layerList}
</div>
<div className="maputnik-layout-drawer">
render() {
document.body.dir = this.props.i18n.dir();
return <IconContext.Provider value={{size: "14px"}}>
<div className="maputnik-layout">
{this.props.toolbar}
<div className="maputnik-layout-main">
{this.props.codeEditor && <div className="maputnik-layout-code-editor">
<ScrollContainer>
{props.layerEditor}
{this.props.codeEditor}
</ScrollContainer>
</div>
</>}
{props.map}
}
{!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>
{this.props.bottom && <div className="maputnik-layout-bottom">
{this.props.bottom}
</div>
}
{this.props.modals}
</div>
{props.bottom && <div className="maputnik-layout-bottom">
{props.bottom}
</div>
}
{props.modals}
</div>
</IconContext.Provider>;
};
</IconContext.Provider>;
}
}
export const AppLayout = withTranslation()(AppLayoutInternal);
const AppLayout = withTranslation()(AppLayoutInternal);
export default AppLayout;
+47 -43
View File
@@ -13,49 +13,53 @@ type AppMessagePanelInternalProps = {
selectedLayerIndex?: number
} & WithTranslation;
const AppMessagePanelInternal: React.FC<AppMessagePanelInternalProps> = ({
onLayerSelect = () => { },
...props
}) => {
const { t, selectedLayerIndex } = props;
const errors = props.errors?.map((error, idx) => {
let content;
if (error.parsed && error.parsed.type === "layer") {
const { parsed } = error;
const layerId = props.mapStyle?.layers[parsed.data.index].id;
content = (
<>
{t("Layer")} <span>{formatLayerId(layerId)}</span>: {parsed.data.message}
{selectedLayerIndex !== parsed.data.index &&
<>
&nbsp;&mdash;&nbsp;
<button
className="maputnik-message-panel__switch-button"
onClick={() => onLayerSelect!(parsed.data.index)}
>
{t("switch to layer")}
</button>
</>
}
</>
);
}
else {
content = error.message;
}
return <p key={"error-" + idx} className="maputnik-message-panel-error">
{content}
</p>;
});
class AppMessagePanelInternal extends React.Component<AppMessagePanelInternalProps> {
static defaultProps = {
onLayerSelect: () => { },
};
const infos = props.infos?.map((m, i) => {
return <p key={"info-" + i}>{m}</p>;
});
render() {
const { t, selectedLayerIndex } = this.props;
const errors = this.props.errors?.map((error, idx) => {
let content;
if (error.parsed && error.parsed.type === "layer") {
const { parsed } = error;
const layerId = this.props.mapStyle?.layers[parsed.data.index].id;
content = (
<>
{t("Layer")} <span>{formatLayerId(layerId)}</span>: {parsed.data.message}
{selectedLayerIndex !== parsed.data.index &&
<>
&nbsp;&mdash;&nbsp;
<button
className="maputnik-message-panel__switch-button"
onClick={() => this.props.onLayerSelect!(parsed.data.index)}
>
{t("switch to layer")}
</button>
</>
}
</>
);
}
else {
content = error.message;
}
return <p key={"error-" + idx} className="maputnik-message-panel-error">
{content}
</p>;
});
return <div className="maputnik-message-panel">
{errors}
{infos}
</div>;
};
const infos = this.props.infos?.map((m, i) => {
return <p key={"info-" + i}>{m}</p>;
});
export const AppMessagePanel = withTranslation()(AppMessagePanelInternal);
return <div className="maputnik-message-panel">
{errors}
{infos}
</div>;
}
}
const AppMessagePanel = withTranslation()(AppMessagePanelInternal);
export default AppMessagePanel;
+213 -192
View File
@@ -31,9 +31,11 @@ type IconTextProps = {
};
const IconText: React.FC<IconTextProps> = (props) => {
return <span className="maputnik-icon-text">{props.children}</span>;
};
class IconText extends React.Component<IconTextProps> {
render() {
return <span className="maputnik-icon-text">{this.props.children}</span>;
}
}
type ToolbarLinkProps = {
className?: string
@@ -41,31 +43,35 @@ type ToolbarLinkProps = {
href?: string
};
const ToolbarLink: React.FC<ToolbarLinkProps> = (props) => {
return <a
className={classnames("maputnik-toolbar-link", props.className)}
href={props.href}
rel="noopener noreferrer"
target="_blank"
data-wd-key="toolbar:link"
>
{props.children}
</a>;
};
class ToolbarLink extends React.Component<ToolbarLinkProps> {
render() {
return <a
className={classnames("maputnik-toolbar-link", this.props.className)}
href={this.props.href}
rel="noopener noreferrer"
target="_blank"
data-wd-key="toolbar:link"
>
{this.props.children}
</a>;
}
}
type ToolbarSelectProps = {
children?: React.ReactNode
wdKey?: string
};
const ToolbarSelect: React.FC<ToolbarSelectProps> = (props) => {
return <div
className='maputnik-toolbar-select'
data-wd-key={props.wdKey}
>
{props.children}
</div>;
};
class ToolbarSelect extends React.Component<ToolbarSelectProps> {
render() {
return <div
className='maputnik-toolbar-select'
data-wd-key={this.props.wdKey}
>
{this.props.children}
</div>;
}
}
type ToolbarActionProps = {
children?: React.ReactNode
@@ -73,15 +79,17 @@ type ToolbarActionProps = {
wdKey?: string
};
const ToolbarAction: React.FC<ToolbarActionProps> = (props) => {
return <button
className='maputnik-toolbar-action'
data-wd-key={props.wdKey}
onClick={props.onClick}
>
{props.children}
</button>;
};
class ToolbarAction extends React.Component<ToolbarActionProps> {
render() {
return <button
className='maputnik-toolbar-action'
data-wd-key={this.props.wdKey}
onClick={this.props.onClick}
>
{this.props.children}
</button>;
}
}
export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deuteranopia" | "filter-protanopia" | "filter-tritanopia";
@@ -100,16 +108,26 @@ type AppToolbarInternalProps = {
renderer?: string
} & WithTranslation;
const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
function handleSelection(val: MapState) {
props.onSetMapState(val);
class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
state = {
isOpen: {
settings: false,
sources: false,
open: false,
add: false,
export: false,
}
};
handleSelection(val: MapState) {
this.props.onSetMapState(val);
}
function handleLanguageChange(val: string) {
props.i18n.changeLanguage(val);
handleLanguageChange(val: string) {
this.props.i18n.changeLanguage(val);
}
const onSkip = (target: string) => {
onSkip = (target: string) => {
if (target === "map") {
(document.querySelector(".maplibregl-canvas") as HTMLCanvasElement).focus();
}
@@ -119,171 +137,174 @@ const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
}
};
const t = props.t;
const views = [
{
id: "map",
group: "general",
title: t("Map"),
},
{
id: "inspect",
group: "general",
title: t("Inspect"),
disabled: props.renderer === "ol",
},
{
id: "filter-deuteranopia",
group: "color-accessibility",
title: t("Deuteranopia filter"),
disabled: !colorAccessibilityFiltersEnabled,
},
{
id: "filter-protanopia",
group: "color-accessibility",
title: t("Protanopia filter"),
disabled: !colorAccessibilityFiltersEnabled,
},
{
id: "filter-tritanopia",
group: "color-accessibility",
title: t("Tritanopia filter"),
disabled: !colorAccessibilityFiltersEnabled,
},
{
id: "filter-achromatopsia",
group: "color-accessibility",
title: t("Achromatopsia filter"),
disabled: !colorAccessibilityFiltersEnabled,
},
];
render() {
const t = this.props.t;
const views = [
{
id: "map",
group: "general",
title: t("Map"),
},
{
id: "inspect",
group: "general",
title: t("Inspect"),
disabled: this.props.renderer === "ol",
},
{
id: "filter-deuteranopia",
group: "color-accessibility",
title: t("Deuteranopia filter"),
disabled: !colorAccessibilityFiltersEnabled,
},
{
id: "filter-protanopia",
group: "color-accessibility",
title: t("Protanopia filter"),
disabled: !colorAccessibilityFiltersEnabled,
},
{
id: "filter-tritanopia",
group: "color-accessibility",
title: t("Tritanopia filter"),
disabled: !colorAccessibilityFiltersEnabled,
},
{
id: "filter-achromatopsia",
group: "color-accessibility",
title: t("Achromatopsia filter"),
disabled: !colorAccessibilityFiltersEnabled,
},
];
const currentView = views.find((view) => {
return view.id === props.mapState;
});
const currentView = views.find((view) => {
return view.id === this.props.mapState;
});
return <nav className='maputnik-toolbar'>
<div className="maputnik-toolbar__inner">
<div
className="maputnik-toolbar-logo-container"
>
{/* Keyboard accessible quick links */}
<button
data-wd-key="root:skip:layer-list"
className="maputnik-toolbar-skip"
onClick={_e => onSkip("layer-list")}
return <nav className='maputnik-toolbar'>
<div className="maputnik-toolbar__inner">
<div
className="maputnik-toolbar-logo-container"
>
{t("Layers list")}
</button>
<button
data-wd-key="root:skip:layer-editor"
className="maputnik-toolbar-skip"
onClick={_e => onSkip("layer-editor")}
>
{t("Layer editor")}
</button>
<button
data-wd-key="root:skip:map-view"
className="maputnik-toolbar-skip"
onClick={_e => onSkip("map")}
>
{t("Map view")}
</button>
<a
className="maputnik-toolbar-logo"
target="blank"
rel="noreferrer noopener"
href="https://github.com/maplibre/maputnik"
>
<img src={maputnikLogo} alt={t("Maputnik on GitHub")} />
<h1>
<span className="maputnik-toolbar-name">{pkgJson.name}</span>
<span className="maputnik-toolbar-version">v{pkgJson.version}</span>
</h1>
</a>
</div>
<div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar">
<ToolbarAction wdKey="nav:open" onClick={() => props.onToggleModal("open")}>
<MdOpenInBrowser />
<IconText>{t("Open")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:export" onClick={() => props.onToggleModal("export")}>
<MdSave />
<IconText>{t("Save")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:code-editor" onClick={() => props.onToggleModal("codeEditor")}>
<MdCode />
<IconText>{t("Code Editor")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:sources" onClick={() => props.onToggleModal("sources")}>
<MdLayers />
<IconText>{t("Data Sources")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:settings" onClick={() => props.onToggleModal("settings")}>
<MdSettings />
<IconText>{t("Style Settings")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:global-state" onClick={() => props.onToggleModal("globalState")}>
<MdPublic />
<IconText>{t("Global State")}</IconText>
</ToolbarAction>
{/* Keyboard accessible quick links */}
<button
data-wd-key="root:skip:layer-list"
className="maputnik-toolbar-skip"
onClick={_e => this.onSkip("layer-list")}
>
{t("Layers list")}
</button>
<button
data-wd-key="root:skip:layer-editor"
className="maputnik-toolbar-skip"
onClick={_e => this.onSkip("layer-editor")}
>
{t("Layer editor")}
</button>
<button
data-wd-key="root:skip:map-view"
className="maputnik-toolbar-skip"
onClick={_e => this.onSkip("map")}
>
{t("Map view")}
</button>
<a
className="maputnik-toolbar-logo"
target="blank"
rel="noreferrer noopener"
href="https://github.com/maplibre/maputnik"
>
<img src={maputnikLogo} alt={t("Maputnik on GitHub")} />
<h1>
<span className="maputnik-toolbar-name">{pkgJson.name}</span>
<span className="maputnik-toolbar-version">v{pkgJson.version}</span>
</h1>
</a>
</div>
<div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar">
<ToolbarAction wdKey="nav:open" onClick={() => this.props.onToggleModal("open")}>
<MdOpenInBrowser />
<IconText>{t("Open")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:export" onClick={() => this.props.onToggleModal("export")}>
<MdSave />
<IconText>{t("Save")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:code-editor" onClick={() => this.props.onToggleModal("codeEditor")}>
<MdCode />
<IconText>{t("Code Editor")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:sources" onClick={() => this.props.onToggleModal("sources")}>
<MdLayers />
<IconText>{t("Data Sources")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:settings" onClick={() => this.props.onToggleModal("settings")}>
<MdSettings />
<IconText>{t("Style Settings")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:global-state" onClick={() => this.props.onToggleModal("globalState")}>
<MdPublic />
<IconText>{t("Global State")}</IconText>
</ToolbarAction>
<ToolbarSelect wdKey="nav:inspect">
<MdFindInPage />
<IconText>{t("View")}
<select
className="maputnik-select"
data-wd-key="maputnik-select"
onChange={(e) => handleSelection(e.target.value as MapState)}
value={currentView?.id}
>
{views.filter(v => v.group === "general").map((item) => {
return (
<option key={item.id} value={item.id} disabled={item.disabled} data-wd-key={item.id}>
{item.title}
</option>
);
})}
<optgroup label={t("Color accessibility")}>
{views.filter(v => v.group === "color-accessibility").map((item) => {
<ToolbarSelect wdKey="nav:inspect">
<MdFindInPage />
<IconText>{t("View")}
<select
className="maputnik-select"
data-wd-key="maputnik-select"
onChange={(e) => this.handleSelection(e.target.value as MapState)}
value={currentView?.id}
>
{views.filter(v => v.group === "general").map((item) => {
return (
<option key={item.id} value={item.id} disabled={item.disabled}>
<option key={item.id} value={item.id} disabled={item.disabled} data-wd-key={item.id}>
{item.title}
</option>
);
})}
</optgroup>
</select>
</IconText>
</ToolbarSelect>
<optgroup label={t("Color accessibility")}>
{views.filter(v => v.group === "color-accessibility").map((item) => {
return (
<option key={item.id} value={item.id} disabled={item.disabled}>
{item.title}
</option>
);
})}
</optgroup>
</select>
</IconText>
</ToolbarSelect>
<ToolbarSelect wdKey="nav:language">
<MdLanguage />
<IconText>Language
<select
className="maputnik-select"
data-wd-key="maputnik-lang-select"
onChange={(e) => handleLanguageChange(e.target.value)}
value={props.i18n.language}
>
{Object.entries(supportedLanguages).map(([code, name]) => {
return (
<option key={code} value={code}>
{name}
</option>
);
})}
</select>
</IconText>
</ToolbarSelect>
<ToolbarSelect wdKey="nav:language">
<MdLanguage />
<IconText>Language
<select
className="maputnik-select"
data-wd-key="maputnik-lang-select"
onChange={(e) => this.handleLanguageChange(e.target.value)}
value={this.props.i18n.language}
>
{Object.entries(supportedLanguages).map(([code, name]) => {
return (
<option key={code} value={code}>
{name}
</option>
);
})}
</select>
</IconText>
</ToolbarSelect>
<ToolbarLink href={"https://github.com/maplibre/maputnik/wiki"}>
<MdHelpOutline />
<IconText>{t("Help")}</IconText>
</ToolbarLink>
<ToolbarLink href={"https://github.com/maplibre/maputnik/wiki"}>
<MdHelpOutline />
<IconText>{t("Help")}</IconText>
</ToolbarLink>
</div>
</div>
</div>
</nav>;
};
</nav>;
}
}
export const AppToolbar = withTranslation()(AppToolbarInternal);
const AppToolbar = withTranslation()(AppToolbarInternal);
export default AppToolbar;
+70 -49
View File
@@ -1,7 +1,7 @@
import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent, useRef, useState} from "react";
import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent} from "react";
import classnames from "classnames";
import { FieldDocLabel } from "./FieldDocLabel";
import { Doc } from "./Doc";
import FieldDocLabel from "./FieldDocLabel";
import Doc from "./Doc";
export type BlockProps = PropsWithChildren & {
"data-wd-key"?: string
@@ -14,13 +14,32 @@ export type BlockProps = PropsWithChildren & {
error?: {message: string}
};
/** Wrap a component with a label */
export const Block: React.FC<BlockProps> = (props) => {
const [showDoc, setShowDoc] = useState(false);
const blockEl = useRef<HTMLDivElement | null>(null);
type BlockState = {
showDoc: boolean
};
const onToggleDoc = (val: boolean) => {
setShowDoc(val);
/** Wrap a component with a label */
export default class Block extends React.Component<BlockProps, BlockState> {
_blockEl: HTMLDivElement | null = null;
constructor (props: BlockProps) {
super(props);
this.state = {
showDoc: false,
};
}
onChange(e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) {
const value = e.target.value;
if (this.props.onChange) {
return this.props.onChange(value === "" ? undefined : value);
}
}
onToggleDoc = (val: boolean) => {
this.setState({
showDoc: val
});
};
/**
@@ -29,9 +48,9 @@ export const Block: React.FC<BlockProps> = (props) => {
* causing the picker to reopen. This causes a scenario where the picker can
* never be closed once open.
*/
const onLabelClick = (event: SyntheticEvent<any, any>) => {
onLabelClick = (event: SyntheticEvent<any, any>) => {
const el = event.nativeEvent.target;
const contains = blockEl.current?.contains(el);
const contains = this._blockEl?.contains(el);
if (event.nativeEvent.target.nodeName !== "INPUT" && !contains) {
event.stopPropagation();
@@ -41,43 +60,45 @@ export const Block: React.FC<BlockProps> = (props) => {
}
};
return <label style={props.style}
data-wd-key={props["data-wd-key"]}
className={classnames({
"maputnik-input-block": true,
"maputnik-input-block--wide": props.wideMode,
"maputnik-action-block": props.action,
"maputnik-input-block--error": props.error
})}
onClick={onLabelClick}
>
{props.fieldSpec &&
<div className="maputnik-input-block-label">
<FieldDocLabel
label={props.label}
onToggleDoc={onToggleDoc}
fieldSpec={props.fieldSpec}
/>
render() {
return <label style={this.props.style}
data-wd-key={this.props["data-wd-key"]}
className={classnames({
"maputnik-input-block": true,
"maputnik-input-block--wide": this.props.wideMode,
"maputnik-action-block": this.props.action,
"maputnik-input-block--error": this.props.error
})}
onClick={this.onLabelClick}
>
{this.props.fieldSpec &&
<div className="maputnik-input-block-label">
<FieldDocLabel
label={this.props.label}
onToggleDoc={this.onToggleDoc}
fieldSpec={this.props.fieldSpec}
/>
</div>
}
{!this.props.fieldSpec &&
<div className="maputnik-input-block-label">
{this.props.label}
</div>
}
<div className="maputnik-input-block-action">
{this.props.action}
</div>
}
{!props.fieldSpec &&
<div className="maputnik-input-block-label">
{props.label}
<div className="maputnik-input-block-content" ref={el => {this._blockEl = el;}}>
{this.props.children}
</div>
}
<div className="maputnik-input-block-action">
{props.action}
</div>
<div className="maputnik-input-block-content" ref={blockEl}>
{props.children}
</div>
{props.fieldSpec &&
<div
className="maputnik-doc-inline"
style={{display: showDoc ? "" : "none"}}
>
<Doc fieldSpec={props.fieldSpec} />
</div>
}
</label>;
};
{this.props.fieldSpec &&
<div
className="maputnik-doc-inline"
style={{display: this.state.showDoc ? "" : "none"}}
>
<Doc fieldSpec={this.props.fieldSpec} />
</div>
}
</label>;
}
}
+4 -2
View File
@@ -1,4 +1,4 @@
import { InputJson } from "./InputJson";
import InputJson from "./InputJson";
import React from "react";
import { withTranslation, type WithTranslation } from "react-i18next";
import { type StyleSpecification } from "maplibre-gl";
@@ -24,4 +24,6 @@ const CodeEditorInternal: React.FC<CodeEditorProps> = (props) => {
</>;
};
export const CodeEditor = withTranslation()(CodeEditorInternal);
const CodeEditor = withTranslation()(CodeEditorInternal);
export default CodeEditor;
+21 -15
View File
@@ -9,19 +9,25 @@ type CollapseProps = {
};
export const Collapse: React.FC<CollapseProps> = ({isActive = true, children}) => {
if (reducedMotionEnabled()) {
return (
<div style={{display: isActive ? "block" : "none"}}>
{children}
</div>
);
export default class Collapse extends React.Component<CollapseProps> {
static defaultProps = {
isActive: true
};
render() {
if (reducedMotionEnabled()) {
return (
<div style={{display: this.props.isActive ? "block" : "none"}}>
{this.props.children}
</div>
);
}
else {
return (
<ReactCollapse isOpened={this.props.isActive}>
{this.props.children}
</ReactCollapse>
);
}
}
else {
return (
<ReactCollapse isOpened={isActive}>
{children}
</ReactCollapse>
);
}
};
}
+10 -8
View File
@@ -6,11 +6,13 @@ type CollapserProps = {
style?: object
};
export const Collapser: React.FC<CollapserProps> = (props) => {
const iconStyle = {
width: 20,
height: 20,
...props.style,
};
return props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />;
};
export default class Collapser extends React.Component<CollapserProps> {
render() {
const iconStyle = {
width: 20,
height: 20,
...this.props.style,
};
return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />;
}
}
-371
View File
@@ -1,371 +0,0 @@
import React, { useRef } from "react";
import {PiListPlusBold} from "react-icons/pi";
import {TbMathFunction} from "react-icons/tb";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import { InputButton } from "./InputButton";
import { InputSpec } from "./InputSpec";
import { InputNumber } from "./InputNumber";
import { InputString } from "./InputString";
import { InputSelect } from "./InputSelect";
import { Block } from "./Block";
import { generateUniqueId as docUid } from "../libs/document-uid";
import { sortNumerically } from "../libs/sort-numerically";
import {findDefaultFromSpec} from "../libs/spec-helper";
import { type WithTranslation, withTranslation } from "react-i18next";
import { labelFromFieldName } from "../libs/label-from-field-name";
import { DeleteStopButton } from "./DeleteStopButton";
import { type MappedLayerErrors } from "../libs/definitions";
function setStopRefs(props: DataPropertyInternalProps, state: DataPropertyState) {
// This is initialised below only if required to improved performance.
let newRefs: {[key: number]: string} | undefined;
if(props.value && props.value.stops) {
props.value.stops.forEach((_val, idx) => {
if(!Object.prototype.hasOwnProperty.call(state.refs, idx)) {
if(!newRefs) {
newRefs = {...state};
}
newRefs[idx] = docUid("stop-");
}
});
}
return newRefs;
}
type DataPropertyInternalProps = {
onChange?(fieldName: string, value: any): unknown
onDeleteStop?(...args: unknown[]): unknown
onAddStop?(...args: unknown[]): unknown
onExpressionClick?(...args: unknown[]): unknown
onChangeToZoomFunction?(...args: unknown[]): unknown
fieldName: string
fieldType?: string
fieldSpec?: object
value?: DataPropertyValue
errors?: MappedLayerErrors
} & WithTranslation;
type DataPropertyState = {
refs: {[key: number]: string}
};
type DataPropertyValue = {
default?: any
property?: string
base?: number
type?: string
stops: Stop[]
};
export type Stop = [{
zoom: number
value: number
}, number];
const DataPropertyInternal: React.FC<DataPropertyInternalProps> = (props) => {
// Kept in a ref rather than state: the original recomputed these on every
// render via getDerivedStateFromProps, which as state would mean setting
// state during render on every pass.
const refs = useRef<{[key: number]: string}>({});
// setStopRefs returns undefined when no new stop needs a ref; as in the
// original, only replace the map when it actually produced one.
const newStopRefs = setStopRefs(props, { refs: refs.current });
if (newStopRefs) {
refs.current = newStopRefs;
}
function getFieldFunctionType(fieldSpec: any) {
if (fieldSpec.expression.interpolated) {
return "exponential";
}
if (fieldSpec.type === "number") {
return "interval";
}
return "categorical";
}
function getDataFunctionTypes(fieldSpec: any) {
if (fieldSpec.expression.interpolated) {
return ["interpolate", "categorical", "interval", "exponential", "identity"];
}
else {
return ["categorical", "interval", "identity"];
}
}
// Order the stops altering the refs to reflect their new position.
function orderStopsByZoom(stops: Stop[]) {
const mappedWithRef = stops
.map((stop, idx) => {
return {
ref: refs.current[idx],
data: stop
};
})
// Sort by zoom
.sort((a, b) => sortNumerically(a.data[0].zoom, b.data[0].zoom));
// Fetch the new position of the stops
const newRefs = {} as {[key: number]: string};
mappedWithRef
.forEach((stop, idx) =>{
newRefs[idx] = stop.ref;
});
refs.current = newRefs;
return mappedWithRef.map((item) => item.data);
}
const onChange = (fieldName: string, value: any) => {
if (value.type === "identity") {
value = {
type: value.type,
property: value.property,
};
}
else {
const stopValue = value.type === "categorical" ? "" : 0;
value = {
property: "",
type: value.type,
// Default props if they don't already exist.
stops: [
[{zoom: 6, value: stopValue}, findDefaultFromSpec(props.fieldSpec as any)],
[{zoom: 10, value: stopValue}, findDefaultFromSpec(props.fieldSpec as any)]
],
...value,
};
}
props.onChange!(fieldName, value);
};
function changeStop(changeIdx: number, stopData: { zoom: number | undefined, value: number }, value: number) {
const stops = props.value?.stops.slice(0) || [];
// const changedStop = stopData.zoom === undefined ? stopData.value : stopData
stops[changeIdx] = [
{
value: stopData.value,
zoom: (stopData.zoom === undefined) ? 0 : stopData.zoom,
},
value
];
const orderedStops = orderStopsByZoom(stops);
const changedValue = {
...props.value,
stops: orderedStops,
};
onChange(props.fieldName, changedValue);
}
function changeBase(newValue: number | undefined) {
const changedValue = {
...props.value,
base: newValue
};
if (changedValue.base === undefined) {
delete changedValue["base"];
}
props.onChange!(props.fieldName, changedValue);
}
function changeDataType(propVal: string) {
if (propVal === "interpolate" && props.onChangeToZoomFunction) {
props.onChangeToZoomFunction();
}
else {
onChange(props.fieldName, {
...props.value,
type: propVal,
});
}
}
function changeDataProperty(propName: "property" | "default", propVal: any) {
if (propVal) {
props.value![propName] = propVal;
}
else {
delete props.value![propName];
}
onChange(props.fieldName, props.value);
}
const t = props.t;
if (typeof props.value?.type === "undefined") {
props.value!.type = getFieldFunctionType(props.fieldSpec);
}
let dataFields;
if (props.value?.stops) {
dataFields = props.value.stops.map((stop, idx) => {
const zoomLevel = typeof stop[0] === "object" ? stop[0].zoom : undefined;
const key = refs.current[idx];
const dataLevel = typeof stop[0] === "object" ? stop[0].value : stop[0];
const value = stop[1];
const deleteStopBtn = <DeleteStopButton onClick={props.onDeleteStop?.bind(null, idx)} />;
const dataProps = {
"aria-label": t("Input value"),
label: t("Data value"),
value: dataLevel as any,
onChange: (newData: string | number | undefined) => changeStop(idx, { zoom: zoomLevel, value: newData as number }, value)
};
let dataInput;
if(props.value?.type === "categorical") {
dataInput = <InputString {...dataProps} />;
}
else {
dataInput = <InputNumber {...dataProps} />;
}
let zoomInput = null;
if(zoomLevel !== undefined) {
zoomInput = <div>
<InputNumber
aria-label="Zoom"
value={zoomLevel}
onChange={newZoom => changeStop(idx, {zoom: newZoom, value: dataLevel}, value)}
min={0}
max={22}
/>
</div>;
}
return <tr key={key}>
<td>
{zoomInput}
</td>
<td>
{dataInput}
</td>
<td>
<InputSpec
aria-label={t("Output value")}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={value}
onChange={(_, newValue) => changeStop(idx, {zoom: zoomLevel, value: dataLevel}, newValue as number)}
/>
</td>
<td>
{deleteStopBtn}
</td>
</tr>;
});
}
return <div className="maputnik-data-spec-block">
<fieldset className="maputnik-data-spec-property">
<legend>{labelFromFieldName(props.fieldName)}</legend>
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
key="function"
data-wd-key="function-type"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={props.value!.type}
onChange={(propVal: string) => changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={getDataFunctionTypes(props.fieldSpec)}
/>
</div>
</Block>
{props.value?.type !== "identity" &&
<Block
label={t("Base")}
key="base"
data-wd-key="function-base"
>
<div className="maputnik-data-spec-property-input">
<InputSpec
fieldName={"base"}
fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }}
value={props.value?.base}
onChange={(_, newValue) => changeBase(newValue as number)}
/>
</div>
</Block>
}
<Block
label={"Property"}
key="property"
data-wd-key="function-property"
>
<div className="maputnik-data-spec-property-input">
<InputString
value={props.value?.property}
title={t("Input a data property to base styles off of.")}
onChange={propVal => changeDataProperty("property", propVal)}
/>
</div>
</Block>
{dataFields &&
<Block
label={t("Default")}
key="default"
data-wd-key="function-default"
>
<InputSpec
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={props.value?.default}
onChange={(_, propVal) => changeDataProperty("default", propVal)}
/>
</Block>
}
{dataFields &&
<div className="maputnik-function-stop">
<table className="maputnik-function-stop-table">
<caption>{t("Stops")}</caption>
<thead>
<tr>
<th>{t("Zoom")}</th>
<th>{t("Input value")}</th>
<th rowSpan={2}>{t("Output value")}</th>
</tr>
</thead>
<tbody>
{dataFields}
</tbody>
</table>
</div>
}
<div className="maputnik-toolbox">
{dataFields &&
<InputButton
className="maputnik-add-stop"
onClick={props.onAddStop?.bind(null)}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add stop")}
</InputButton>
}
<InputButton
className="maputnik-add-stop"
data-wd-key="convert-to-expression"
onClick={props.onExpressionClick?.bind(null)}
>
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
{t("Convert to expression")}
</InputButton>
</div>
</div>
</fieldset>
</div>;
};
export const DataProperty = withTranslation()(DataPropertyInternal);
-24
View File
@@ -1,24 +0,0 @@
import React from "react";
import { InputButton } from "./InputButton";
import {MdDelete} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next";
type DeleteStopButtonInternalProps = {
onClick?(...args: unknown[]): unknown
} & WithTranslation;
const DeleteStopButtonInternal: React.FC<DeleteStopButtonInternalProps> = (props) => {
const t = props.t;
return <InputButton
className="maputnik-delete-stop"
onClick={props.onClick}
title={t("Remove zoom level from stop")}
>
<MdDelete />
</InputButton>;
};
export const DeleteStopButton = withTranslation()(DeleteStopButtonInternal);
+80 -76
View File
@@ -23,84 +23,88 @@ type DocProps = {
}
};
export const Doc: React.FC<DocProps> = ({fieldSpec}) => {
const {doc, values, docUrl, docUrlLinkText} = fieldSpec;
const sdkSupport = fieldSpec["sdk-support"];
export default class Doc extends React.Component<DocProps> {
render () {
const {fieldSpec} = this.props;
const renderValues = (
!!values &&
// HACK: Currently we merge additional values into the style spec, so this is required
// See <https://github.com/maplibre/maputnik/blob/main/src/components/PropertyGroup.jsx#L16>
!Array.isArray(values)
);
const {doc, values, docUrl, docUrlLinkText} = fieldSpec;
const sdkSupport = fieldSpec["sdk-support"];
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;
};
const renderValues = (
!!values &&
// HACK: Currently we merge additional values into the style spec, so this is required
// See <https://github.com/maplibre/maputnik/blob/main/src/components/PropertyGroup.jsx#L16>
!Array.isArray(values)
);
return (
<>
{doc &&
<div className="SpecDoc">
<div className="SpecDoc__doc" data-wd-key='spec-field-doc'>
<Markdown components={{
a: ({node: _node, href, children, ...props}) => <a href={href} target="_blank" {...props}>{children}</a>,
}}>{doc}</Markdown>
</div>
{renderValues &&
<ul className="SpecDoc__values">
{Object.entries(values).map(([key, value]) => {
return (
<li key={key}>
<code>{JSON.stringify(key)}</code>
<div>{value.doc}</div>
</li>
);
})}
</ul>
}
</div>
const sdkSupportToJsx = (value: string) => {
const supportValue = value.toLowerCase();
if (supportValue.startsWith("https://")) {
return <a href={supportValue} target="_blank" rel="noreferrer">{"#" + supportValue.split("/").pop()}</a>;
}
{sdkSupport &&
<div className="SpecDoc__sdk-support">
<table className="SpecDoc__sdk-support__table">
<thead>
<tr>
<th></th>
{Object.values(headers).map(header => {
return <th key={header}>{header}</th>;
return value;
};
return (
<>
{doc &&
<div className="SpecDoc">
<div className="SpecDoc__doc" data-wd-key='spec-field-doc'>
<Markdown components={{
a: ({node: _node, href, children, ...props}) => <a href={href} target="_blank" {...props}>{children}</a>,
}}>{doc}</Markdown>
</div>
{renderValues &&
<ul className="SpecDoc__values">
{Object.entries(values).map(([key, value]) => {
return (
<li key={key}>
<code>{JSON.stringify(key)}</code>
<div>{value.doc}</div>
</li>
);
})}
</tr>
</thead>
<tbody>
{Object.entries(sdkSupport).map(([key, supportObj]) => {
return (
<tr key={key}>
<td>{key}</td>
{Object.keys(headers).map((k) => {
if (Object.prototype.hasOwnProperty.call(supportObj, k)) {
return <td key={k}>{sdkSupportToJsx(supportObj[k as keyof typeof headers])}</td>;
}
else {
return <td key={k}>no</td>;
}
})}
</tr>
);
})}
</tbody>
</table>
</div>
}
{docUrl && docUrlLinkText &&
<div className="SpecDoc__learn-more">
<a href={docUrl} target="_blank" rel="noreferrer">{docUrlLinkText}</a>
</div>
}
</>
);
};
</ul>
}
</div>
}
{sdkSupport &&
<div className="SpecDoc__sdk-support">
<table className="SpecDoc__sdk-support__table">
<thead>
<tr>
<th></th>
{Object.values(headers).map(header => {
return <th key={header}>{header}</th>;
})}
</tr>
</thead>
<tbody>
{Object.entries(sdkSupport).map(([key, supportObj]) => {
return (
<tr key={key}>
<td>{key}</td>
{Object.keys(headers).map((k) => {
if (Object.prototype.hasOwnProperty.call(supportObj, k)) {
return <td key={k}>{sdkSupportToJsx(supportObj[k as keyof typeof headers])}</td>;
}
else {
return <td key={k}>no</td>;
}
})}
</tr>
);
})}
</tbody>
</table>
</div>
}
{docUrl && docUrlLinkText &&
<div className="SpecDoc__learn-more">
<a href={docUrl} target="_blank" rel="noreferrer">{docUrlLinkText}</a>
</div>
}
</>
);
}
}
-85
View File
@@ -1,85 +0,0 @@
import React from "react";
import {MdDelete, MdUndo} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next";
import { Block } from "./Block";
import { InputButton } from "./InputButton";
import { labelFromFieldName } from "../libs/label-from-field-name";
import { FieldJson } from "./FieldJson";
import type { StylePropertySpecification } from "maplibre-gl";
import { type MappedLayerErrors } from "../libs/definitions";
type ExpressionPropertyInternalProps = {
fieldName: string
fieldType?: string
fieldSpec?: StylePropertySpecification
value?: any
errors?: MappedLayerErrors
onDelete?(...args: unknown[]): unknown
onChange(value: object): void
onUndo?(...args: unknown[]): unknown
canUndo?(...args: unknown[]): unknown
onFocus?(...args: unknown[]): unknown
onBlur?(...args: unknown[]): unknown
} & WithTranslation;
const ExpressionPropertyInternal: React.FC<ExpressionPropertyInternalProps> = ({
errors = {},
onFocus = () => {},
onBlur = () => {},
...props
}) => {
const {t, value, canUndo} = props;
const undoDisabled = canUndo ? !canUndo() : true;
const deleteStopBtn = (
<>
{props.onUndo &&
<InputButton
key="undo_action"
onClick={props.onUndo}
disabled={undoDisabled}
className="maputnik-delete-stop"
data-wd-key="undo-expression"
title={t("Revert from expression")}
>
<MdUndo />
</InputButton>
}
<InputButton
key="delete_action"
onClick={props.onDelete}
className="maputnik-delete-stop"
data-wd-key="delete-expression"
title={t("Delete expression")}
>
<MdDelete />
</InputButton>
</>
);
let error = undefined;
if (errors) {
const fieldKey = props.fieldType ? props.fieldType + "." + props.fieldName : props.fieldName;
error = errors[fieldKey];
}
return <Block
fieldSpec={props.fieldSpec}
label={t(labelFromFieldName(props.fieldName))}
action={deleteStopBtn}
wideMode={true}
error={error}
>
<FieldJson
lintType="expression"
spec={props.fieldSpec}
className="maputnik-expression-editor"
onFocus={onFocus}
onBlur={onBlur}
value={value}
onChange={props.onChange}
/>
</Block>;
};
export const ExpressionProperty = withTranslation()(ExpressionPropertyInternal);
+5 -3
View File
@@ -1,5 +1,5 @@
import { InputArray, type InputArrayProps } from "./InputArray";
import { Fieldset } from "./Fieldset";
import InputArray, { type InputArrayProps } from "./InputArray";
import Fieldset from "./Fieldset";
type FieldArrayProps = InputArrayProps & {
name?: string
@@ -8,10 +8,12 @@ type FieldArrayProps = InputArrayProps & {
}
};
export const FieldArray: React.FC<FieldArrayProps> = (props) => {
const FieldArray: React.FC<FieldArrayProps> = (props) => {
return (
<Fieldset label={props.label} fieldSpec={props.fieldSpec}>
<InputArray {...props} />
</Fieldset>
);
};
export default FieldArray;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Block } from "./Block";
import { InputAutocomplete, type InputAutocompleteProps } from "./InputAutocomplete";
import Block from "./Block";
import InputAutocomplete, { type InputAutocompleteProps } from "./InputAutocomplete";
type FieldAutocompleteProps = InputAutocompleteProps & {
@@ -7,10 +7,12 @@ type FieldAutocompleteProps = InputAutocompleteProps & {
};
export const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => {
const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => {
return (
<Block label={props.label}>
<InputAutocomplete {...props} />
</Block>
);
};
export default FieldAutocomplete;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Block } from "./Block";
import { InputCheckbox, type InputCheckboxProps } from "./InputCheckbox";
import Block from "./Block";
import InputCheckbox, {type InputCheckboxProps} from "./InputCheckbox";
type FieldCheckboxProps = InputCheckboxProps & {
@@ -7,10 +7,12 @@ type FieldCheckboxProps = InputCheckboxProps & {
};
export const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => {
const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => {
return (
<Block label={props.label}>
<InputCheckbox {...props} />
</Block>
);
};
export default FieldCheckbox;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Block } from "./Block";
import { InputColor, type InputColorProps } from "./InputColor";
import Block from "./Block";
import InputColor, {type InputColorProps} from "./InputColor";
type FieldColorProps = InputColorProps & {
@@ -10,10 +10,12 @@ type FieldColorProps = InputColorProps & {
};
export const FieldColor: React.FC<FieldColorProps> = (props) => {
const FieldColor: React.FC<FieldColorProps> = (props) => {
return (
<Block label={props.label} fieldSpec={props.fieldSpec}>
<InputColor {...props} />
</Block>
);
};
export default FieldColor;
+4 -3
View File
@@ -1,7 +1,7 @@
import React from "react";
import { Block } from "./Block";
import { InputString } from "./InputString";
import Block from "./Block";
import InputString from "./InputString";
import { type WithTranslation, withTranslation } from "react-i18next";
type FieldCommentInternalProps = {
@@ -36,4 +36,5 @@ const FieldCommentInternal: React.FC<FieldCommentInternalProps> = (props) => {
);
};
export const FieldComment = withTranslation()(FieldCommentInternal);
const FieldComment = withTranslation()(FieldCommentInternal);
export default FieldComment;
+3 -1
View File
@@ -10,7 +10,7 @@ type FieldDocLabelProps = {
};
export const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
const [open, setOpen] = React.useState(false);
const onToggleDoc = (state: boolean) => {
@@ -49,3 +49,5 @@ export const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
}
return <div />;
};
export default FieldDocLabel;
+5 -3
View File
@@ -1,14 +1,16 @@
import { InputDynamicArray, type InputDynamicArrayProps } from "./InputDynamicArray";
import { Fieldset } from "./Fieldset";
import InputDynamicArray, {type InputDynamicArrayProps} from "./InputDynamicArray";
import Fieldset from "./Fieldset";
type FieldDynamicArrayProps = InputDynamicArrayProps & {
name?: string
};
export const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => {
const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => {
return (
<Fieldset label={props.label}>
<InputDynamicArray {...props} />
</Fieldset>
);
};
export default FieldDynamicArray;
+5 -3
View File
@@ -1,5 +1,5 @@
import { InputEnum, type InputEnumProps } from "./InputEnum";
import { Fieldset } from "./Fieldset";
import InputEnum, {type InputEnumProps} from "./InputEnum";
import Fieldset from "./Fieldset";
type FieldEnumProps = InputEnumProps & {
@@ -10,10 +10,12 @@ type FieldEnumProps = InputEnumProps & {
};
export const FieldEnum: React.FC<FieldEnumProps> = (props) => {
const FieldEnum: React.FC<FieldEnumProps> = (props) => {
return (
<Fieldset label={props.label} fieldSpec={props.fieldSpec}>
<InputEnum {...props} />
</Fieldset>
);
};
export default FieldEnum;
+7 -5
View File
@@ -1,9 +1,9 @@
import React from "react";
import { SpecProperty } from "./SpecProperty";
import { DataProperty, type Stop } from "./DataProperty";
import { ZoomProperty } from "./ZoomProperty";
import { ExpressionProperty } from "./ExpressionProperty";
import SpecProperty from "./_SpecProperty";
import DataProperty, { type Stop } from "./_DataProperty";
import ZoomProperty from "./_ZoomProperty";
import ExpressionProperty from "./_ExpressionProperty";
import {function as styleFunction} from "@maplibre/maplibre-gl-style-spec";
import {findDefaultFromSpec} from "../libs/spec-helper";
import { type MappedLayerErrors } from "../libs/definitions";
@@ -128,7 +128,7 @@ type FieldFunctionProps = {
/** Supports displaying spec field for zoom function objects
* https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property
*/
export const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
const [dataType, setDataType] = React.useState(
getDataType(props.value, props.fieldSpec)
);
@@ -402,3 +402,5 @@ export const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
</div>
);
};
export default FieldFunction;
+5 -3
View File
@@ -1,7 +1,7 @@
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import { Block } from "./Block";
import { InputString } from "./InputString";
import Block from "./Block";
import InputString from "./InputString";
type FieldIdProps = {
value: string
@@ -10,7 +10,7 @@ type FieldIdProps = {
error?: {message: string}
};
export const FieldId: React.FC<FieldIdProps> = (props) => {
const FieldId: React.FC<FieldIdProps> = (props) => {
return (
<Block label="ID" fieldSpec={latest.layer.id}
data-wd-key={props.wdKey}
@@ -24,3 +24,5 @@ export const FieldId: React.FC<FieldIdProps> = (props) => {
</Block>
);
};
export default FieldId;
+4 -2
View File
@@ -1,9 +1,11 @@
import { InputJson, type InputJsonProps } from "./InputJson";
import InputJson, {type InputJsonProps} from "./InputJson";
type FieldJsonProps = InputJsonProps & {};
export const FieldJson: React.FC<FieldJsonProps> = (props) => {
const FieldJson: React.FC<FieldJsonProps> = (props) => {
return <InputJson {...props} />;
};
export default FieldJson;
+4 -3
View File
@@ -1,8 +1,8 @@
import React from "react";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import { Block } from "./Block";
import { InputNumber } from "./InputNumber";
import Block from "./Block";
import InputNumber from "./InputNumber";
import { type WithTranslation, withTranslation } from "react-i18next";
type FieldMaxZoomInternalProps = {
@@ -31,4 +31,5 @@ const FieldMaxZoomInternal: React.FC<FieldMaxZoomInternalProps> = (props) => {
);
};
export const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal);
const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal);
export default FieldMaxZoom;
+4 -3
View File
@@ -1,8 +1,8 @@
import React from "react";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import { Block } from "./Block";
import { InputNumber } from "./InputNumber";
import Block from "./Block";
import InputNumber from "./InputNumber";
import { type WithTranslation, withTranslation } from "react-i18next";
type FieldMinZoomInternalProps = {
@@ -31,4 +31,5 @@ const FieldMinZoomInternal: React.FC<FieldMinZoomInternalProps> = (props) => {
);
};
export const FieldMinZoom = withTranslation()(FieldMinZoomInternal);
const FieldMinZoom = withTranslation()(FieldMinZoomInternal);
export default FieldMinZoom;
+5 -3
View File
@@ -1,5 +1,5 @@
import { InputMultiInput, type InputMultiInputProps } from "./InputMultiInput";
import { Fieldset } from "./Fieldset";
import InputMultiInput, {type InputMultiInputProps} from "./InputMultiInput";
import Fieldset from "./Fieldset";
type FieldMultiInputProps = InputMultiInputProps & {
@@ -7,10 +7,12 @@ type FieldMultiInputProps = InputMultiInputProps & {
};
export const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => {
const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => {
return (
<Fieldset label={props.label}>
<InputMultiInput {...props} />
</Fieldset>
);
};
export default FieldMultiInput;
+5 -3
View File
@@ -1,5 +1,5 @@
import { InputNumber, type InputNumberProps } from "./InputNumber";
import { Block } from "./Block";
import InputNumber, {type InputNumberProps} from "./InputNumber";
import Block from "./Block";
type FieldNumberProps = InputNumberProps & {
@@ -10,10 +10,12 @@ type FieldNumberProps = InputNumberProps & {
};
export const FieldNumber: React.FC<FieldNumberProps> = (props) => {
const FieldNumber: React.FC<FieldNumberProps> = (props) => {
return (
<Block label={props.label} fieldSpec={props.fieldSpec}>
<InputNumber {...props} />
</Block>
);
};
export default FieldNumber;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Block } from "./Block";
import { InputSelect, type InputSelectProps } from "./InputSelect";
import Block from "./Block";
import InputSelect, {type InputSelectProps} from "./InputSelect";
type FieldSelectProps = InputSelectProps & {
@@ -10,10 +10,12 @@ type FieldSelectProps = InputSelectProps & {
};
export const FieldSelect: React.FC<FieldSelectProps> = (props) => {
const FieldSelect: React.FC<FieldSelectProps> = (props) => {
return (
<Block label={props.label} fieldSpec={props.fieldSpec}>
<InputSelect {...props} />
</Block>
);
};
export default FieldSelect;
+4 -3
View File
@@ -1,8 +1,8 @@
import React from "react";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import { Block } from "./Block";
import { InputAutocomplete } from "./InputAutocomplete";
import Block from "./Block";
import InputAutocomplete from "./InputAutocomplete";
import { type WithTranslation, withTranslation } from "react-i18next";
type FieldSourceInternalProps = {
@@ -38,4 +38,5 @@ const FieldSourceInternal: React.FC<FieldSourceInternalProps> = ({
};
export const FieldSource = withTranslation()(FieldSourceInternal);
const FieldSource = withTranslation()(FieldSourceInternal);
export default FieldSource;
+4 -3
View File
@@ -1,8 +1,8 @@
import React from "react";
import {latest} from "@maplibre/maplibre-gl-style-spec";
import { Block } from "./Block";
import { InputAutocomplete } from "./InputAutocomplete";
import Block from "./Block";
import InputAutocomplete from "./InputAutocomplete";
import { type WithTranslation, withTranslation } from "react-i18next";
type FieldSourceLayerInternalProps = {
@@ -35,4 +35,5 @@ const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = ({
);
};
export const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
export default FieldSourceLayer;
+6 -4
View File
@@ -1,6 +1,6 @@
import { Block, type BlockProps } from "./Block";
import { InputSpec, type FieldSpecType, type InputSpecProps } from "./InputSpec";
import { Fieldset, type FieldsetProps } from "./Fieldset";
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) {
@@ -36,7 +36,7 @@ function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[
export type FieldSpecProps = InputSpecProps & BlockProps & FieldsetProps;
export const FieldSpec: React.FC<FieldSpecProps> = (props) => {
const FieldSpec: React.FC<FieldSpecProps> = (props) => {
const TypeBlock = getElementFromType(props.fieldSpec!);
return (
@@ -45,3 +45,5 @@ export const FieldSpec: React.FC<FieldSpecProps> = (props) => {
</TypeBlock>
);
};
export default FieldSpec;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Block } from "./Block";
import { InputString, type InputStringProps } from "./InputString";
import Block from "./Block";
import InputString, {type InputStringProps} from "./InputString";
type FieldStringProps = InputStringProps & {
name?: string
@@ -9,10 +9,12 @@ type FieldStringProps = InputStringProps & {
}
};
export const FieldString: React.FC<FieldStringProps> = (props) => {
const FieldString: React.FC<FieldStringProps> = (props) => {
return (
<Block label={props.label} fieldSpec={props.fieldSpec}>
<InputString {...props} />
</Block>
);
};
export default FieldString;
+5 -4
View File
@@ -1,8 +1,8 @@
import React from "react";
import {v8} from "@maplibre/maplibre-gl-style-spec";
import { Block } from "./Block";
import { InputSelect } from "./InputSelect";
import { InputString } from "./InputString";
import Block from "./Block";
import InputSelect from "./InputSelect";
import InputString from "./InputString";
import { type WithTranslation, withTranslation } from "react-i18next";
import { startCase } from "lodash";
@@ -43,4 +43,5 @@ const FieldTypeInternal: React.FC<FieldTypeInternalProps> = ({
);
};
export const FieldType = withTranslation()(FieldTypeInternal);
const FieldType = withTranslation()(FieldTypeInternal);
export default FieldType;
+5 -3
View File
@@ -1,5 +1,5 @@
import { InputUrl, type FieldUrlProps as InputUrlProps } from "./InputUrl";
import { Block } from "./Block";
import InputUrl, {type FieldUrlProps as InputUrlProps} from "./InputUrl";
import Block from "./Block";
type FieldUrlProps = InputUrlProps & {
@@ -10,10 +10,12 @@ type FieldUrlProps = InputUrlProps & {
};
export const FieldUrl: React.FC<FieldUrlProps> = (props) => {
const FieldUrl: React.FC<FieldUrlProps> = (props) => {
return (
<Block label={props.label} fieldSpec={props.fieldSpec}>
<InputUrl {...props} />
</Block>
);
};
export default FieldUrl;
+6 -4
View File
@@ -1,8 +1,8 @@
import React, { type PropsWithChildren, type ReactElement } from "react";
import classnames from "classnames";
import { FieldDocLabel } from "./FieldDocLabel";
import { Doc } from "./Doc";
import { generateUniqueId } from "../libs/document-uid";
import FieldDocLabel from "./FieldDocLabel";
import Doc from "./Doc";
import generateUniqueId from "../libs/document-uid";
export type FieldsetProps = PropsWithChildren & {
label?: string,
@@ -12,7 +12,7 @@ export type FieldsetProps = PropsWithChildren & {
};
export const Fieldset: React.FC<FieldsetProps> = (props) => {
const Fieldset: React.FC<FieldsetProps> = (props) => {
const [showDoc, setShowDoc] = React.useState(false);
const labelId = React.useRef(generateUniqueId("fieldset_label_"));
@@ -49,3 +49,5 @@ export const Fieldset: React.FC<FieldsetProps> = (props) => {
</div>
);
};
export default Fieldset;
+181 -150
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React from "react";
import { TbMathFunction } from "react-icons/tb";
import { PiListPlusBold } from "react-icons/pi";
import {isEqual} from "lodash";
@@ -7,13 +7,13 @@ import {migrate, convertFilter} from "@maplibre/maplibre-gl-style-spec";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import {combiningFilterOps} from "../libs/filterops";
import { InputSelect } from "./InputSelect";
import { Block } from "./Block";
import { SingleFilterEditor } from "./SingleFilterEditor";
import { FilterEditorBlock } from "./FilterEditorBlock";
import { InputButton } from "./InputButton";
import { Doc } from "./Doc";
import { ExpressionProperty } from "./ExpressionProperty";
import InputSelect from "./InputSelect";
import Block from "./Block";
import SingleFilterEditor from "./SingleFilterEditor";
import FilterEditorBlock from "./FilterEditorBlock";
import InputButton from "./InputButton";
import Doc from "./Doc";
import ExpressionProperty from "./_ExpressionProperty";
import { type WithTranslation, withTranslation } from "react-i18next";
import type { MappedLayerErrors, StyleSpecificationWithId } from "../libs/definitions";
@@ -100,190 +100,221 @@ type FilterEditorInternalProps = {
onChange(value: LegacyFilterSpecification | ExpressionSpecification): void
} & WithTranslation;
const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["all"], ...rest }) => {
const props = { filter, ...rest } as FilterEditorInternalProps;
type FilterEditorState = {
showDoc: boolean
displaySimpleFilter: boolean
valueIsSimpleFilter?: boolean
};
// Nothing ever toggles this: the Block below renders its own documentation
// toggle, so this component's inline doc panel stays hidden (as it did before).
const [showDoc] = useState(false);
const [displaySimpleFilter, setDisplaySimpleFilter] = useState(() =>
checkIfSimpleFilter(combiningFilter(props))
);
class FilterEditorInternal extends React.Component<FilterEditorInternalProps, FilterEditorState> {
static defaultProps = {
filter: ["all"],
};
// Replaces getDerivedStateFromProps. "Upgrade but never downgrade": once the
// filter stops being expressible in the simple editor, switch to the
// expression editor and stay there.
const isSimpleFilter = checkIfSimpleFilter(combiningFilter(props));
if (!isSimpleFilter && displaySimpleFilter) {
setDisplaySimpleFilter(false);
constructor (props: FilterEditorInternalProps) {
super(props);
this.state = {
showDoc: false,
displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)),
};
}
// In the original this was state, but every branch derived it from these two
// values alone.
const valueIsSimpleFilter = isSimpleFilter && !displaySimpleFilter;
// Convert filter to combining filter
function onFilterPartChanged(filterIdx: number, newPart: any[]) {
const newFilter = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
onFilterPartChanged(filterIdx: number, newPart: any[]) {
const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
newFilter[filterIdx] = newPart;
props.onChange(newFilter);
this.props.onChange(newFilter);
}
function deleteFilterItem(filterIdx: number) {
const newFilter = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
deleteFilterItem(filterIdx: number) {
const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
newFilter.splice(filterIdx + 1, 1);
props.onChange(newFilter);
this.props.onChange(newFilter);
}
const addFilterItem = () => {
const newFilterItem = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
addFilterItem = () => {
const newFilterItem = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
(newFilterItem as any[]).push(["==", "name", ""]);
props.onChange(newFilterItem);
this.props.onChange(newFilterItem);
};
const makeFilter = () => {
setDisplaySimpleFilter(true);
onToggleDoc = (val: boolean) => {
this.setState({
showDoc: val
});
};
const makeExpression = () => {
const currentFilter = combiningFilter(props);
props.onChange(migrateFilter(currentFilter));
setDisplaySimpleFilter(false);
makeFilter = () => {
this.setState({
displaySimpleFilter: true,
});
};
const {errors, t} = props;
const fieldSpec={
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
makeExpression = () => {
const filter = combiningFilter(this.props);
this.props.onChange(migrateFilter(filter));
this.setState({
displaySimpleFilter: false,
});
};
const defaultFilter = ["all"] as LegacyFilterSpecification | ExpressionSpecification;
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(props));
static getDerivedStateFromProps(props: Readonly<FilterEditorInternalProps>, state: FilterEditorState) {
const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props));
if (isNestedCombiningFilter) {
return <div className="maputnik-filter-editor-unsupported">
<p>
{t("Nested filters are not supported.")}
</p>
<InputButton
onClick={makeExpression}
title={t("Convert to expression")}
>
<TbMathFunction />
{t("Upgrade to expression")}
</InputButton>
</div>;
// Upgrade but never downgrade
if (!displaySimpleFilter && state.displaySimpleFilter === true) {
return {
displaySimpleFilter: false,
valueIsSimpleFilter: false,
};
}
else if (displaySimpleFilter && state.displaySimpleFilter === false) {
return {
valueIsSimpleFilter: true,
};
}
else {
return {
valueIsSimpleFilter: false,
};
}
}
else if (displaySimpleFilter) {
const filter = combiningFilter(props);
const combiningOp = filter[0];
const filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[];
const actions = (
<div>
render() {
const {errors, t} = this.props;
const {displaySimpleFilter} = this.state;
const fieldSpec={
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
};
const defaultFilter = ["all"] as LegacyFilterSpecification | ExpressionSpecification;
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(this.props));
if (isNestedCombiningFilter) {
return <div className="maputnik-filter-editor-unsupported">
<p>
{t("Nested filters are not supported.")}
</p>
<InputButton
onClick={makeExpression}
onClick={this.makeExpression}
title={t("Convert to expression")}
className="maputnik-make-zoom-function"
data-wd-key="filter-convert-to-expression"
>
<TbMathFunction />
{t("Upgrade to expression")}
</InputButton>
</div>
);
</div>;
}
else if (displaySimpleFilter) {
const filter = combiningFilter(this.props);
const combiningOp = filter[0];
const filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[];
const editorBlocks = filters.map((f, idx) => {
const error = errors![`filter[${idx+1}]`];
return (
<div key={`block-${idx}`}>
<FilterEditorBlock key={idx} onDelete={deleteFilterItem.bind(null, idx)}>
<SingleFilterEditor
properties={props.properties}
filter={f}
onChange={onFilterPartChanged.bind(null, idx + 1)}
/>
</FilterEditorBlock>
{error &&
<div key="error" className="maputnik-inline-error">{error.message}</div>
}
</div>
);
});
return (
<>
<Block
key="top"
fieldSpec={fieldSpec}
label={t("Filter")}
action={actions}
data-wd-key="filter-combining-operator"
>
<InputSelect
value={combiningOp}
onChange={(v: [string, any]) => onFilterPartChanged(0, v)}
options={[
["all", t("every filter matches")],
["none", t("no filter matches")],
["any", t("any filter matches")]
]}
/>
</Block>
{editorBlocks}
<div
key="buttons"
className="maputnik-filter-editor-add-wrapper"
>
const actions = (
<div>
<InputButton
data-wd-key="layer-filter-button"
className="maputnik-add-filter"
onClick={addFilterItem}
onClick={this.makeExpression}
title={t("Convert to expression")}
className="maputnik-make-zoom-function"
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add filter")}
<TbMathFunction />
</InputButton>
</div>
<div
key="doc"
className="maputnik-doc-inline"
style={{display: showDoc ? "" : "none"}}
>
<Doc fieldSpec={fieldSpec} />
</div>
</>
);
}
else {
const {filter} = props;
);
return (
<>
<ExpressionProperty
onDelete={() => {
setDisplaySimpleFilter(true);
props.onChange(defaultFilter);
}}
fieldName="filter"
value={filter}
errors={errors}
onChange={props.onChange}
/>
{valueIsSimpleFilter &&
const editorBlocks = filters.map((f, idx) => {
const error = errors![`filter[${idx+1}]`];
return (
<div key={`block-${idx}`}>
<FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
<SingleFilterEditor
properties={this.props.properties}
filter={f}
onChange={this.onFilterPartChanged.bind(this, idx + 1)}
/>
</FilterEditorBlock>
{error &&
<div key="error" className="maputnik-inline-error">{error.message}</div>
}
</div>
);
});
return (
<>
<Block
key="top"
fieldSpec={fieldSpec}
label={t("Filter")}
action={actions}
>
<InputSelect
value={combiningOp}
onChange={(v: [string, any]) => this.onFilterPartChanged(0, v)}
options={[
["all", t("every filter matches")],
["none", t("no filter matches")],
["any", t("any filter matches")]
]}
/>
</Block>
{editorBlocks}
<div
key="buttons"
className="maputnik-filter-editor-add-wrapper"
>
<InputButton
data-wd-key="layer-filter-button"
className="maputnik-add-filter"
onClick={this.addFilterItem}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add filter")}
</InputButton>
</div>
<div
key="doc"
className="maputnik-doc-inline"
style={{display: this.state.showDoc ? "" : "none"}}
>
<Doc fieldSpec={fieldSpec} />
</div>
</>
);
}
else {
const {filter} = this.props;
return (
<>
<ExpressionProperty
onDelete={() => {
this.setState({displaySimpleFilter: true});
this.props.onChange(defaultFilter);
}}
fieldName="filter"
value={filter}
errors={errors}
onChange={this.props.onChange}
/>
{this.state.valueIsSimpleFilter &&
<div className="maputnik-expr-infobox">
{t("You've entered an old style filter.")}
{" "}
<button
onClick={makeFilter}
onClick={this.makeFilter}
className="maputnik-expr-infobox__button"
>
{t("Switch to filter editor.")}
</button>
</div>
}
</>
);
}
</>
);
}
}
};
}
export const FilterEditor = withTranslation()(FilterEditorInternal);
const FilterEditor = withTranslation()(FilterEditorInternal);
export default FilterEditor;
+22 -19
View File
@@ -1,5 +1,5 @@
import React, { type PropsWithChildren } from "react";
import { InputButton } from "./InputButton";
import InputButton from "./InputButton";
import {MdDelete} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next";
@@ -7,22 +7,25 @@ type FilterEditorBlockInternalProps = PropsWithChildren & {
onDelete(...args: unknown[]): unknown
} & WithTranslation;
const FilterEditorBlockInternal: React.FC<FilterEditorBlockInternalProps> = (props) => {
const t = props.t;
return <div className="maputnik-filter-editor-block">
<div className="maputnik-filter-editor-block-content">
{props.children}
</div>
<div className="maputnik-filter-editor-block-action">
<InputButton
className="maputnik-icon-button"
onClick={props.onDelete}
title={t("Delete filter block")}
>
<MdDelete />
</InputButton>
</div>
</div>;
};
class FilterEditorBlockInternal extends React.Component<FilterEditorBlockInternalProps> {
render() {
const t = this.props.t;
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">
<InputButton
className="maputnik-icon-button"
onClick={this.props.onDelete}
title={t("Delete filter block")}
>
<MdDelete />
</InputButton>
</div>
</div>;
}
}
export const FilterEditorBlock = withTranslation()(FilterEditorBlockInternal);
const FilterEditorBlock = withTranslation()(FilterEditorBlockInternal);
export default FilterEditorBlock;
-68
View File
@@ -1,68 +0,0 @@
import React from "react";
import { InputButton } from "./InputButton";
import {MdFunctions, MdInsertChart} from "react-icons/md";
import { TbMathFunction } from "react-icons/tb";
import { type WithTranslation, withTranslation } from "react-i18next";
type FunctionInputButtonsInternalProps = {
fieldSpec?: any
onZoomClick?(): void
onDataClick?(): void
onExpressionClick?(): void
onElevationClick?(): void
} & WithTranslation;
const FunctionInputButtonsInternal: React.FC<FunctionInputButtonsInternalProps> = (props) => {
const t = props.t;
if (props.fieldSpec.expression?.parameters.includes("zoom")) {
const expressionInputButton = (
<InputButton
className="maputnik-make-zoom-function"
onClick={props.onExpressionClick}
title={t("Convert to expression")}
>
<TbMathFunction />
</InputButton>
);
const makeZoomInputButton = <InputButton
className="maputnik-make-zoom-function"
onClick={props.onZoomClick}
title={t("Convert property into a zoom function")}
>
<MdFunctions />
</InputButton>;
let makeDataInputButton;
if (props.fieldSpec["property-type"] === "data-driven") {
makeDataInputButton = <InputButton
className="maputnik-make-data-function"
onClick={props.onDataClick}
title={t("Convert property to data function")}
>
<MdInsertChart />
</InputButton>;
}
return <div>
{expressionInputButton}
{makeDataInputButton}
{makeZoomInputButton}
</div>;
} else if (props.fieldSpec.expression?.parameters.includes("elevation")) {
const inputElevationButton = <InputButton
className="maputnik-make-elevation-function"
onClick={props.onElevationClick}
title={t("Convert property into a elevation function")}
data-wd-key='make-elevation-function'
>
<MdFunctions />
</InputButton>;
return <div>{inputElevationButton}</div>;
} else {
return <div></div>;
}
};
export const FunctionInputButtons = withTranslation()(FunctionInputButtonsInternal);
+3 -1
View File
@@ -13,7 +13,7 @@ type IconLayerProps = {
className?: string
};
export const IconLayer: React.FC<IconLayerProps> = (props) => {
const IconLayer: React.FC<IconLayerProps> = (props) => {
const iconProps = { style: props.style };
switch(props.type) {
case "fill-extrusion": return <IoMdCube {...iconProps} />;
@@ -29,3 +29,5 @@ export const IconLayer: React.FC<IconLayerProps> = (props) => {
default: return <MdPriorityHigh {...iconProps} />;
}
};
export default IconLayer;
+96 -62
View File
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { InputString } from "./InputString";
import { InputNumber } from "./InputNumber";
import React from "react";
import InputString from "./InputString";
import InputNumber from "./InputNumber";
export type InputArrayProps = {
value: (string | number | undefined)[]
@@ -12,72 +12,106 @@ export type InputArrayProps = {
label?: string
};
export const InputArray: React.FC<InputArrayProps> = ({
value: propsValue = [],
default: propsDefault = [],
...rest
}) => {
const props = { value: propsValue, default: propsDefault, ...rest };
type InputArrayState = {
value: (string | number | undefined)[]
initialPropsValue: unknown[]
};
// The original seeded this from props and then never let props overwrite it
// again (its getDerivedStateFromProps assigned the existing state back in
// both branches), so the value is owned by this component after mount.
const [value, setValue] = useState<(string | number | undefined)[]>(() => propsValue.slice(0));
export default class InputArray extends React.Component<InputArrayProps, InputArrayState> {
static defaultProps = {
value: [],
default: [],
};
function isComplete(val: unknown[]) {
return Array(props.length).fill(null).every((_, i) => {
const v = val[i];
return !(v === undefined || v === "");
constructor (props: InputArrayProps) {
super(props);
this.state = {
value: this.props.value.slice(0),
// This is so we can compare changes in getDerivedStateFromProps
initialPropsValue: this.props.value.slice(0),
};
}
static getDerivedStateFromProps(props: Readonly<InputArrayProps>, state: InputArrayState) {
const value: any[] = [];
const initialPropsValue = state.initialPropsValue.slice(0);
Array(props.length).fill(null).map((_, i) => {
if (props.value[i] === state.initialPropsValue[i]) {
value[i] = state.value[i];
}
else {
value[i] = state.value[i];
initialPropsValue[i] = state.value[i];
}
});
return {
value,
initialPropsValue,
};
}
isComplete(value: unknown[]) {
return Array(this.props.length).fill(null).every((_, i) => {
const val = value[i];
return !(val === undefined || val === "");
});
}
function changeValue(idx: number, newValue: string | number | undefined) {
const nextValue = value.slice(0);
nextValue[idx] = newValue;
changeValue(idx: number, newValue: string | number | undefined) {
const value = this.state.value.slice(0);
value[idx] = newValue;
setValue(nextValue);
if (isComplete(nextValue) && props.onChange) {
props.onChange(nextValue);
}
else if (props.onChange) {
// Unset until complete
props.onChange(undefined);
}
this.setState({
value,
}, () => {
if (this.isComplete(value) && this.props.onChange) {
this.props.onChange(value);
}
else if (this.props.onChange){
// Unset until complete
this.props.onChange(undefined);
}
});
}
const containsValues = (
value.length > 0 &&
!value.every(val => {
return (val === "" || val === undefined);
})
);
render() {
const {value} = this.state;
const inputs = Array(props.length).fill(null).map((_, i) => {
if(props.type === "number") {
return <InputNumber
key={i}
default={containsValues || !props.default ? undefined : props.default[i] as number}
value={value[i] as number}
required={containsValues ? true : false}
onChange={(v) => changeValue(i, v)}
aria-label={props["aria-label"] || props.label}
/>;
} else {
return <InputString
key={i}
default={containsValues || !props.default ? undefined : props.default[i] as string}
value={value[i] as string}
required={containsValues ? true : false}
onChange={(v) => changeValue(i, v)}
aria-label={props["aria-label"] || props.label}
/>;
}
});
const containsValues = (
value.length > 0 &&
!value.every(val => {
return (val === "" || val === undefined);
})
);
return (
<div className="maputnik-array">
{inputs}
</div>
);
};
const inputs = Array(this.props.length).fill(null).map((_, i) => {
if(this.props.type === "number") {
return <InputNumber
key={i}
default={containsValues || !this.props.default ? undefined : this.props.default[i] as number}
value={value[i] as number}
required={containsValues ? true : false}
onChange={(v) => this.changeValue(i, v)}
aria-label={this.props["aria-label"] || this.props.label}
/>;
} else {
return <InputString
key={i}
default={containsValues || !this.props.default ? undefined : this.props.default[i] as string}
value={value[i] as string}
required={containsValues ? true : false}
onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label}
/>;
}
});
return (
<div className="maputnik-array">
{inputs}
</div>
);
}
}
@@ -0,0 +1,18 @@
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");
});
});
+1 -1
View File
@@ -11,7 +11,7 @@ export type InputAutocompleteProps = {
"aria-label"?: string
};
export function InputAutocomplete({
export default function InputAutocomplete({
value,
options = [],
onChange = () => {},
+17 -15
View File
@@ -14,18 +14,20 @@ type InputButtonProps = {
title?: string
};
export const InputButton: React.FC<InputButtonProps> = (props) => {
return <button
id={props.id}
title={props.title}
type={props.type}
onClick={props.onClick}
disabled={props.disabled}
aria-label={props["aria-label"]}
className={classnames("maputnik-button", props.className)}
data-wd-key={props["data-wd-key"]}
style={props.style}
>
{props.children}
</button>;
};
export default class InputButton extends React.Component<InputButtonProps> {
render() {
return <button
id={this.props.id}
title={this.props.title}
type={this.props.type}
onClick={this.props.onClick}
disabled={this.props.disabled}
aria-label={this.props["aria-label"]}
className={classnames("maputnik-button", this.props.className)}
data-wd-key={this.props["data-wd-key"]}
style={this.props.style}
>
{this.props.children}
</button>;
}
}
+27 -21
View File
@@ -6,26 +6,32 @@ export type InputCheckboxProps = {
onChange(...args: unknown[]): unknown
};
export const InputCheckbox: React.FC<InputCheckboxProps> = ({value = false, ...props}) => {
const onChange = () => {
props.onChange(!value);
export default class InputCheckbox extends React.Component<InputCheckboxProps> {
static defaultProps = {
value: false,
};
return <div className="maputnik-checkbox-wrapper">
<input
className="maputnik-checkbox"
type="checkbox"
style={props.style}
onChange={onChange}
onClick={onChange}
checked={value}
/>
<div className="maputnik-checkbox-box">
<svg style={{
display: value ? "inline" : "none"
}} className="maputnik-checkbox-icon" viewBox='0 0 32 32'>
<path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' />
</svg>
</div>
</div>;
};
onChange = () => {
this.props.onChange(!this.props.value);
};
render() {
return <div className="maputnik-checkbox-wrapper">
<input
className="maputnik-checkbox"
type="checkbox"
style={this.props.style}
onChange={this.onChange}
onClick={this.onChange}
checked={this.props.value}
/>
<div className="maputnik-checkbox-box">
<svg style={{
display: this.props.value ? "inline" : "none"
}} className="maputnik-checkbox-icon" viewBox='0 0 32 32'>
<path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' />
</svg>
</div>
</div>;
}
}
+77 -76
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React from "react";
import Color from "color";
import ChromePicker from "react-color/lib/components/chrome/Chrome";
import {type ColorResult} from "react-color";
@@ -20,27 +20,26 @@ export type InputColorProps = {
};
/*** Number fields with support for min, max and units and documentation*/
export const InputColor: React.FC<InputColorProps> = (props) => {
const [pickerOpened, setPickerOpened] = useState(false);
const colorInput = useRef<HTMLInputElement | null>(null);
export default class InputColor extends React.Component<InputColorProps> {
state = {
pickerOpened: false
};
colorInput: HTMLInputElement | null = null;
// Keep the latest `onChange` available to the throttled callback, which is
// created only once so that throttling actually takes effect.
const onChangeProp = useRef(props.onChange);
useEffect(() => {
onChangeProp.current = props.onChange;
});
constructor (props: InputColorProps) {
super(props);
this.onChangeNoCheck = lodash.throttle(this.onChangeNoCheck, 1000/30);
}
const onChangeNoCheck = useMemo(
() => lodash.throttle((v: string) => onChangeProp.current(v), 1000/30),
[]
);
onChangeNoCheck(v: string) {
this.props.onChange(v);
}
//TODO: I much rather would do this with absolute positioning
//but I am too stupid to get it to work together with fixed position
//and scrollbars so I have to fallback to JavaScript
const calcPickerOffset = () => {
const elem = colorInput.current;
calcPickerOffset = () => {
const elem = this.colorInput;
if(elem) {
const pos = elem.getBoundingClientRect();
return {
@@ -55,80 +54,82 @@ export const InputColor: React.FC<InputColorProps> = (props) => {
}
};
const togglePicker = () => {
setPickerOpened(opened => !opened);
togglePicker = () => {
this.setState({ pickerOpened: !this.state.pickerOpened });
};
const getColor = () => {
get color() {
// Catch invalid color.
try {
return Color(props.value).rgb();
return Color(this.props.value).rgb();
}
catch(err) {
console.warn("Error parsing color: ", err);
return Color("rgb(255,255,255)");
}
};
}
const onChange = (v: string) => {
props.onChange(v === "" ? undefined : v);
};
onChange (v: string) {
this.props.onChange(v === "" ? undefined : v);
}
const offset = calcPickerOffset();
const currentColor = getColor().object();
const currentChromeColor = {
r: currentColor.r,
g: currentColor.g,
b: currentColor.b,
// Rename alpha -> a for ChromePicker
a: currentColor.alpha!
};
render() {
const offset = this.calcPickerOffset();
const currentColor = this.color.object();
const currentChromeColor = {
r: currentColor.r,
g: currentColor.g,
b: currentColor.b,
// Rename alpha -> a for ChromePicker
a: currentColor.alpha!
};
const picker = <div
className="maputnik-color-picker-offset"
style={{
position: "fixed",
zIndex: 1,
left: offset.left,
top: offset.top,
}}>
<ChromePicker
color={currentChromeColor}
onChange={c => onChangeNoCheck(formatColor(c))}
/>
<div
const picker = <div
className="maputnik-color-picker-offset"
onClick={togglePicker}
style={{
zIndex: -1,
position: "fixed",
top: "0px",
right: "0px",
bottom: "0px",
left: "0px",
}}
/>
</div>;
zIndex: 1,
left: offset.left,
top: offset.top,
}}>
<ChromePicker
color={currentChromeColor}
onChange={c => this.onChangeNoCheck(formatColor(c))}
/>
<div
className="maputnik-color-picker-offset"
onClick={this.togglePicker}
style={{
zIndex: -1,
position: "fixed",
top: "0px",
right: "0px",
bottom: "0px",
left: "0px",
}}
/>
</div>;
const swatchStyle = {
backgroundColor: props.value
};
const swatchStyle = {
backgroundColor: this.props.value
};
return <div className="maputnik-color-wrapper">
{pickerOpened && picker}
<div className="maputnik-color-swatch" style={swatchStyle}></div>
<input
aria-label={props["aria-label"]}
spellCheck="false"
autoComplete="off"
className="maputnik-color"
ref={colorInput}
onClick={togglePicker}
style={props.style}
name={props.name}
placeholder={props.default}
value={props.value ? props.value : ""}
onChange={(e) => onChange(e.target.value)}
/>
</div>;
};
return <div className="maputnik-color-wrapper">
{this.state.pickerOpened && picker}
<div className="maputnik-color-swatch" style={swatchStyle}></div>
<input
aria-label={this.props["aria-label"]}
spellCheck="false"
autoComplete="off"
className="maputnik-color"
ref={(input) => {this.colorInput = input;}}
onClick={this.togglePicker}
style={this.props.style}
name={this.props.name}
placeholder={this.props.default}
value={this.props.value ? this.props.value : ""}
onChange={(e) => this.onChange(e.target.value)}
/>
</div>;
}
}
+122 -114
View File
@@ -3,13 +3,13 @@ import capitalize from "lodash.capitalize";
import {MdDelete} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next";
import { InputString } from "./InputString";
import { InputNumber } from "./InputNumber";
import { InputButton } from "./InputButton";
import { FieldDocLabel } from "./FieldDocLabel";
import { InputEnum } from "./InputEnum";
import { InputUrl } from "./InputUrl";
import { InputColor } from "./InputColor";
import InputString from "./InputString";
import InputNumber from "./InputNumber";
import InputButton from "./InputButton";
import FieldDocLabel from "./FieldDocLabel";
import InputEnum from "./InputEnum";
import InputUrl from "./InputUrl";
import InputColor from "./InputColor";
export type InputDynamicArrayProps = {
@@ -27,130 +27,138 @@ export type InputDynamicArrayProps = {
type InputDynamicArrayInternalProps = InputDynamicArrayProps & WithTranslation;
const InputDynamicArrayInternal: React.FC<InputDynamicArrayInternalProps> = (props) => {
const values = props.value || props.default || [];
class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInternalProps> {
changeValue(idx: number, newValue: string | number | undefined) {
const values = this.values.slice(0);
values[idx] = newValue;
if (this.props.onChange) this.props.onChange(values);
}
const changeValue = (idx: number, newValue: string | number | undefined) => {
const newValues = values.slice(0);
newValues[idx] = newValue;
if (props.onChange) props.onChange(newValues);
};
get values() {
return this.props.value || this.props.default || [];
}
const addValue = () => {
const newValues = values.slice(0);
if (props.type === "number") {
newValues.push(0);
addValue = () => {
const values = this.values.slice(0);
if (this.props.type === "number") {
values.push(0);
}
else if (props.type === "url") {
newValues.push("");
else if (this.props.type === "url") {
values.push("");
}
else if (props.type === "enum") {
const {fieldSpec} = props;
else if (this.props.type === "enum") {
const {fieldSpec} = this.props;
const defaultValue = Object.keys(fieldSpec!.values)[0];
newValues.push(defaultValue);
} else if (props.type === "color") {
newValues.push("#000000");
values.push(defaultValue);
} else if (this.props.type === "color") {
values.push("#000000");
} else {
newValues.push("");
values.push("");
}
if (props.onChange) props.onChange(newValues);
if (this.props.onChange) this.props.onChange(values);
};
const deleteValue = (valueIdx: number) => {
const newValues = values.slice(0);
newValues.splice(valueIdx, 1);
deleteValue(valueIdx: number) {
const values = this.values.slice(0);
values.splice(valueIdx, 1);
if (props.onChange) props.onChange(newValues.length > 0 ? newValues : undefined);
};
if (this.props.onChange) this.props.onChange(values.length > 0 ? values : undefined);
}
const t = props.t;
const i18nProps = { t, i18n: props.i18n, tReady: props.tReady };
const inputs = values.map((v, i) => {
const deleteValueBtn= <DeleteValueInputButton
onClick={deleteValue.bind(null, i)}
{...i18nProps}
/>;
let input;
if(props.type === "url") {
input = <InputUrl
value={v as string}
onChange={changeValue.bind(null, i)}
aria-label={props["aria-label"] || props.label}
render() {
const t = this.props.t;
const i18nProps = { t, i18n: this.props.i18n, tReady: this.props.tReady };
const inputs = this.values.map((v, i) => {
const deleteValueBtn= <DeleteValueInputButton
onClick={this.deleteValue.bind(this, i)}
{...i18nProps}
/>;
}
else if (props.type === "number") {
input = <InputNumber
value={v as number}
onChange={changeValue.bind(null, i)}
aria-label={props["aria-label"] || props.label}
/>;
}
else if (props.type === "enum") {
const options = Object.keys(props.fieldSpec?.values).map(v => [v, capitalize(v)]);
input = <InputEnum
options={options}
value={v as string}
onChange={changeValue.bind(null, i)}
aria-label={props["aria-label"] || props.label}
/>;
}
else if (props.type === "color") {
input = <InputColor
value={v as string}
onChange={changeValue.bind(null, i)}
aria-label={props["aria-label"] || props.label}
/>;
}
else {
input = <InputString
value={v as string}
onChange={changeValue.bind(null, i)}
aria-label={props["aria-label"] || props.label}
/>;
}
let input;
if(this.props.type === "url") {
input = <InputUrl
value={v as string}
onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label}
/>;
}
else if (this.props.type === "number") {
input = <InputNumber
value={v as number}
onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label}
/>;
}
else if (this.props.type === "enum") {
const options = Object.keys(this.props.fieldSpec?.values).map(v => [v, capitalize(v)]);
input = <InputEnum
options={options}
value={v as string}
onChange={this.changeValue.bind(this, i)}
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 {
input = <InputString
value={v as string}
onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label}
/>;
}
return <div
style={props.style}
key={i}
className="maputnik-array-block"
>
<div className="maputnik-array-block-action">
{deleteValueBtn}
</div>
<div className="maputnik-array-block-content">
{input}
</div>
</div>;
});
return (
<div className="maputnik-array">
{inputs}
<InputButton
className="maputnik-array-add-value"
onClick={addValue}
return <div
style={this.props.style}
key={i}
className="maputnik-array-block"
>
{t("Add value")}
</InputButton>
</div>
);
};
<div className="maputnik-array-block-action">
{deleteValueBtn}
</div>
<div className="maputnik-array-block-content">
{input}
</div>
</div>;
});
return (
<div className="maputnik-array">
{inputs}
<InputButton
className="maputnik-array-add-value"
onClick={this.addValue}
>
{t("Add value")}
</InputButton>
</div>
);
}
}
const InputDynamicArray = withTranslation()(InputDynamicArrayInternal);
export default InputDynamicArray;
export const InputDynamicArray = withTranslation()(InputDynamicArrayInternal);
type DeleteValueInputButtonProps = {
onClick?(...args: unknown[]): unknown
} & WithTranslation;
const DeleteValueInputButton: React.FC<DeleteValueInputButtonProps> = (props) => {
const t = props.t;
return <InputButton
className="maputnik-delete-stop"
onClick={props.onClick}
title={t("Remove array item")}
>
<FieldDocLabel
label={<MdDelete />}
/>
</InputButton>;
};
class DeleteValueInputButton extends React.Component<DeleteValueInputButtonProps> {
render() {
const t = this.props.t;
return <InputButton
className="maputnik-delete-stop"
onClick={this.props.onClick}
title={t("Remove array item")}
>
<FieldDocLabel
label={<MdDelete />}
/>
</InputButton>;
}
}
+22 -20
View File
@@ -1,6 +1,6 @@
import React from "react";
import { InputSelect } from "./InputSelect";
import { InputMultiInput } from "./InputMultiInput";
import InputSelect from "./InputSelect";
import InputMultiInput from "./InputMultiInput";
function optionsLabelLength(options: any[]) {
@@ -25,23 +25,25 @@ export type InputEnumProps = {
};
export const InputEnum: React.FC<InputEnumProps> = (props) => {
const {options, value, onChange, name, label} = props;
export default class InputEnum extends React.Component<InputEnumProps> {
render() {
const {options, value, onChange, name, label} = this.props;
if(options.length <= 3 && optionsLabelLength(options) <= 20) {
return <InputMultiInput
name={name}
options={options}
value={(value || props.default)!}
onChange={onChange}
aria-label={props["aria-label"] || label}
/>;
} else {
return <InputSelect
options={options}
value={(value || props.default)!}
onChange={onChange}
aria-label={props["aria-label"] || label}
/>;
if(options.length <= 3 && optionsLabelLength(options) <= 20) {
return <InputMultiInput
name={name}
options={options}
value={(value || this.props.default)!}
onChange={onChange}
aria-label={this.props["aria-label"] || label}
/>;
} else {
return <InputSelect
options={options}
value={(value || this.props.default)!}
onChange={onChange}
aria-label={this.props["aria-label"] || label}
/>;
}
}
};
}
+33 -29
View File
@@ -1,5 +1,5 @@
import React from "react";
import { InputAutocomplete } from "./InputAutocomplete";
import InputAutocomplete from "./InputAutocomplete";
export type InputFontProps = {
name: string
@@ -11,9 +11,13 @@ export type InputFontProps = {
"aria-label"?: string
};
export const InputFont: React.FC<InputFontProps> = ({fonts = [], ...props}) => {
const getValues = () => {
const out = props.value || props.default || [];
export default class InputFont extends React.Component<InputFontProps> {
static defaultProps = {
fonts: []
};
get values() {
const out = this.props.value || this.props.default || [];
// Always put a "" in the last field to you can keep adding entries
if (out[out.length-1] !== ""){
@@ -22,36 +26,36 @@ export const InputFont: React.FC<InputFontProps> = ({fonts = [], ...props}) => {
else {
return out;
}
};
}
const values = getValues();
const changeFont = (idx: number, newValue: string) => {
const changedValues = values.slice(0);
changeFont(idx: number, newValue: string) {
const changedValues = this.values.slice(0);
changedValues[idx] = newValue;
const filteredValues = changedValues
.filter(v => v !== undefined)
.filter(v => v !== "");
props.onChange(filteredValues);
};
this.props.onChange(filteredValues);
}
const inputs = values.map((value, i) => {
return <li
key={i}
>
<InputAutocomplete
aria-label={props["aria-label"] || props.name}
value={value}
options={fonts.map(f => [f, f])}
onChange={changeFont.bind(null, i)}
/>
</li>;
});
render() {
const inputs = this.values.map((value, i) => {
return <li
key={i}
>
<InputAutocomplete
aria-label={this.props["aria-label"] || this.props.name}
value={value}
options={this.props.fonts?.map(f => [f, f])}
onChange={this.changeFont.bind(this, i)}
/>
</li>;
});
return (
<ul className="maputnik-font">
{inputs}
</ul>
);
};
return (
<ul className="maputnik-font">
{inputs}
</ul>
);
}
}
+89 -89
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react";
import React from "react";
import classnames from "classnames";
import { type WithTranslation, withTranslation } from "react-i18next";
@@ -25,49 +25,88 @@ export type InputJsonProps = {
};
type InputJsonInternalProps = InputJsonProps & WithTranslation;
function getPrettyJson(data: any) {
return stringifyPretty(data, {indent: 2, maxLength: 40});
}
type InputJsonState = {
isEditing: boolean
prevValue: string
};
const InputJsonInternal: React.FC<InputJsonInternalProps> = ({
value,
className,
onChange,
onFocus = () => {},
onBlur = () => {},
lintType,
spec,
withScroll = false,
}) => {
const el = useRef<HTMLDivElement | null>(null);
const view = useRef<EditorView | undefined>(undefined);
const cancelNextChange = useRef<boolean>(false);
// `isEditing` and `prevValue` are never rendered, they are only read from the
// CodeMirror callbacks, so refs keep them up to date without re-rendering.
const isEditing = useRef<boolean>(false);
const prevValue = useRef<string>(getPrettyJson(value));
// Mirrors `prevProps.value` from the previous `componentDidUpdate`.
const prevValueProp = useRef<object>(value);
class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJsonState> {
static defaultProps = {
onFocus: () => {},
onBlur: () => {},
withScroll: false
};
_view: EditorView | undefined;
_el: HTMLDivElement | null = null;
_cancelNextChange: boolean = false;
const handleFocus = () => {
if (onFocus) onFocus();
isEditing.current = true;
constructor(props: InputJsonInternalProps) {
super(props);
this.state = {
isEditing: false,
prevValue: this.getPrettyJson(this.props.value),
};
}
getPrettyJson(data: any) {
return stringifyPretty(data, {indent: 2, maxLength: 40});
}
componentDidMount () {
this._view = createEditor({
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 = () => {
if (this.props.onFocus) this.props.onFocus();
this.setState({
isEditing: true,
});
};
const handleBlur = () => {
if (onBlur) onBlur();
isEditing.current = false;
onBlur = () => {
if (this.props.onBlur) this.props.onBlur();
this.setState({
isEditing: false,
});
};
const handleChange = () => {
if (cancelNextChange.current) {
cancelNextChange.current = false;
prevValue.current = view.current!.state.doc.toString();
componentDidUpdate(prevProps: InputJsonProps) {
if (!this.state.isEditing && prevProps.value !== this.props.value) {
this._cancelNextChange = true;
const transactionSpec: TransactionSpec = {
changes: {
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);
}
}
onChange = (_e: unknown) => {
if (this._cancelNextChange) {
this._cancelNextChange = false;
this.setState({
prevValue: this._view!.state.doc.toString(),
});
return;
}
const newCode = view.current!.state.doc.toString();
const newCode = this._view!.state.doc.toString();
if (prevValue.current !== newCode) {
if (this.state.prevValue !== newCode) {
let parsedLayer, err;
try {
parsedLayer = JSON.parse(newCode);
@@ -77,63 +116,24 @@ const InputJsonInternal: React.FC<InputJsonInternalProps> = ({
}
if (!err) {
if (onChange) onChange(parsedLayer);
if (this.props.onChange) this.props.onChange(parsedLayer);
}
}
prevValue.current = newCode;
this.setState({
prevValue: newCode,
});
};
// The editor is created once on mount, so its callbacks have to go through a
// ref to always see the latest props.
const handlers = useRef({handleChange, handleFocus, handleBlur});
useEffect(() => {
handlers.current = {handleChange, handleFocus, handleBlur};
});
render() {
return <div className="json-editor" data-wd-key="json-editor" aria-hidden="true" style={{cursor: "text"}}>
<div
className={classnames("codemirror-container", this.props.className)}
ref={(el) => {this._el = el;}}
/>
</div>;
}
}
useEffect(() => {
view.current = createEditor({
parent: el.current!,
value: getPrettyJson(value),
lintType: lintType || "layer",
onChange: () => handlers.current.handleChange(),
onFocus: () => handlers.current.handleFocus(),
onBlur: () => handlers.current.handleBlur(),
spec: spec
});
// Runs once on mount, mirroring the previous componentDidMount.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only: adding deps would rebuild the editor on every change
}, []);
useEffect(() => {
// Only react to an actual change of the `value` prop, which also skips the
// initial mount (the editor is created with the value already).
if (prevValueProp.current === value) return;
prevValueProp.current = value;
if (isEditing.current) return;
cancelNextChange.current = true;
const transactionSpec: TransactionSpec = {
changes: {
from: 0,
to: view.current!.state.doc.length,
insert: getPrettyJson(value)
}
};
if (withScroll) {
transactionSpec.selection = view.current!.state.selection;
transactionSpec.scrollIntoView = true;
}
view.current!.dispatch(transactionSpec);
}, [value, withScroll]);
return <div className="json-editor" data-wd-key="json-editor" aria-hidden="true" style={{cursor: "text"}}>
<div
className={classnames("codemirror-container", className)}
ref={el}
/>
</div>;
};
export const InputJson = withTranslation()(InputJsonInternal);
const InputJson = withTranslation()(InputJsonInternal);
export default InputJson;
+27 -25
View File
@@ -9,29 +9,31 @@ export type InputMultiInputProps = {
"aria-label"?: string
};
export const InputMultiInput: React.FC<InputMultiInputProps> = (props) => {
let options = props.options;
if(options.length > 0 && !Array.isArray(options[0])) {
options = options.map(v => [v, v]);
export default class InputMultiInput extends React.Component<InputMultiInputProps> {
render() {
let options = this.props.options;
if(options.length > 0 && !Array.isArray(options[0])) {
options = options.map(v => [v, v]);
}
const selectedValue = this.props.value || options[0][0];
const radios = options.map(([val, label])=> {
return <label
key={val}
className={classnames("maputnik-button", "maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})}
>
<input type="radio"
name={this.props.name}
onChange={_e => this.props.onChange(val)}
value={val}
checked={val === selectedValue}
/>
{label}
</label>;
});
return <fieldset className="maputnik-multibutton" aria-label={this.props["aria-label"]}>
{radios}
</fieldset>;
}
const selectedValue = props.value || options[0][0];
const radios = options.map(([val, label])=> {
return <label
key={val}
className={classnames("maputnik-button", "maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})}
>
<input type="radio"
name={props.name}
onChange={_e => props.onChange(val)}
value={val}
checked={val === selectedValue}
/>
{label}
</label>;
});
return <fieldset className="maputnik-multibutton" aria-label={props["aria-label"]}>
{radios}
</fieldset>;
};
}
+169 -141
View File
@@ -1,4 +1,5 @@
import React, { type BaseSyntheticEvent, useRef, useState } from "react";
import React, { type BaseSyntheticEvent } from "react";
import generateUniqueId from "../libs/document-uid";
export type InputNumberProps = {
value?: number
@@ -13,206 +14,233 @@ export type InputNumberProps = {
"aria-label"?: string
};
export const InputNumber: React.FC<InputNumberProps> = (props) => {
const { rangeStep = 1 } = props;
type InputNumberState = {
uuid: number
editing: boolean
editingRange?: boolean
value?: number
/**
* This is the value that is currently being edited. It can be an invalid value.
*/
dirtyValue?: number | string | undefined
};
const [editing, setEditing] = useState(false);
const [editingRange, setEditingRange] = useState(false);
const [value, setValue] = useState<number | undefined>(props.value);
/** The value currently being edited. It can be an invalid value. */
const [dirtyValue, setDirtyValue] = useState<number | string | undefined>(props.value);
export default class InputNumber extends React.Component<InputNumberProps, InputNumberState> {
static defaultProps = {
rangeStep: 1
};
_keyboardEvent: boolean = false;
const keyboardEvent = useRef(false);
// Replaces getDerivedStateFromProps: while not editing, track the prop.
if (!editing && props.value !== value) {
setValue(props.value);
setDirtyValue(props.value);
constructor(props: InputNumberProps) {
super(props);
this.state = {
uuid: +generateUniqueId(),
editing: false,
value: props.value,
dirtyValue: props.value,
};
}
function isValid(v: number | string | undefined) {
static getDerivedStateFromProps(props: Readonly<InputNumberProps>, state: InputNumberState) {
if (!state.editing && props.value !== state.value) {
return {
value: props.value,
dirtyValue: props.value,
};
}
return null;
}
changeValue(newValue: number | string | undefined) {
const value = (newValue === "" || newValue === undefined) ?
undefined : +newValue;
const hasChanged = this.props.value !== value;
if(this.isValid(value) && hasChanged) {
if (this.props.onChange) this.props.onChange(value);
this.setState({
value: value,
});
}
else if (!this.isValid(value) && hasChanged) {
this.setState({
value: undefined,
});
}
this.setState({
dirtyValue: newValue === "" ? undefined : newValue,
});
}
isValid(v: number | string | undefined) {
if (v === undefined) {
return true;
}
const val = +v;
if(isNaN(val)) {
const value = +v;
if(isNaN(value)) {
return false;
}
if(!isNaN(props.min!) && val < props.min!) {
if(!isNaN(this.props.min!) && value < this.props.min!) {
return false;
}
if(!isNaN(props.max!) && val > props.max!) {
if(!isNaN(this.props.max!) && value > this.props.max!) {
return false;
}
return true;
}
function changeValue(newValue: number | string | undefined) {
const val = (newValue === "" || newValue === undefined) ?
undefined : +newValue;
const hasChanged = props.value !== val;
if(isValid(val) && hasChanged) {
if (props.onChange) props.onChange(val);
setValue(val);
}
else if (!isValid(val) && hasChanged) {
setValue(undefined);
}
setDirtyValue(newValue === "" ? undefined : newValue);
}
const resetValue = () => {
setEditing(false);
resetValue = () => {
this.setState({editing: false});
// Reset explicitly to default value if value has been cleared
if(!value) {
if(!this.state.value) {
return;
}
// If set value is invalid fall back to the last valid value from props or at last resort the default value
if (!isValid(value)) {
if(isValid(props.value)) {
changeValue(props.value);
setDirtyValue(props.value);
if (!this.isValid(this.state.value)) {
if(this.isValid(this.props.value)) {
this.changeValue(this.props.value);
this.setState({dirtyValue: this.props.value});
} else {
changeValue(undefined);
setDirtyValue(undefined);
this.changeValue(undefined);
this.setState({dirtyValue: undefined});
}
}
};
const onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
let newValue = parseFloat(e.target.value);
const step = rangeStep;
let newDirtyValue = newValue;
onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
let value = parseFloat(e.target.value);
const step = this.props.rangeStep;
let dirtyValue = value;
if(step) {
// Can't do this with the <input/> range step attribute else we won't be able to set a high precision value via the text input.
const snap = newValue % step;
const snap = value % step;
// Round up/down to step
if (keyboardEvent.current) {
if (this._keyboardEvent) {
// If it's keyboard event we might get a low positive/negative value,
// for example we might go from 13 to 13.23, however because we know
// that came from a keyboard event we always want to increase by a
// single step value.
if (newValue < +dirtyValue!) {
newValue = value! - step;
if (value < +this.state.dirtyValue!) {
value = this.state.value! - step;
}
else {
newValue = value! + step;
value = this.state.value! + step;
}
newDirtyValue = newValue;
dirtyValue = value;
}
else {
if (snap < step/2) {
newValue = newValue - snap;
value = value - snap;
}
else {
newValue = newValue + (step - snap);
value = value + (step - snap);
}
}
}
keyboardEvent.current = false;
this._keyboardEvent = false;
// Clamp between min/max
newValue = Math.max(props.min!, Math.min(props.max!, newValue));
value = Math.max(this.props.min!, Math.min(this.props.max!, value));
setValue(newValue);
setDirtyValue(newDirtyValue);
if (props.onChange) props.onChange(newValue);
this.setState({value, dirtyValue});
if (this.props.onChange) this.props.onChange(value);
};
if(
Object.prototype.hasOwnProperty.call(props, "min") &&
Object.prototype.hasOwnProperty.call(props, "max") &&
props.min !== undefined && props.max !== undefined &&
props.allowRange
) {
const currentValue = editing ? dirtyValue : value;
const defaultValue = props.default === undefined ? "" : props.default;
let inputValue;
if (editingRange) {
inputValue = value;
render() {
if(
Object.prototype.hasOwnProperty.call(this.props, "min") &&
Object.prototype.hasOwnProperty.call(this.props, "max") &&
this.props.min !== undefined && this.props.max !== undefined &&
this.props.allowRange
) {
const value = this.state.editing ? this.state.dirtyValue : this.state.value;
const defaultValue = this.props.default === undefined ? "" : this.props.default;
let inputValue;
if (this.state.editingRange) {
inputValue = this.state.value;
}
else {
inputValue = value;
}
return <div className="maputnik-number-container">
<input
className="maputnik-number-range"
key="range"
type="range"
max={this.props.max}
min={this.props.min}
step="any"
spellCheck="false"
value={value === undefined ? defaultValue : value}
onChange={this.onChangeRange}
onKeyDown={() => {
this._keyboardEvent = true;
}}
onPointerDown={() => {
this.setState({editing: true, editingRange: true});
}}
onPointerUp={() => {
// Safari doesn't get onBlur event
this.setState({editing: false, editingRange: false});
}}
onBlur={() => {
this.setState({
editing: false,
editingRange: false,
dirtyValue: this.state.value,
});
}}
data-wd-key={this.props["data-wd-key"] + "-range"}
/>
<input
key="text"
type="text"
spellCheck="false"
className="maputnik-number"
placeholder={this.props.default?.toString()}
value={inputValue === undefined ? "" : inputValue}
onFocus={_e => {
this.setState({editing: true});
}}
onChange={e => {
this.changeValue(e.target.value);
}}
onBlur={_e => {
this.setState({editing: false});
this.resetValue();
}}
data-wd-key={this.props["data-wd-key"] + "-text"}
/>
</div>;
}
else {
inputValue = currentValue;
}
const value = this.state.editing ? this.state.dirtyValue : this.state.value;
return <div className="maputnik-number-container">
<input
className="maputnik-number-range"
key="range"
type="range"
max={props.max}
min={props.min}
step="any"
spellCheck="false"
value={currentValue === undefined ? defaultValue : currentValue}
onChange={onChangeRange}
onKeyDown={() => {
keyboardEvent.current = true;
}}
onPointerDown={() => {
setEditing(true);
setEditingRange(true);
}}
onPointerUp={() => {
// Safari doesn't get onBlur event
setEditing(false);
setEditingRange(false);
}}
onBlur={() => {
setEditing(false);
setEditingRange(false);
setDirtyValue(value);
}}
data-wd-key={props["data-wd-key"] + "-range"}
/>
<input
key="text"
type="text"
return <input
aria-label={this.props["aria-label"]}
spellCheck="false"
className="maputnik-number"
placeholder={props.default?.toString()}
value={inputValue === undefined ? "" : inputValue}
onFocus={_e => {
setEditing(true);
placeholder={this.props.default?.toString()}
value={value === undefined ? "" : value}
onChange={e => this.changeValue(e.target.value)}
onFocus={() => {
this.setState({editing: true});
}}
onChange={e => {
changeValue(e.target.value);
}}
onBlur={_e => {
setEditing(false);
resetValue();
}}
data-wd-key={props["data-wd-key"] + "-text"}
/>
</div>;
onBlur={this.resetValue}
required={this.props.required}
data-wd-key={this.props["data-wd-key"]}
/>;
}
}
else {
const currentValue = editing ? dirtyValue : value;
return <input
aria-label={props["aria-label"]}
spellCheck="false"
className="maputnik-number"
placeholder={props.default?.toString()}
value={currentValue === undefined ? "" : currentValue}
onChange={e => changeValue(e.target.value)}
onFocus={() => {
setEditing(true);
}}
onBlur={resetValue}
required={props.required}
data-wd-key={props["data-wd-key"]}
/>;
}
};
}
+19 -17
View File
@@ -10,21 +10,23 @@ export type InputSelectProps = {
"aria-label"?: string
};
export const InputSelect: React.FC<InputSelectProps> = (props) => {
let options = props.options;
if(options.length > 0 && !Array.isArray(options[0])) {
options = options.map((v) => [v, v]) as [string, any][];
}
export default class InputSelect extends React.Component<InputSelectProps> {
render() {
let options = this.props.options;
if(options.length > 0 && !Array.isArray(options[0])) {
options = options.map((v) => [v, v]) as [string, any][];
}
return <select
className="maputnik-select"
data-wd-key={props["data-wd-key"]}
style={props.style}
title={props.title}
value={props.value}
onChange={e => props.onChange(e.target.value)}
aria-label={props["aria-label"]}
>
{ options.map(([val, label]) => <option key={val} value={val}>{label}</option>) }
</select>;
};
return <select
className="maputnik-select"
data-wd-key={this.props["data-wd-key"]}
style={this.props.style}
title={this.props.title}
value={this.props.value}
onChange={e => this.props.onChange(e.target.value)}
aria-label={this.props["aria-label"]}
>
{ options.map(([val, label]) => <option key={val} value={val}>{label}</option>) }
</select>;
}
}
+49 -47
View File
@@ -1,14 +1,14 @@
import React, { type ReactElement } from "react";
import { InputColor, type InputColorProps } from "./InputColor";
import { InputNumber, type InputNumberProps } from "./InputNumber";
import { InputCheckbox, type InputCheckboxProps } from "./InputCheckbox";
import { InputString, type InputStringProps } from "./InputString";
import { InputArray, type InputArrayProps } from "./InputArray";
import { InputDynamicArray, type InputDynamicArrayProps } from "./InputDynamicArray";
import { InputFont, type InputFontProps } from "./InputFont";
import { InputAutocomplete, type InputAutocompleteProps } from "./InputAutocomplete";
import { InputEnum, type InputEnumProps } from "./InputEnum";
import InputColor, { type InputColorProps } from "./InputColor";
import InputNumber, { type InputNumberProps } from "./InputNumber";
import InputCheckbox, { type InputCheckboxProps } from "./InputCheckbox";
import InputString, { type InputStringProps } from "./InputString";
import InputArray, { type InputArrayProps } from "./InputArray";
import InputDynamicArray, { type InputDynamicArrayProps } from "./InputDynamicArray";
import InputFont, { type InputFontProps } from "./InputFont";
import InputAutocomplete, { type InputAutocompleteProps } from "./InputAutocomplete";
import InputEnum, { type InputEnumProps } from "./InputEnum";
import capitalize from "lodash.capitalize";
const iconProperties = ["background-pattern", "fill-pattern", "line-pattern", "fill-extrusion-pattern", "icon-image"];
@@ -38,31 +38,31 @@ export type InputSpecProps = {
/** Display any field from the Maplibre GL style spec and
* choose the correct field component based on the @{fieldSpec}
* to display @{value}. */
export const InputSpec: React.FC<InputSpecProps> = (props) => {
export default class InputSpec extends React.Component<InputSpecProps> {
const childNodes = () => {
childNodes() {
const commonProps = {
fieldSpec: props.fieldSpec,
label: props.label,
action: props.action,
style: props.style,
value: props.value,
default: props.fieldSpec?.default,
name: props.fieldName,
"data-wd-key": "spec-field-input:" + props.fieldName,
onChange: (newValue: number | undefined | (string | number | undefined)[]) => props.onChange!(props.fieldName, newValue),
"aria-label": props["aria-label"],
fieldSpec: this.props.fieldSpec,
label: this.props.label,
action: this.props.action,
style: this.props.style,
value: this.props.value,
default: this.props.fieldSpec?.default,
name: 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),
"aria-label": this.props["aria-label"],
};
switch(props.fieldSpec?.type) {
switch(this.props.fieldSpec?.type) {
case "number": return (
<InputNumber
{...commonProps as InputNumberProps}
min={props.fieldSpec.minimum}
max={props.fieldSpec.maximum}
min={this.props.fieldSpec.minimum}
max={this.props.fieldSpec.maximum}
/>
);
case "enum": {
const options = Object.keys(props.fieldSpec.values || []).map(v => [v, capitalize(v)]);
const options = Object.keys(this.props.fieldSpec.values || []).map(v => [v, capitalize(v)]);
return <InputEnum
{...commonProps as Omit<InputEnumProps, "options">}
@@ -72,8 +72,8 @@ export const InputSpec: React.FC<InputSpecProps> = (props) => {
case "resolvedImage":
case "formatted":
case "string":
if (iconProperties.indexOf(props.fieldName!) >= 0) {
const options = props.fieldSpec.values || [];
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])}
@@ -94,59 +94,61 @@ export const InputSpec: React.FC<InputSpecProps> = (props) => {
/>
);
case "array":
if(props.fieldName === "text-font") {
if(this.props.fieldName === "text-font") {
return <InputFont
{...commonProps as InputFontProps}
fonts={props.fieldSpec.values}
fonts={this.props.fieldSpec.values}
/>;
} else {
if (props.fieldSpec.length) {
if (this.props.fieldSpec.length) {
return <InputArray
{...commonProps as InputArrayProps}
type={props.fieldSpec.value}
length={props.fieldSpec.length}
type={this.props.fieldSpec.value}
length={this.props.fieldSpec.length}
/>;
} else {
return <InputDynamicArray
{...commonProps as InputDynamicArrayProps}
fieldSpec={props.fieldSpec}
type={props.fieldSpec.value as InputDynamicArrayProps["type"]}
fieldSpec={this.props.fieldSpec}
type={this.props.fieldSpec.value as InputDynamicArrayProps["type"]}
/>;
}
}
case "numberArray": return (
<InputDynamicArray
{...commonProps as InputDynamicArrayProps}
fieldSpec={props.fieldSpec}
fieldSpec={this.props.fieldSpec}
type="number"
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]}
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
/>
);
case "colorArray": return (
<InputDynamicArray
{...commonProps as InputDynamicArrayProps}
fieldSpec={props.fieldSpec}
fieldSpec={this.props.fieldSpec}
type="color"
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]}
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(props.value) ? props.value : [props.value]) as (string | number | undefined)[]}
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 ${props.fieldName} type: ${props.fieldSpec?.type}`);
console.warn(`No proper field input for ${this.props.fieldName} type: ${this.props.fieldSpec?.type}`);
return null;
}
};
}
return (
<div data-wd-key={"spec-field:"+props.fieldName}>
{childNodes()}
</div>
);
};
render() {
return (
<div data-wd-key={"spec-field:"+this.props.fieldName}>
{this.childNodes()}
</div>
);
}
}
+82 -63
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React from "react";
export type InputStringProps = {
"data-wd-key"?: string
@@ -15,66 +15,85 @@ export type InputStringProps = {
title?: string
};
export const InputString: React.FC<InputStringProps> = (props) => {
const { onInput = () => {} } = props;
const [editing, setEditing] = useState(false);
const [value, setValue] = useState<string | undefined>(props.value);
// Replaces getDerivedStateFromProps: while the field is not being edited its
// value tracks the prop, so an external change to the style is picked up.
if (!editing && value !== props.value) {
setValue(props.value);
}
let tag;
let classes;
if(props.multi) {
tag = "textarea";
classes = [
"maputnik-string",
"maputnik-string--multi"
];
}
else {
tag = "input";
classes = [
"maputnik-string"
];
}
if(props.disabled) {
classes.push("maputnik-string--disabled");
}
return React.createElement(tag, {
"aria-label": props["aria-label"],
"data-wd-key": props["data-wd-key"],
spellCheck: Object.prototype.hasOwnProperty.call(props, "spellCheck") ? props.spellCheck : !(tag === "input"),
disabled: props.disabled,
className: classes.join(" "),
style: props.style,
value: value === undefined ? "" : value,
placeholder: props.default,
title: props.title,
onChange: (e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
setEditing(true);
setValue(e.target.value);
onInput(e.target.value);
},
onBlur: () => {
// Note: editing is only cleared when the value actually changed; this
// mirrors the original and keeps a no-op blur from resyncing the value.
if(value !== props.value) {
setEditing(false);
if (props.onChange) props.onChange(value);
}
},
onKeyDown: (e: React.KeyboardEvent) => {
if (e.keyCode === 13 && props.onChange) {
props.onChange(value);
}
},
required: props.required,
});
type InputStringState = {
editing: boolean
value?: string
};
export default class InputString extends React.Component<InputStringProps, InputStringState> {
static defaultProps = {
onInput: () => {},
};
constructor(props: InputStringProps) {
super(props);
this.state = {
editing: false,
value: props.value || ""
};
}
static getDerivedStateFromProps(props: Readonly<InputStringProps>, state: InputStringState) {
if (!state.editing) {
return {
value: props.value
};
}
return {};
}
render() {
let tag;
let classes;
if(this.props.multi) {
tag = "textarea";
classes = [
"maputnik-string",
"maputnik-string--multi"
];
}
else {
tag = "input";
classes = [
"maputnik-string"
];
}
if(this.props.disabled) {
classes.push("maputnik-string--disabled");
}
return React.createElement(tag, {
"aria-label": this.props["aria-label"],
"data-wd-key": this.props["data-wd-key"],
spellCheck: Object.prototype.hasOwnProperty.call(this.props, "spellCheck") ? this.props.spellCheck : !(tag === "input"),
disabled: this.props.disabled,
className: classes.join(" "),
style: this.props.style,
value: this.state.value === undefined ? "" : this.state.value,
placeholder: this.props.default,
title: this.props.title,
onChange: (e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
this.setState({
editing: true,
value: e.target.value
}, () => {
if (this.props.onInput) this.props.onInput(this.state.value);
});
},
onBlur: () => {
if(this.state.value!==this.props.value) {
this.setState({editing: false});
if (this.props.onChange) this.props.onChange(this.state.value);
}
},
onKeyDown: (e) => {
if (e.keyCode === 13 && this.props.onChange) {
this.props.onChange(this.state.value);
}
},
required: this.props.required,
});
}
}
+48 -28
View File
@@ -1,6 +1,6 @@
import React, { type JSX, useState } from "react";
import { InputString } from "./InputString";
import { SmallError } from "./SmallError";
import React, { type JSX } from "react";
import InputString from "./InputString";
import SmallError from "./SmallError";
import { Trans, type WithTranslation, withTranslation } from "react-i18next";
import { type TFunction } from "i18next";
import { ErrorType, validate } from "../libs/urlopen";
@@ -46,30 +46,50 @@ export type FieldUrlProps = {
type InputUrlInternalProps = FieldUrlProps & WithTranslation;
const InputUrlInternal: React.FC<InputUrlInternalProps> = ({onInput = () => {}, ...props}) => {
const [error, setError] = useState<ErrorType | undefined>(() => validate(props.value));
const handleInput = (url: string) => {
setError(validate(url));
onInput(url);
};
const handleChange = (url: string) => {
setError(validate(url));
props.onChange(url);
};
return (
<div>
<InputString
{...props}
onInput={handleInput}
onChange={handleChange}
aria-label={props["aria-label"]}
/>
{errorTypeToJsx(error, props.t)}
</div>
);
type InputUrlState = {
error?: ErrorType
};
export const InputUrl = withTranslation()(InputUrlInternal);
class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlState> {
static defaultProps = {
onInput: () => {},
};
constructor (props: InputUrlInternalProps) {
super(props);
this.state = {
error: validate(props.value),
};
}
onInput = (url: string) => {
this.setState({
error: validate(url),
});
if (this.props.onInput) this.props.onInput(url);
};
onChange = (url: string) => {
this.setState({
error: validate(url),
});
this.props.onChange(url);
};
render () {
return (
<div>
<InputString
{...this.props}
onInput={this.onInput}
onChange={this.onChange}
aria-label={this.props["aria-label"]}
/>
{errorTypeToJsx(this.state.error, this.props.t)}
</div>
);
}
}
const InputUrl = withTranslation()(InputUrlInternal);
export default InputUrl;
+210 -199
View File
@@ -1,4 +1,4 @@
import React, { type JSX, useState } from "react";
import React, { type JSX } from "react";
import { Wrapper, Button, Menu, MenuItem } from "react-aria-menubutton";
import { Accordion } from "react-accessible-accordion";
import { MdMoreVert } from "react-icons/md";
@@ -6,20 +6,18 @@ import { IconContext } from "react-icons";
import { type BackgroundLayerSpecification, type LayerSpecification, type SourceSpecification } from "maplibre-gl";
import { v8 } from "@maplibre/maplibre-gl-style-spec";
import { FieldJson } from "./FieldJson";
import { FilterEditor } from "./FilterEditor";
import { PropertyGroup } from "./PropertyGroup";
import { LayerEditorGroup } from "./LayerEditorGroup";
import { FieldType } from "./FieldType";
import { FieldId } from "./FieldId";
import { FieldMinZoom } from "./FieldMinZoom";
import { FieldMaxZoom } from "./FieldMaxZoom";
import { FieldComment } from "./FieldComment";
import { FieldSource } from "./FieldSource";
import { FieldSourceLayer } from "./FieldSourceLayer";
// Aliased: the component defines its own changeProperty, which would otherwise
// shadow this import (as a class method there was no collision).
import { changeType, changeProperty as changeLayerProperty } from "../libs/layer";
import FieldJson from "./FieldJson";
import FilterEditor from "./FilterEditor";
import PropertyGroup from "./PropertyGroup";
import LayerEditorGroup from "./LayerEditorGroup";
import FieldType from "./FieldType";
import FieldId from "./FieldId";
import FieldMinZoom from "./FieldMinZoom";
import FieldMaxZoom from "./FieldMaxZoom";
import FieldComment from "./FieldComment";
import FieldSource from "./FieldSource";
import FieldSourceLayer from "./FieldSourceLayer";
import { changeType, changeProperty } from "../libs/layer";
import { formatLayerId } from "../libs/format";
import { type WithTranslation, withTranslation } from "react-i18next";
import { type TFunction } from "i18next";
@@ -133,56 +131,67 @@ type LayerEditorInternalProps = {
errors?: MappedError[]
} & WithTranslation;
type LayerEditorState = {
editorGroups: { [keys: string]: boolean }
};
/** Layer editor supporting multiple types of layers. */
const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
onLayerChanged = () => { },
onLayerIdChange = () => { },
...rest
}) => {
const props = { onLayerChanged, onLayerIdChange, ...rest } as LayerEditorInternalProps;
class LayerEditorInternal extends React.Component<LayerEditorInternalProps, LayerEditorState> {
static defaultProps = {
onLayerChanged: () => { },
onLayerIdChange: () => { },
onLayerDestroyed: () => { },
};
const [editorGroups, setEditorGroups] = useState<{ [keys: string]: boolean }>(() => {
const groups: { [keys: string]: boolean } = {};
for (const group of layoutGroups(props.layer.type, props.t)) {
groups[group.title] = true;
}
return groups;
});
constructor(props: LayerEditorInternalProps) {
super(props);
// Replaces getDerivedStateFromProps: groups that appear after mount (because
// the layer type changed) start out expanded. Guarded so it only sets state
// when a group is genuinely new, otherwise this would loop every render.
const newGroups = getLayoutForType(props.layer.type, props.t)
.filter(group => !(group.title in editorGroups));
if (newGroups.length > 0) {
const additionalGroups = { ...editorGroups };
for (const group of newGroups) {
additionalGroups[group.title] = true;
const editorGroups: { [keys: string]: boolean } = {};
for (const group of layoutGroups(this.props.layer.type, props.t)) {
editorGroups[group.title] = true;
}
setEditorGroups(additionalGroups);
this.state = { editorGroups };
}
function changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
props.onLayerChanged(
props.layerIndex,
changeLayerProperty(props.layer, group, property, newValue)
static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) {
const additionalGroups = { ...state.editorGroups };
for (const group of getLayoutForType(props.layer.type, props.t)) {
if (!(group.title in additionalGroups)) {
additionalGroups[group.title] = true;
}
}
return {
editorGroups: additionalGroups
};
}
changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
this.props.onLayerChanged(
this.props.layerIndex,
changeProperty(this.props.layer, group, property, newValue)
);
}
function onGroupToggle(groupTitle: string, active: boolean) {
onGroupToggle(groupTitle: string, active: boolean) {
const changedActiveGroups = {
...editorGroups,
...this.state.editorGroups,
[groupTitle]: active,
};
setEditorGroups(changedActiveGroups);
this.setState({
editorGroups: changedActiveGroups
});
}
function renderGroupType(type: string, fields?: string[]): JSX.Element {
renderGroupType(type: string, fields?: string[]): JSX.Element {
let comment = "";
if (props.layer.metadata) {
comment = (props.layer.metadata as any)["maputnik:comment"];
if (this.props.layer.metadata) {
comment = (this.props.layer.metadata as any)["maputnik:comment"];
}
const { errors, layerIndex } = props;
const { errors, layerIndex } = this.props;
const errorData: MappedLayerErrors = {};
errors!.forEach(error => {
@@ -198,85 +207,84 @@ const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
});
let sourceLayerIds;
const layer = props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
if (Object.prototype.hasOwnProperty.call(props.sources, layer.source)) {
sourceLayerIds = props.sources[layer.source].layers;
const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
if (Object.prototype.hasOwnProperty.call(this.props.sources, layer.source)) {
sourceLayerIds = this.props.sources[layer.source].layers;
}
switch (type) {
case "layer": return <div>
<FieldId
value={props.layer.id}
value={this.props.layer.id}
wdKey="layer-editor.layer-id"
error={errorData.id}
onChange={newId => props.onLayerIdChange(props.layerIndex, props.layer.id, newId)}
onChange={newId => this.props.onLayerIdChange(this.props.layerIndex, this.props.layer.id, newId)}
/>
<FieldType
disabled={true}
error={errorData.type}
value={props.layer.type}
onChange={newType => props.onLayerChanged(
props.layerIndex,
changeType(props.layer, newType)
value={this.props.layer.type}
onChange={newType => this.props.onLayerChanged(
this.props.layerIndex,
changeType(this.props.layer, newType)
)}
/>
{props.layer.type !== "background" && <FieldSource
wdKey="layer-editor.layer-source"
{this.props.layer.type !== "background" && <FieldSource
error={errorData.source}
sourceIds={Object.keys(props.sources!)}
value={props.layer.source}
onChange={v => changeProperty(null, "source", v)}
sourceIds={Object.keys(this.props.sources!)}
value={this.props.layer.source}
onChange={v => this.changeProperty(null, "source", v)}
/>
}
{!NON_SOURCE_LAYERS.includes(props.layer.type) &&
{!NON_SOURCE_LAYERS.includes(this.props.layer.type) &&
<FieldSourceLayer
error={errorData["source-layer"]}
sourceLayerIds={sourceLayerIds}
value={(props.layer as any)["source-layer"]}
onChange={v => changeProperty(null, "source-layer", v)}
value={(this.props.layer as any)["source-layer"]}
onChange={v => this.changeProperty(null, "source-layer", v)}
/>
}
<FieldMinZoom
error={errorData.minzoom}
value={props.layer.minzoom}
onChange={v => changeProperty(null, "minzoom", v)}
value={this.props.layer.minzoom}
onChange={v => this.changeProperty(null, "minzoom", v)}
/>
<FieldMaxZoom
error={errorData.maxzoom}
value={props.layer.maxzoom}
onChange={v => changeProperty(null, "maxzoom", v)}
value={this.props.layer.maxzoom}
onChange={v => this.changeProperty(null, "maxzoom", v)}
/>
<FieldComment
error={errorData.comment}
value={comment}
onChange={v => changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)}
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={(props.layer as any).filter}
properties={props.vectorLayers[(props.layer as any)["source-layer"]]}
onChange={f => changeProperty(null, "filter", f)}
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}
layer={props.layer}
layer={this.props.layer}
groupFields={fields!}
spec={props.spec}
onChange={changeProperty.bind(null)}
spec={this.props.spec}
onChange={this.changeProperty.bind(this)}
/>;
case "jsoneditor":
return <FieldJson
lintType="layer"
value={props.layer}
value={this.props.layer}
onChange={(layer: LayerSpecification) => {
props.onLayerChanged(
props.layerIndex,
this.props.onLayerChanged(
this.props.layerIndex,
layer
);
}}
@@ -285,128 +293,131 @@ const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
}
}
function moveLayer(offset: number) {
props.onMoveLayer({
oldIndex: props.layerIndex,
newIndex: props.layerIndex + offset
moveLayer(offset: number) {
this.props.onMoveLayer({
oldIndex: this.props.layerIndex,
newIndex: this.props.layerIndex + offset
});
}
const t = props.t;
render() {
const t = this.props.t;
const groupIds: string[] = [];
const layerType = props.layer.type;
const groups = layoutGroups(layerType, t).filter(group => {
return !(layerType === "background" && group.type === "source");
}).map(group => {
const groupId = group.id;
groupIds.push(groupId);
return <LayerEditorGroup
data-wd-key={group.title}
id={groupId}
key={groupId}
title={group.title}
isActive={editorGroups[group.title]}
onActiveToggle={onGroupToggle.bind(null, group.title)}
>
{renderGroupType(group.type, group.fields)}
</LayerEditorGroup>;
});
const layout = props.layer.layout || {};
const items: {
[key: string]: {
text: string,
handler: () => void,
disabled?: boolean,
wdKey?: string
}
} = {
delete: {
text: t("Delete"),
handler: () => props.onLayerDestroy(props.layerIndex),
wdKey: "menu-delete-layer"
},
duplicate: {
text: t("Duplicate"),
handler: () => props.onLayerCopy(props.layerIndex),
wdKey: "menu-duplicate-layer"
},
hide: {
text: (layout.visibility === "none") ? t("Show") : t("Hide"),
handler: () => props.onLayerVisibilityToggle(props.layerIndex),
wdKey: "menu-hide-layer"
},
moveLayerUp: {
text: t("Move layer up"),
disabled: props.isFirstLayer,
handler: () => moveLayer(-1),
wdKey: "menu-move-layer-up"
},
moveLayerDown: {
text: t("Move layer down"),
disabled: props.isLastLayer,
handler: () => moveLayer(+1),
wdKey: "menu-move-layer-down"
}
};
function handleSelection(id: string, event: React.SyntheticEvent) {
event.stopPropagation();
items[id].handler();
}
return <IconContext.Provider value={{ size: "14px", color: "#8e8e8e" }}>
<section className="maputnik-layer-editor"
role="main"
aria-label={t("Layer editor")}
data-wd-key="layer-editor"
>
<header data-wd-key="layer-editor.header">
<div className="layer-header">
<h2 className="layer-header__title">
{t("Layer")}: {formatLayerId(props.layer.id)}
</h2>
<div className="layer-header__info">
<Wrapper
className='more-menu'
onSelection={(id, event) => handleSelection(id as string, event)}
closeOnSelection={false}
>
<Button
id="skip-target-layer-editor"
data-wd-key="skip-target-layer-editor"
className='more-menu__button'
title={"Layer options"}>
<MdMoreVert className="more-menu__button__svg" />
</Button>
<Menu>
<ul className="more-menu__menu">
{Object.keys(items).map((id) => {
const item = items[id];
return <li key={id}>
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}>
{item.text}
</MenuItem>
</li>;
})}
</ul>
</Menu>
</Wrapper>
</div>
</div>
</header>
<Accordion
allowMultipleExpanded={true}
allowZeroExpanded={true}
preExpanded={groupIds}
const groupIds: string[] = [];
const layerType = this.props.layer.type;
const groups = layoutGroups(layerType, t).filter(group => {
return !(layerType === "background" && group.type === "source");
}).map(group => {
const groupId = group.id;
groupIds.push(groupId);
return <LayerEditorGroup
data-wd-key={group.title}
id={groupId}
key={groupId}
title={group.title}
isActive={this.state.editorGroups[group.title]}
onActiveToggle={this.onGroupToggle.bind(this, group.title)}
>
{groups}
</Accordion>
</section>
</IconContext.Provider>;
};
{this.renderGroupType(group.type, group.fields)}
</LayerEditorGroup>;
});
export const LayerEditor = withTranslation()(LayerEditorInternal);
const layout = this.props.layer.layout || {};
const items: {
[key: string]: {
text: string,
handler: () => void,
disabled?: boolean,
wdKey?: string
}
} = {
delete: {
text: t("Delete"),
handler: () => this.props.onLayerDestroy(this.props.layerIndex),
wdKey: "menu-delete-layer"
},
duplicate: {
text: t("Duplicate"),
handler: () => this.props.onLayerCopy(this.props.layerIndex),
wdKey: "menu-duplicate-layer"
},
hide: {
text: (layout.visibility === "none") ? t("Show") : t("Hide"),
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex),
wdKey: "menu-hide-layer"
},
moveLayerUp: {
text: t("Move layer up"),
disabled: this.props.isFirstLayer,
handler: () => this.moveLayer(-1),
wdKey: "menu-move-layer-up"
},
moveLayerDown: {
text: t("Move layer down"),
disabled: this.props.isLastLayer,
handler: () => this.moveLayer(+1),
wdKey: "menu-move-layer-down"
}
};
function handleSelection(id: string, event: React.SyntheticEvent) {
event.stopPropagation();
items[id].handler();
}
return <IconContext.Provider value={{ size: "14px", color: "#8e8e8e" }}>
<section className="maputnik-layer-editor"
role="main"
aria-label={t("Layer editor")}
data-wd-key="layer-editor"
>
<header data-wd-key="layer-editor.header">
<div className="layer-header">
<h2 className="layer-header__title">
{t("Layer")}: {formatLayerId(this.props.layer.id)}
</h2>
<div className="layer-header__info">
<Wrapper
className='more-menu'
onSelection={(id, event) => handleSelection(id as string, event)}
closeOnSelection={false}
>
<Button
id="skip-target-layer-editor"
data-wd-key="skip-target-layer-editor"
className='more-menu__button'
title={"Layer options"}>
<MdMoreVert className="more-menu__button__svg" />
</Button>
<Menu>
<ul className="more-menu__menu">
{Object.keys(items).map((id) => {
const item = items[id];
return <li key={id}>
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}>
{item.text}
</MenuItem>
</li>;
})}
</ul>
</Menu>
</Wrapper>
</div>
</div>
</header>
<Accordion
allowMultipleExpanded={true}
allowZeroExpanded={true}
preExpanded={groupIds}
>
{groups}
</Accordion>
</section>
</IconContext.Provider>;
}
}
const LayerEditor = withTranslation()(LayerEditorInternal);
export default LayerEditor;
+19 -17
View File
@@ -18,20 +18,22 @@ type LayerEditorGroupProps = {
};
export const LayerEditorGroup: React.FC<LayerEditorGroupProps> = (props) => {
return <AccordionItem uuid={props.id}>
<AccordionItemHeading className="maputnik-layer-editor-group"
data-wd-key={"layer-editor-group:"+props["data-wd-key"]}
onClick={_e => props.onActiveToggle(!props.isActive)}
>
<AccordionItemButton className="maputnik-layer-editor-group__button">
<span style={{flexGrow: 1, alignContent: "center"}}>{props.title}</span>
<MdArrowDropUp size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--up"></MdArrowDropUp>
<MdArrowDropDown size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"></MdArrowDropDown>
</AccordionItemButton>
</AccordionItemHeading>
<AccordionItemPanel>
{props.children}
</AccordionItemPanel>
</AccordionItem>;
};
export default class LayerEditorGroup extends React.Component<LayerEditorGroupProps> {
render() {
return <AccordionItem uuid={this.props.id}>
<AccordionItemHeading className="maputnik-layer-editor-group"
data-wd-key={"layer-editor-group:"+this.props["data-wd-key"]}
onClick={_e => this.props.onActiveToggle(!this.props.isActive)}
>
<AccordionItemButton className="maputnik-layer-editor-group__button">
<span style={{flexGrow: 1, alignContent: "center"}}>{this.props.title}</span>
<MdArrowDropUp size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--up"></MdArrowDropUp>
<MdArrowDropDown size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"></MdArrowDropDown>
</AccordionItemButton>
</AccordionItemHeading>
<AccordionItemPanel>
{this.props.children}
</AccordionItemPanel>
</AccordionItem>;
}
}
+236 -223
View File
@@ -1,4 +1,4 @@
import React, {type JSX, useEffect, useRef, useState} from "react";
import React, {type JSX} from "react";
import classnames from "classnames";
import lodash from "lodash";
import {
@@ -14,12 +14,12 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { LayerListGroup } from "./LayerListGroup";
import { LayerListItem } from "./LayerListItem";
import { ModalAdd } from "./modals/ModalAdd";
import LayerListGroup from "./LayerListGroup";
import LayerListItem from "./LayerListItem";
import ModalAdd from "./modals/ModalAdd";
import type {LayerSpecification, SourceSpecification} from "maplibre-gl";
import { generateUniqueId } from "../libs/document-uid";
import generateUniqueId from "../libs/document-uid";
import { findClosestCommonPrefix, layerPrefix } from "../libs/layer";
import { type WithTranslation, withTranslation } from "react-i18next";
import { type MappedError, type OnMoveLayerCallback } from "../libs/definitions";
@@ -37,98 +37,62 @@ type LayerListContainerProps = {
};
type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation;
const noopLayerSelect = () => {};
// Replaces the previous `shouldComponentUpdate`. Note the inversion: this
// returns true when the props are EQUAL (i.e. no re-render is needed), whereas
// `shouldComponentUpdate` returned true when a re-render WAS needed.
function arePropsEqual(prevProps: LayerListContainerInternalProps, nextProps: LayerListContainerInternalProps) {
// This component tree only requires id and visibility from the layers
// objects
function getRequiredProps(layer: LayerSpecification) {
const out: {id: string, layout?: { visibility: any}} = {
id: layer.id,
};
if (layer.layout) {
out.layout = {
visibility: layer.layout.visibility
};
}
return out;
}
const layersEqual = lodash.isEqual(
nextProps.layers.map(getRequiredProps),
prevProps.layers.map(getRequiredProps),
);
function withoutLayers(props: LayerListContainerInternalProps) {
const out = {
...props
} as LayerListContainerInternalProps & { layers?: any };
delete out["layers"];
return out;
}
// Compare the props without layers because we've already compared them
// efficiently above.
const propsEqual = lodash.isEqual(
withoutLayers(prevProps),
withoutLayers(nextProps)
);
return layersEqual && propsEqual;
}
type LayerListContainerState = {
collapsedGroups: {[ket: string]: boolean}
areAllGroupsExpanded: boolean
keys: {[key: string]: number}
isOpen: {[key: string]: boolean}
};
// List of collapsible layer editors
function LayerListContainerInternal({
layers: propsLayers,
selectedLayerIndex,
onLayersChange,
onLayerSelect = noopLayerSelect,
onLayerDestroy,
onLayerCopy,
onLayerVisibilityToggle,
sources,
errors,
t,
}: LayerListContainerInternalProps) {
const selectedItemRef = useRef<any>(null);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const hasMountedRef = useRef(false);
class LayerListContainerInternal extends React.Component<LayerListContainerInternalProps, LayerListContainerState> {
static defaultProps = {
onLayerSelect: () => {},
};
selectedItemRef: React.RefObject<any>;
scrollContainerRef: React.RefObject<HTMLElement | null>;
const [collapsedGroups, setCollapsedGroups] = useState<{[key: string]: boolean}>({});
const [areAllGroupsExpanded, setAreAllGroupsExpanded] = useState(false);
const [keys, setKeys] = useState<{[key: string]: number}>(() => ({
add: +generateUniqueId(),
}));
const [isOpen, setIsOpen] = useState<{[key: string]: boolean}>({
add: false,
});
function toggleModal(modalName: string) {
setKeys(prevKeys => ({
...prevKeys,
[modalName]: +generateUniqueId(),
}));
setIsOpen(prevIsOpen => ({
...prevIsOpen,
[modalName]: !prevIsOpen[modalName]
}));
constructor(props: LayerListContainerInternalProps) {
super(props);
this.selectedItemRef = React.createRef();
this.scrollContainerRef = React.createRef();
this.state = {
collapsedGroups: {},
areAllGroupsExpanded: false,
keys: {
add: +generateUniqueId(),
},
isOpen: {
add: false,
}
};
}
const toggleLayers = () => {
toggleModal(modalName: string) {
this.setState({
keys: {
...this.state.keys,
[modalName]: +generateUniqueId(),
},
isOpen: {
...this.state.isOpen,
[modalName]: !this.state.isOpen[modalName]
}
});
}
toggleLayers = () => {
let idx = 0;
const newGroups: {[key:string]: boolean} = {};
groupedLayers().forEach(layers => {
this.groupedLayers().forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id);
const lookupKey = [groupPrefix, idx].join("-");
if (layers.length > 1) {
newGroups[lookupKey] = areAllGroupsExpanded;
newGroups[lookupKey] = this.state.areAllGroupsExpanded;
}
layers.forEach((_layer) => {
@@ -136,17 +100,19 @@ function LayerListContainerInternal({
});
});
setCollapsedGroups(newGroups);
setAreAllGroupsExpanded(!areAllGroupsExpanded);
this.setState({
collapsedGroups: newGroups,
areAllGroupsExpanded: !this.state.areAllGroupsExpanded
});
};
function groupedLayers(): (LayerSpecification & {key: string})[][] {
groupedLayers(): (LayerSpecification & {key: string})[][] {
const groups = [];
const layerIdCount = new Map();
for (let i = 0; i < propsLayers.length; i++) {
const origLayer = propsLayers[i];
const previousLayer = propsLayers[i-1];
for (let i = 0; i < this.props.layers.length; i++) {
const origLayer = this.props.layers[i];
const previousLayer = this.props.layers[i-1];
layerIdCount.set(origLayer.id,
layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0
);
@@ -164,168 +130,213 @@ function LayerListContainerInternal({
return groups;
}
function toggleLayerGroup(groupPrefix: string, idx: number) {
toggleLayerGroup(groupPrefix: string, idx: number) {
const lookupKey = [groupPrefix, idx].join("-");
setCollapsedGroups(prevCollapsedGroups => {
const newGroups = { ...prevCollapsedGroups };
if(lookupKey in prevCollapsedGroups) {
newGroups[lookupKey] = !prevCollapsedGroups[lookupKey];
} else {
newGroups[lookupKey] = false;
}
return newGroups;
const newGroups = { ...this.state.collapsedGroups };
if(lookupKey in this.state.collapsedGroups) {
newGroups[lookupKey] = !this.state.collapsedGroups[lookupKey];
} else {
newGroups[lookupKey] = false;
}
this.setState({
collapsedGroups: newGroups
});
}
function isCollapsed(groupPrefix: string, idx: number) {
const collapsed = collapsedGroups[[groupPrefix, idx].join("-")];
isCollapsed(groupPrefix: string, idx: number) {
const collapsed = this.state.collapsedGroups[[groupPrefix, idx].join("-")];
return collapsed === undefined ? true : collapsed;
}
useEffect(() => {
// `componentDidUpdate` did not run on mount, so skip the first run here too.
if (!hasMountedRef.current) {
hasMountedRef.current = true;
return;
shouldComponentUpdate (nextProps: LayerListContainerProps, nextState: LayerListContainerState) {
// Always update on state change
if (this.state !== nextState) {
return true;
}
const selectedItemNode = selectedItemRef.current;
if (selectedItemNode && selectedItemNode.node) {
const target = selectedItemNode.node;
const options = {
root: scrollContainerRef.current,
threshold: 1.0
// This component tree only requires id and visibility from the layers
// objects
function getRequiredProps(layer: LayerSpecification) {
const out: {id: string, layout?: { visibility: any}} = {
id: layer.id,
};
const observer = new IntersectionObserver(entries => {
observer.unobserve(target);
if (entries.length > 0 && entries[0].intersectionRatio < 1) {
target.scrollIntoView();
}
}, options);
observer.observe(target);
if (layer.layout) {
out.layout = {
visibility: layer.layout.visibility
};
}
return out;
}
}, [selectedLayerIndex]);
const layersEqual = lodash.isEqual(
nextProps.layers.map(getRequiredProps),
this.props.layers.map(getRequiredProps),
);
const listItems: JSX.Element[] = [];
let idx = 0;
const layersByGroup = groupedLayers();
layersByGroup.forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id);
if(layers.length > 1) {
const currentIdx = idx;
const grp = <LayerListGroup
data-wd-key={[groupPrefix, idx].join("-")}
aria-controls={layers.map(l => l.key).join(" ")}
key={`group-${groupPrefix}-${idx}`}
title={groupPrefix}
isActive={!isCollapsed(groupPrefix, idx) || idx === selectedLayerIndex}
onActiveToggle={() => toggleLayerGroup(groupPrefix, currentIdx)}
/>;
listItems.push(grp);
function withoutLayers(props: LayerListContainerProps) {
const out = {
...props
} as LayerListContainerProps & { layers?: any };
delete out["layers"];
return out;
}
layers.forEach((layer, idxInGroup) => {
const groupIdx = findClosestCommonPrefix(propsLayers, idx);
// Compare the props without layers because we've already compared them
// efficiently above.
const propsEqual = lodash.isEqual(
withoutLayers(this.props),
withoutLayers(nextProps)
);
const layerError = errors.find(error => {
return (
error.parsed &&
error.parsed.type === "layer" &&
error.parsed.data.index == idx
);
});
const propsChanged = !(layersEqual && propsEqual);
return propsChanged;
}
const additionalProps: {ref?: React.RefObject<any>} = {};
if (idx === selectedLayerIndex) {
additionalProps.ref = selectedItemRef;
componentDidUpdate (prevProps: LayerListContainerProps) {
if (prevProps.selectedLayerIndex !== this.props.selectedLayerIndex) {
const selectedItemNode = this.selectedItemRef.current;
if (selectedItemNode && selectedItemNode.node) {
const target = selectedItemNode.node;
const options = {
root: this.scrollContainerRef.current,
threshold: 1.0
};
const observer = new IntersectionObserver(entries => {
observer.unobserve(target);
if (entries.length > 0 && entries[0].intersectionRatio < 1) {
target.scrollIntoView();
}
}, options);
observer.observe(target);
}
}
}
render() {
const listItems: JSX.Element[] = [];
let idx = 0;
const layersByGroup = this.groupedLayers();
layersByGroup.forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id);
if(layers.length > 1) {
const grp = <LayerListGroup
data-wd-key={[groupPrefix, idx].join("-")}
aria-controls={layers.map(l => l.key).join(" ")}
key={`group-${groupPrefix}-${idx}`}
title={groupPrefix}
isActive={!this.isCollapsed(groupPrefix, idx) || idx === this.props.selectedLayerIndex}
onActiveToggle={this.toggleLayerGroup.bind(this, groupPrefix, idx)}
/>;
listItems.push(grp);
}
const listItem = <LayerListItem
className={classnames({
"maputnik-layer-list-item-collapsed": layers.length > 1 && isCollapsed(groupPrefix, groupIdx) && idx !== selectedLayerIndex,
"maputnik-layer-list-item-group-last": idxInGroup == layers.length - 1 && layers.length > 1,
"maputnik-layer-list-item--error": !!layerError
})}
key={layer.key}
id={layer.key}
layerId={layer.id}
layerIndex={idx}
layerType={layer.type}
visibility={(layer.layout || {}).visibility}
isSelected={idx === selectedLayerIndex}
onLayerSelect={onLayerSelect}
onLayerDestroy={onLayerDestroy}
onLayerCopy={onLayerCopy}
onLayerVisibilityToggle={onLayerVisibilityToggle}
{...additionalProps}
/>;
listItems.push(listItem);
idx += 1;
});
});
layers.forEach((layer, idxInGroup) => {
const groupIdx = findClosestCommonPrefix(this.props.layers, idx);
return <section
className="maputnik-layer-list"
data-wd-key="layer-list"
role="complementary"
aria-label={t("Layers list")}
ref={scrollContainerRef}
>
<ModalAdd
key={keys.add}
layers={propsLayers}
sources={sources}
isOpen={isOpen.add}
onOpenToggle={() => toggleModal("add")}
onLayersChange={onLayersChange}
/>
<header className="maputnik-layer-list-header" data-wd-key="layer-list.header">
<span className="maputnik-layer-list-header-title">{t("Layers")}</span>
<span className="maputnik-space" />
<div className="maputnik-default-property">
<div className="maputnik-multibutton">
<button
id="skip-target-layer-list"
data-wd-key="skip-target-layer-list"
onClick={toggleLayers}
className="maputnik-button">
{areAllGroupsExpanded === true ?
t("Collapse")
:
t("Expand")
}
</button>
</div>
</div>
<div className="maputnik-default-property">
<div className="maputnik-multibutton">
<button
onClick={() => toggleModal("add")}
data-wd-key="layer-list:add-layer"
className="maputnik-button maputnik-button-selected">
{t("Add Layer")}
</button>
</div>
</div>
</header>
<div
role="navigation"
const layerError = this.props.errors.find(error => {
return (
error.parsed &&
error.parsed.type === "layer" &&
error.parsed.data.index == idx
);
});
const additionalProps: {ref?: React.RefObject<any>} = {};
if (idx === this.props.selectedLayerIndex) {
additionalProps.ref = this.selectedItemRef;
}
const listItem = <LayerListItem
className={classnames({
"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--error": !!layerError
})}
key={layer.key}
id={layer.key}
layerId={layer.id}
layerIndex={idx}
layerType={layer.type}
visibility={(layer.layout || {}).visibility}
isSelected={idx === this.props.selectedLayerIndex}
onLayerSelect={this.props.onLayerSelect}
onLayerDestroy={this.props.onLayerDestroy?.bind(this)}
onLayerCopy={this.props.onLayerCopy.bind(this)}
onLayerVisibilityToggle={this.props.onLayerVisibilityToggle.bind(this)}
{...additionalProps}
/>;
listItems.push(listItem);
idx += 1;
});
});
const t = this.props.t;
return <section
className="maputnik-layer-list"
data-wd-key="layer-list"
role="complementary"
aria-label={t("Layers list")}
ref={this.scrollContainerRef}
>
<ul className="maputnik-layer-list-container">
{listItems}
</ul>
</div>
</section>;
<ModalAdd
key={this.state.keys.add}
layers={this.props.layers}
sources={this.props.sources}
isOpen={this.state.isOpen.add}
onOpenToggle={this.toggleModal.bind(this, "add")}
onLayersChange={this.props.onLayersChange}
/>
<header className="maputnik-layer-list-header" data-wd-key="layer-list.header">
<span className="maputnik-layer-list-header-title">{t("Layers")}</span>
<span className="maputnik-space" />
<div className="maputnik-default-property">
<div className="maputnik-multibutton">
<button
id="skip-target-layer-list"
data-wd-key="skip-target-layer-list"
onClick={this.toggleLayers}
className="maputnik-button">
{this.state.areAllGroupsExpanded === true ?
t("Collapse")
:
t("Expand")
}
</button>
</div>
</div>
<div className="maputnik-default-property">
<div className="maputnik-multibutton">
<button
onClick={this.toggleModal.bind(this, "add")}
data-wd-key="layer-list:add-layer"
className="maputnik-button maputnik-button-selected">
{t("Add Layer")}
</button>
</div>
</div>
</header>
<div
role="navigation"
aria-label={t("Layers list")}
>
<ul className="maputnik-layer-list-container">
{listItems}
</ul>
</div>
</section>;
}
}
const LayerListContainer = withTranslation()(React.memo(LayerListContainerInternal, arePropsEqual));
const LayerListContainer = withTranslation()(LayerListContainerInternal);
type LayerListProps = LayerListContainerProps & {
onMoveLayer: OnMoveLayerCallback
};
export const LayerList: React.FC<LayerListProps> = (props) => {
const LayerList: React.FC<LayerListProps> = (props) => {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const handleDragEnd = (event: DragEndEvent) => {
@@ -350,3 +361,5 @@ export const LayerList: React.FC<LayerListProps> = (props) => {
</DndContext>
);
};
export default LayerList;

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