Compare commits

..

10 Commits

Author SHA1 Message Date
HarelM f88e1b04fa Initial commit. 2026-07-14 13:50:17 +03:00
Harel M f14eeae38b Increase coverage (#1997)
## Launch Checklist

This PR increases coverage by adding unit tests to lib folde, replace
the skipped end to end placeholder with actual tests and adds more end
to end tests.

This was mostly done by AI (Claude opus 4.8) and I reviewed it and
requested changes where needed.

 - [x] Briefly describe the changes in this PR.
 - [x] Link to related issues.
- [x] Include before/after visuals or gifs if this PR includes visual
changes.
 - [x] Write tests for all new functionality.
 - [x] Add an entry to `CHANGELOG.md` under the `## main` section.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-12 15:46:30 +03:00
Harel M 9c1499b805 Replace default export with named exports (#1998)
## Launch Checklist

See title,
Also removed some "_" from some file names.
This is a pure refactoring, no logic changes.


 - [x] Briefly describe the changes in this PR.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-12 14:07:28 +03:00
dependabot[bot] 21e141542a chore(deps): Bump qs from 6.14.2 to 6.15.3 (#1996)
Bumps [qs](https://github.com/ljharb/qs) from 6.14.2 to 6.15.3.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ljharb/qs/blob/main/CHANGELOG.md">qs's
changelog</a>.</em></p>
<blockquote>
<h2><strong>6.15.3</strong></h2>
<ul>
<li>[Fix] <code>parse</code>: enforce <code>throwOnLimitExceeded</code>
for cumulative array growth via
<code>combine</code>/<code>merge</code></li>
<li>[Fix] <code>utils</code>: respect encoding of surrogate pairs across
chunks (<a
href="https://redirect.github.com/ljharb/qs/issues/559">#559</a>)</li>
<li>[Robustness] <code>parse</code>: throw the <code>arrayLimit</code>
error before splitting oversized comma values</li>
<li>[Robustness] <code>utils.merge</code> / <code>utils.assign</code>:
avoid invoking <code>__proto__</code> setter when copying own
properties</li>
<li>[Robustness] <code>utils</code>: enforce <code>arrayLimit</code>
consistently across <code>merge</code>'s array paths</li>
<li>[Perf] <code>utils</code>: make <code>compact</code> O(n) via a
side-channel visited-set instead of <code>Array.indexOf</code></li>
<li>[Deps] update <code>side-channel</code></li>
<li>[Dev Deps] update <code>eslint</code>, <code>mock-property</code>,
<code>tape</code></li>
<li>[Tests] <code>parse</code>: characterize current lenient handling of
unbalanced bracket keys (<a
href="https://redirect.github.com/ljharb/qs/issues/558">#558</a>)</li>
</ul>
<h2><strong>6.15.2</strong></h2>
<ul>
<li>[Fix] <code>stringify</code>: skip null/undefined entries in
<code>arrayFormat: 'comma'</code> + <code>encodeValuesOnly</code>
instead of crashing in <code>encoder</code></li>
<li>[Fix] <code>stringify</code>: use configured <code>delimiter</code>
after <code>charsetSentinel</code> (<a
href="https://redirect.github.com/ljharb/qs/issues/555">#555</a>)</li>
<li>[Fix] <code>stringify</code>: apply <code>formatter</code> to
encoded key under <code>strictNullHandling</code> (<a
href="https://redirect.github.com/ljharb/qs/issues/554">#554</a>)</li>
<li>[Fix] <code>stringify</code>: skip null/undefined filter-array
entries instead of crashing in <code>encoder</code> (<a
href="https://redirect.github.com/ljharb/qs/issues/551">#551</a>)</li>
<li>[Fix] <code>parse</code>: handle nested bracket groups and add
regression tests (<a
href="https://redirect.github.com/ljharb/qs/issues/530">#530</a>)</li>
<li>[readme] fix grammar (<a
href="https://redirect.github.com/ljharb/qs/issues/550">#550</a>)</li>
<li>[Dev Deps] update <code>@ljharb/eslint-config</code></li>
<li>[Tests] add regression tests for keys containing percent-encoded
bracket text</li>
</ul>
<h2><strong>6.15.1</strong></h2>
<ul>
<li>[Fix] <code>parse</code>: <code>parameterLimit: Infinity</code> with
<code>throwOnLimitExceeded: true</code> silently drops all
parameters</li>
<li>[Deps] update <code>@ljharb/eslint-config</code></li>
<li>[Dev Deps] update <code>@ljharb/eslint-config</code>,
<code>iconv-lite</code></li>
<li>[Tests] increase coverage</li>
</ul>
<h2><strong>6.15.0</strong></h2>
<ul>
<li>[New] <code>parse</code>: add <code>strictMerge</code> option to
wrap object/primitive conflicts in an array (<a
href="https://redirect.github.com/ljharb/qs/issues/425">#425</a>, <a
href="https://redirect.github.com/ljharb/qs/issues/122">#122</a>)</li>
<li>[Fix] <code>duplicates</code> option should not apply to bracket
notation keys (<a
href="https://redirect.github.com/ljharb/qs/issues/514">#514</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ljharb/qs/commit/18d085e919dae70c8f1b200ab99323058edab2c2"><code>18d085e</code></a>
v6.15.3</li>
<li><a
href="https://github.com/ljharb/qs/commit/c38af42039d8cbf83f3e0d486c924eaa5a5d1712"><code>c38af42</code></a>
[Deps] update <code>side-channel</code></li>
<li><a
href="https://github.com/ljharb/qs/commit/adce539489fcd34cc10830cf8c4a43e90b9300b0"><code>adce539</code></a>
[Dev Deps] update <code>eslint</code>, <code>mock-property</code>,
<code>tape</code></li>
<li><a
href="https://github.com/ljharb/qs/commit/74a0f6a83af59245b4fa78785fbe709438580c97"><code>74a0f6a</code></a>
[Robustness] <code>utils</code>: enforce <code>arrayLimit</code>
consistently across <code>merge</code>'s arra...</li>
<li><a
href="https://github.com/ljharb/qs/commit/f4938f594a0a2773d3c99c0c6f5c96f30d909a2c"><code>f4938f5</code></a>
[Tests] <code>parse</code>: characterize current lenient handling of
unbalanced bracket ...</li>
<li><a
href="https://github.com/ljharb/qs/commit/5d5f723afe55a861c52cff6327f62269df35fdd1"><code>5d5f723</code></a>
[Perf] <code>utils</code>: make <code>compact</code> O(n) via a
side-channel visited-set instead of...</li>
<li><a
href="https://github.com/ljharb/qs/commit/52afe00abdb36416ee01c8d5243919b47a59af67"><code>52afe00</code></a>
[Robustness] <code>parse</code>: throw the <code>arrayLimit</code> error
before splitting oversized...</li>
<li><a
href="https://github.com/ljharb/qs/commit/963e538c740961a35ecaa0b38ee1f5c16697e208"><code>963e538</code></a>
[Fix] <code>parse</code>: enforce <code>throwOnLimitExceeded</code> for
cumulative array growth via...</li>
<li><a
href="https://github.com/ljharb/qs/commit/59da434d5de8c3d2564e4d75aeedde2e8af72369"><code>59da434</code></a>
[Fix] <code>utils</code>: respect encoding of surrogate pairs across
chunks</li>
<li><a
href="https://github.com/ljharb/qs/commit/9532969635a4bc96506370b28dd28dcab446e7ee"><code>9532969</code></a>
[Robustness] <code>utils.merge</code> / <code>utils.assign</code>: avoid
invoking <code>__proto__</code> sette...</li>
<li>Additional commits viewable in <a
href="https://github.com/ljharb/qs/compare/v6.14.2...v6.15.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=qs&package-manager=npm_and_yarn&previous-version=6.14.2&new-version=6.15.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/maplibre/maputnik/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 18:25:44 +00:00
dependabot[bot] 320f94b707 chore(deps): Bump uuid and istanbul-lib-processinfo (#1995)
Bumps [uuid](https://github.com/uuidjs/uuid) and
[istanbul-lib-processinfo](https://github.com/istanbuljs/istanbul-lib-processinfo).
These dependencies needed to be updated together.
Updates `uuid` from 8.3.2 to 14.0.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/uuidjs/uuid/releases">uuid's
releases</a>.</em></p>
<blockquote>
<h2>v14.0.1</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v14.0.0...v14.0.1">14.0.1</a>
(2026-06-20)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>add types condition to node export for moduleResolution bundler (<a
href="https://redirect.github.com/uuidjs/uuid/issues/961">#961</a>) (<a
href="https://github.com/uuidjs/uuid/commit/27ffae5e867823b8c7db255975d65358fbdb1a7e">27ffae5</a>)</li>
</ul>
<h2>v14.0.0</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0">14.0.0</a>
(2026-04-19)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>expect <code>crypto</code> to be global everywhere (requires
node@20+) (<a
href="https://redirect.github.com/uuidjs/uuid/issues/935">#935</a>)</li>
<li>drop node@18 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/934">#934</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>drop node@18 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/934">#934</a>) (<a
href="https://github.com/uuidjs/uuid/commit/dc4ddb87272ed2843faccd130bcc41d492688bd3">dc4ddb8</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>expect <code>crypto</code> to be global everywhere (requires
node@20+) (<a
href="https://redirect.github.com/uuidjs/uuid/issues/935">#935</a>) (<a
href="https://github.com/uuidjs/uuid/commit/f2c235f93059325fa43e1106e624b5291bb523c4">f2c235f</a>)</li>
<li>Use GITHUB_TOKEN for release-please and enable npm provenance (<a
href="https://redirect.github.com/uuidjs/uuid/issues/925">#925</a>) (<a
href="https://github.com/uuidjs/uuid/commit/ffa31383e8e4e1f0b4e22e504561272041b8738c">ffa3138</a>)</li>
</ul>
<h2>v13.0.2</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v13.0.1...v13.0.2">13.0.2</a>
(2026-05-04)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>rerelease to fix provenance. (<a
href="https://github.com/uuidjs/uuid/commit/49ccb35f78c0c4ce1409dd2f1d89f83caadba10b">49ccb35</a>)</li>
</ul>
<h2>v13.0.1</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v13.0.0...v13.0.1">13.0.1</a>
(2026-04-27)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>backport fix for GHSA-w5hq-g745-h8pq (<a
href="https://github.com/uuidjs/uuid/commit/9d27ddf7046ce496ef39569ff84d948eeff9cb2a">9d27ddf</a>)</li>
</ul>
<h2>v13.0.0</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v12.0.0...v13.0.0">13.0.0</a>
(2025-09-08)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>make browser exports the default (<a
href="https://redirect.github.com/uuidjs/uuid/issues/901">#901</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md">uuid's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v14.0.0...v14.0.1">14.0.1</a>
(2026-06-20)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>add types condition to node export for moduleResolution bundler (<a
href="https://redirect.github.com/uuidjs/uuid/issues/961">#961</a>) (<a
href="https://github.com/uuidjs/uuid/commit/27ffae5e867823b8c7db255975d65358fbdb1a7e">27ffae5</a>)</li>
</ul>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0">14.0.0</a>
(2026-04-19)</h2>
<h3>Security</h3>
<ul>
<li>Fixes <a
href="https://github.com/uuidjs/uuid/security/advisories/GHSA-w5hq-g745-h8pq">GHSA-w5hq-g745-h8pq</a>:
<code>v3()</code>, <code>v5()</code>, and <code>v6()</code> did not
validate that writes would remain within the bounds of a caller-supplied
buffer, allowing out-of-bounds writes when an invalid
<code>offset</code> was provided. A <code>RangeError</code> is now
thrown if <code>offset &lt; 0</code> or <code>offset + 16 &gt;
buf.length</code>.</li>
</ul>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li><code>crypto</code> is now expected to be globally defined (requires
node@20+) (<a
href="https://redirect.github.com/uuidjs/uuid/issues/935">#935</a>)</li>
<li>drop node@18 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/934">#934</a>)</li>
<li>upgrade minimum supported TypeScript version to 5.4.3, in keeping
with the project's policy of supporting TypeScript versions released
within the last two years</li>
</ul>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v12.0.0...v13.0.0">13.0.0</a>
(2025-09-08)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>make browser exports the default (<a
href="https://redirect.github.com/uuidjs/uuid/issues/901">#901</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>make browser exports the default (<a
href="https://redirect.github.com/uuidjs/uuid/issues/901">#901</a>) (<a
href="https://github.com/uuidjs/uuid/commit/bce9d72a3ae5b9a3dcd8eb21ef6d1820288a427a">bce9d72</a>)</li>
</ul>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v11.1.0...v12.0.0">12.0.0</a>
(2025-09-05)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>update to typescript@5.2 (<a
href="https://redirect.github.com/uuidjs/uuid/issues/887">#887</a>)</li>
<li>remove CommonJS support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/886">#886</a>)</li>
<li>drop node@16 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/883">#883</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>add node@24 to ci matrix (<a
href="https://redirect.github.com/uuidjs/uuid/issues/879">#879</a>) (<a
href="https://github.com/uuidjs/uuid/commit/42b6178aa21a593257f0a72abacd220f0b7b8a92">42b6178</a>)</li>
<li>drop node@16 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/883">#883</a>) (<a
href="https://github.com/uuidjs/uuid/commit/0f38cf10366ab074f9328ae2021eea04d5f2e530">0f38cf1</a>)</li>
<li>remove CommonJS support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/886">#886</a>) (<a
href="https://github.com/uuidjs/uuid/commit/ae786e27265f50bcf7cead196c29f1869297c42f">ae786e2</a>)</li>
<li>update to typescript@5.2 (<a
href="https://redirect.github.com/uuidjs/uuid/issues/887">#887</a>) (<a
href="https://github.com/uuidjs/uuid/commit/c7ee40598ed78584d81ab78dffded9fe5ff20b01">c7ee405</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/uuidjs/uuid/commit/70177807e9229dfacde2038dc1e722f1828f358a"><code>7017780</code></a>
chore(main): release 14.0.1 (<a
href="https://redirect.github.com/uuidjs/uuid/issues/964">#964</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/f2c3e4b513e47968f568ad1bf96417c6924e83a7"><code>f2c3e4b</code></a>
chore: fix release-please workflow (<a
href="https://redirect.github.com/uuidjs/uuid/issues/963">#963</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/27ffae5e867823b8c7db255975d65358fbdb1a7e"><code>27ffae5</code></a>
fix: add types condition to node export for moduleResolution bundler (<a
href="https://redirect.github.com/uuidjs/uuid/issues/961">#961</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/664cb3163531f49d3a5125f732d38724621bfcaa"><code>664cb31</code></a>
Remove outdated security contact information (<a
href="https://redirect.github.com/uuidjs/uuid/issues/959">#959</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/d729016037c4e1b09a522f4854a96f4f4add9047"><code>d729016</code></a>
fix(ci): checkout PR head commit in browser workflow (<a
href="https://redirect.github.com/uuidjs/uuid/issues/957">#957</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/89a5ebcc56999fc25c95350f922693b71fb11d32"><code>89a5ebc</code></a>
Workflows (<a
href="https://redirect.github.com/uuidjs/uuid/issues/948">#948</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/196e208db33d568d8d43d5b549d5d0eb3d9b0fbf"><code>196e208</code></a>
chore: fix workflow (<a
href="https://redirect.github.com/uuidjs/uuid/issues/947">#947</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/95af4489be459d6ba14d137b6fcd87ffd0953e91"><code>95af448</code></a>
chore: update workflows (<a
href="https://redirect.github.com/uuidjs/uuid/issues/946">#946</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/3b57f95555ab1b8432213264b5eaa318958fb8fe"><code>3b57f95</code></a>
chore: add workflow_dispatch (<a
href="https://redirect.github.com/uuidjs/uuid/issues/944">#944</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/a433096a74e1d1815432fb1bed8115369785a1a9"><code>a433096</code></a>
chore: add 12.x and 13.x maintenance release branches (<a
href="https://redirect.github.com/uuidjs/uuid/issues/941">#941</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/uuidjs/uuid/compare/v8.3.2...v14.0.1">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for uuid since your current version.</p>
</details>
<details>
<summary>Install script changes</summary>
<p>This version adds <code>prepare</code> script that runs during
installation. Review the package contents before updating.</p>
</details>
<br />

Updates `istanbul-lib-processinfo` from 3.0.0 to 3.0.1
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/istanbuljs/istanbul-lib-processinfo/blob/master/CHANGELOG.md">istanbul-lib-processinfo's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<p>All notable changes to this project will be documented in this file.
See <a
href="https://github.com/conventional-changelog/standard-version">standard-version</a>
for commit guidelines.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/istanbuljs/istanbul-lib-processinfo/commit/bd2fd64c87f956f6e5d8ede9cc3f47f37f047145"><code>bd2fd64</code></a>
3.0.1</li>
<li><a
href="https://github.com/istanbuljs/istanbul-lib-processinfo/commit/1100563778e6d1c9c3ce0181c912df750d40475f"><code>1100563</code></a>
chore: remove <code>uuid</code> dependency</li>
<li><a
href="https://github.com/istanbuljs/istanbul-lib-processinfo/commit/79c691abb60dbb7c134c4bf220546250ebb40755"><code>79c691a</code></a>
chore(deps): update uuid</li>
<li>See full diff in <a
href="https://github.com/istanbuljs/istanbul-lib-processinfo/compare/istanbul-lib-processinfo-v3.0.0...v3.0.1">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/maplibre/maputnik/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 18:25:26 +00:00
Harel M 2e0b7cdb8c Replace cypress with playwright (#1988)
## Launch Checklist

This PR replaces cypress with playwright.
<img width="1907" height="933" alt="image"
src="https://github.com/user-attachments/assets/b52075b3-eb3b-45dc-93dc-8c5e9cfd35dd"
/>

I hope this will make the end-to-end debugging and fixing easier due to
the async await support of playwright which is missing in cypress.

This is a pure refactoring change.

 - [x] Briefly describe the changes in this PR.
 - [x] Add an entry to `CHANGELOG.md` under the `## main` section.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-10 18:18:48 +00:00
dependabot[bot] c24049cb88 chore(deps): Bump @codemirror/view from 6.43.5 to 6.43.6 (#1993)
Bumps [@codemirror/view](https://github.com/codemirror/view) from 6.43.5
to 6.43.6.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/codemirror/view/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@codemirror/view&package-manager=npm_and_yarn&previous-version=6.43.5&new-version=6.43.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 13:52:29 +00:00
dependabot[bot] 1047286d9e chore(deps): Bump @codemirror/state from 6.7.0 to 6.7.1 (#1992)
Bumps [@codemirror/state](https://github.com/codemirror/state) from
6.7.0 to 6.7.1.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/codemirror/state/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@codemirror/state&package-manager=npm_and_yarn&previous-version=6.7.0&new-version=6.7.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 13:51:55 +00:00
dependabot[bot] 99b5dd785d chore(deps-dev): Bump the vitest group with 2 updates (#1991)
Bumps the vitest group with 2 updates:
[@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)
and
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).

Updates `@vitest/coverage-v8` from 4.1.9 to 4.1.10
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">@​vitest/coverage-v8's
releases</a>.</em></p>
<blockquote>
<h2>v4.1.10</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li><strong>browser</strong>: Check fs access in builtin commands
[backport to v4]  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode
(claude-opus-4-8)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10680">vitest-dev/vitest#10680</a>
<a href="https://github.com/vitest-dev/vitest/commit/5c18dd267"><!-- raw
HTML omitted -->(5c18d)<!-- raw HTML omitted --></a></li>
<li><strong>vm</strong>: Fix external module resolve error with deps
optimizer query for encoded URI [backport to v4]  -  by <a
href="https://github.com/SveLil"><code>@​SveLil</code></a> and <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10661">vitest-dev/vitest#10661</a>
<a href="https://github.com/vitest-dev/vitest/commit/bae52b511"><!-- raw
HTML omitted -->(bae52)<!-- raw HTML omitted --></a></li>
</ul>
<h5>    <a
href="https://github.com/vitest-dev/vitest/compare/v4.1.9...v4.1.10">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitest-dev/vitest/commit/db616d227b6e0cb07a94f5d1bba262ee95db7e46"><code>db616d2</code></a>
chore: release v4.1.10 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8/issues/10718">#10718</a>)</li>
<li>See full diff in <a
href="https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/coverage-v8">compare
view</a></li>
</ul>
</details>
<br />

Updates `vitest` from 4.1.9 to 4.1.10
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">vitest's
releases</a>.</em></p>
<blockquote>
<h2>v4.1.10</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li><strong>browser</strong>: Check fs access in builtin commands
[backport to v4]  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode
(claude-opus-4-8)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10680">vitest-dev/vitest#10680</a>
<a href="https://github.com/vitest-dev/vitest/commit/5c18dd267"><!-- raw
HTML omitted -->(5c18d)<!-- raw HTML omitted --></a></li>
<li><strong>vm</strong>: Fix external module resolve error with deps
optimizer query for encoded URI [backport to v4]  -  by <a
href="https://github.com/SveLil"><code>@​SveLil</code></a> and <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10661">vitest-dev/vitest#10661</a>
<a href="https://github.com/vitest-dev/vitest/commit/bae52b511"><!-- raw
HTML omitted -->(bae52)<!-- raw HTML omitted --></a></li>
</ul>
<h5>    <a
href="https://github.com/vitest-dev/vitest/compare/v4.1.9...v4.1.10">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitest-dev/vitest/commit/db616d227b6e0cb07a94f5d1bba262ee95db7e46"><code>db616d2</code></a>
chore: release v4.1.10 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10718">#10718</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/bae52b5112a6fd8200101b88bf8af9685d077295"><code>bae52b5</code></a>
fix(vm): fix external module resolve error with deps optimizer query for
enco...</li>
<li>See full diff in <a
href="https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 13:51:14 +00:00
Harel M be9456d11b Relocate e2e tests (#1989)
## Launch Checklist

Renames the e2e test and reduces changes as a preparation step from
playwright in the following PR:
- #1988

This is to keep as much history as possible.
<img width="1907" height="933" alt="image"
src="https://github.com/user-attachments/assets/b52075b3-eb3b-45dc-93dc-8c5e9cfd35dd"
/>

 - [x] Briefly describe the changes in this PR.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:38:59 +03:00
132 changed files with 7903 additions and 7128 deletions
-6
View File
@@ -96,7 +96,6 @@ jobs:
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test-unit-ci - run: npm run test-unit-ci
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
@@ -164,8 +163,3 @@ jobs:
run: npm run test-e2e run: npm run test-e2e
env: env:
E2E_NO_WEBSERVER: "1" E2E_NO_WEBSERVER: "1"
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ${{ github.workspace }}/coverage/coverage-final.json
verbose: true
+104 -1
View File
@@ -36,7 +36,7 @@ Then run the end-to-end tests (Playwright starts the dev server automatically):
npm run test npm run test
``` ```
Run the unit and component tests with Vitest: Run the unit tests with Vitest:
``` ```
npm run test-unit npm run test-unit
@@ -45,3 +45,106 @@ npm run test-unit
## Pull Requests ## Pull Requests
- Pull requests should update `CHANGELOG.md` with a short description of the change. - 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).
+60
View File
@@ -0,0 +1,60 @@
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");
});
});
+5 -5
View File
@@ -1,16 +1,16 @@
import { test } from "./fixtures"; import { test, describe, beforeEach } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
test.describe("accessibility", () => { describe("accessibility", () => {
const { given, get, when, then } = new MaputnikDriver(); const { given, get, when, then } = new MaputnikDriver();
test.beforeEach(async () => { beforeEach(async () => {
await given.setupMockBackedResponses(); await given.setupMockBackedResponses();
await when.setStyle("both"); await when.setStyle("both");
}); });
test.describe("skip links", () => { describe("skip links", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.setStyle("layer"); await when.setStyle("layer");
}); });
+3 -3
View File
@@ -1,10 +1,10 @@
import { test } from "./fixtures"; import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
test.describe("code editor", () => { describe("code editor", () => {
const { given, get, when, then } = new MaputnikDriver(); const { given, get, when, then } = new MaputnikDriver();
test.beforeEach(async () => { beforeEach(async () => {
await given.setupMockBackedResponses(); await given.setupMockBackedResponses();
await when.setStyle("both"); await when.setStyle("both");
}); });
-30
View File
@@ -1,30 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import type { Page } from "@playwright/test";
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.
*/
export 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));
}
-46
View File
@@ -1,46 +0,0 @@
import { test as base, expect, type Page } from "@playwright/test";
import { readCoverage, writeCoverage } from "./coverage";
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;
}
/** Records a coverage snapshot (called before navigations, which reset __coverage__). */
export function recordCoverageChunk(chunk: unknown): void {
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`.
*/
export const test = base.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 },
],
});
export { expect };
+22
View File
@@ -0,0 +1,22 @@
{
"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": []
}
+3 -3
View File
@@ -1,13 +1,13 @@
import { test } from "./fixtures"; import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
test.describe("history", () => { describe("history", () => {
const { given, get, when, then } = new MaputnikDriver(); const { given, get, when, then } = new MaputnikDriver();
const undoKeyCombo = process.platform === "darwin" ? "{meta}z" : "{ctrl}z"; const undoKeyCombo = process.platform === "darwin" ? "{meta}z" : "{ctrl}z";
const redoKeyCombo = process.platform === "darwin" ? "{meta}{shift}z" : "{ctrl}y"; const redoKeyCombo = process.platform === "darwin" ? "{meta}{shift}z" : "{ctrl}y";
test.beforeEach(async () => { beforeEach(async () => {
await given.setupMockBackedResponses(); await given.setupMockBackedResponses();
await when.setStyle("both"); await when.setStyle("both");
}); });
+6 -6
View File
@@ -1,15 +1,15 @@
import { test } from "./fixtures"; import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
test.describe("i18n", () => { describe("i18n", () => {
const { given, get, when, then } = new MaputnikDriver(); const { given, get, when, then } = new MaputnikDriver();
test.beforeEach(async () => { beforeEach(async () => {
await given.setupMockBackedResponses(); await given.setupMockBackedResponses();
await when.setStyle("both"); await when.setStyle("both");
}); });
test.describe("language detector", () => { describe("language detector", () => {
test("English", async () => { test("English", async () => {
await when.visit("?lng=en"); await when.visit("?lng=en");
await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en"); await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en");
@@ -21,8 +21,8 @@ test.describe("i18n", () => {
}); });
}); });
test.describe("language switcher", () => { describe("language switcher", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.setStyle("layer"); await when.setStyle("layer");
}); });
+5 -5
View File
@@ -1,16 +1,16 @@
import { test } from "./fixtures"; import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
test.describe("keyboard", () => { describe("keyboard", () => {
const { given, get, when, then } = new MaputnikDriver(); const { given, get, when, then } = new MaputnikDriver();
test.beforeEach(async () => { beforeEach(async () => {
await given.setupMockBackedResponses(); await given.setupMockBackedResponses();
await when.setStyle("both"); await when.setStyle("both");
}); });
test.describe("shortcuts", () => { describe("shortcuts", () => {
test.beforeEach(async () => { beforeEach(async () => {
await given.setupMockBackedResponses(); await given.setupMockBackedResponses();
await when.setStyle(""); await when.setStyle("");
}); });
+391 -38
View File
@@ -1,11 +1,11 @@
import { v1 as uuid } from "uuid"; import { v1 as uuid } from "uuid";
import { test } from "./fixtures"; import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
test.describe("layer editor", () => { describe("layer editor", () => {
const { given, get, when, then } = new MaputnikDriver(); const { given, get, when, then } = new MaputnikDriver();
test.beforeEach(async () => { beforeEach(async () => {
await given.setupMockBackedResponses(); await given.setupMockBackedResponses();
await when.setStyle("both"); await when.setStyle("both");
await when.modal.open(); await when.modal.open();
@@ -25,7 +25,18 @@ test.describe("layer editor", () => {
return id; return id;
} }
test.skip("expand/collapse", () => {}); test("expand/collapse", async () => {
const bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
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 () => { test("id", async () => {
const bgId = await createBackground(); const bgId = await createBackground();
@@ -41,7 +52,7 @@ test.describe("layer editor", () => {
}); });
}); });
test.describe("source", () => { describe("source", () => {
test("should show error when the source is invalid", async () => { test("should show error when the source is invalid", async () => {
await when.modal.fillLayers({ await when.modal.fillLayers({
type: "circle", type: "circle",
@@ -53,10 +64,10 @@ test.describe("layer editor", () => {
}); });
}); });
test.describe("min-zoom", () => { describe("min-zoom", () => {
let bgId: string; let bgId: string;
test.beforeEach(async () => { beforeEach(async () => {
bgId = await createBackground(); bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId); await when.click("layer-list-item:background:" + bgId);
await when.setValue("min-zoom.input-text", "1"); await when.setValue("min-zoom.input-text", "1");
@@ -76,12 +87,20 @@ test.describe("layer editor", () => {
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }], 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 }],
});
});
}); });
test.describe("max-zoom", () => { describe("max-zoom", () => {
let bgId: string; let bgId: string;
test.beforeEach(async () => { beforeEach(async () => {
bgId = await createBackground(); bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId); await when.click("layer-list-item:background:" + bgId);
await when.setValue("max-zoom.input-text", "1"); await when.setValue("max-zoom.input-text", "1");
@@ -95,11 +114,11 @@ test.describe("layer editor", () => {
}); });
}); });
test.describe("comments", () => { describe("comments", () => {
let bgId: string; let bgId: string;
const comment = "42"; const comment = "42";
test.beforeEach(async () => { beforeEach(async () => {
bgId = await createBackground(); bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId); await when.click("layer-list-item:background:" + bgId);
await when.setValue("layer-comment.input", comment); await when.setValue("layer-comment.input", comment);
@@ -118,8 +137,8 @@ test.describe("layer editor", () => {
}); });
}); });
test.describe("when unsetting", () => { describe("when unsetting", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.clear("layer-comment.input"); await when.clear("layer-comment.input");
await when.click("min-zoom.input-text"); await when.click("min-zoom.input-text");
}); });
@@ -132,9 +151,9 @@ test.describe("layer editor", () => {
}); });
}); });
test.describe("color", () => { describe("color", () => {
let bgId: string; let bgId: string;
test.beforeEach(async () => { beforeEach(async () => {
bgId = await createBackground(); bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId); await when.click("layer-list-item:background:" + bgId);
await when.click("spec-field:background-color"); await when.click("spec-field:background-color");
@@ -145,11 +164,18 @@ test.describe("layer editor", () => {
layers: [{ id: "background:" + bgId, type: "background" }], 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.describe("opacity", () => { describe("opacity", () => {
let bgId: string; let bgId: string;
test.beforeEach(async () => { beforeEach(async () => {
bgId = await createBackground(); bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId); await when.click("layer-list-item:background:" + bgId);
await when.type("spec-field-input:background-opacity", "0."); await when.type("spec-field-input:background-opacity", "0.");
@@ -165,33 +191,343 @@ test.describe("layer editor", () => {
}); });
}); });
test.describe("filter", () => { describe("filter", () => {
test.skip("expand/collapse", () => {}); let id: string;
test.skip("compound filter", () => {});
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"] }],
});
});
});
}); });
test.describe("layout", () => { 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 } }],
});
});
});
});
describe("layout", () => {
test("text-font", async () => { test("text-font", async () => {
await when.setStyle("font"); await when.setStyle("font");
await when.collapseGroupInLayerEditor(); await when.collapseGroupInLayerEditor();
await when.collapseGroupInLayerEditor(1); await when.collapseGroupInLayerEditor(1);
await when.collapseGroupInLayerEditor(2); await when.collapseGroupInLayerEditor(2);
await when.doWithin("spec-field:text-font", async () => { await when.clickWithin("spec-field:text-font", ".maputnik-autocomplete input");
await get.element(".maputnik-autocomplete input").first().click();
});
await then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible(); await then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible();
await then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3); await then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3);
}); });
}); });
test.describe("paint", () => { describe("paint", () => {
test.skip("expand/collapse", () => {}); let id: string;
test.skip("color", () => {});
test.skip("pattern", () => {}); beforeEach(async () => {
test.skip("opacity", () => {}); 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.describe("json-editor", () => { describe("json-editor", () => {
test("add", async () => { test("add", async () => {
const id = await when.modal.fillLayers({ const id = await when.modal.fillLayers({
type: "circle", type: "circle",
@@ -202,15 +538,34 @@ test.describe("layer editor", () => {
layers: [{ id, type: "circle", source: "example" }], layers: [{ id, type: "circle", source: "example" }],
}); });
const sourceText = get.elementByText('"source"'); await when.clickByText('"source"');
await sourceText.click();
await when.typeKeys('"'); await when.typeKeys('"');
await then(get.element(".cm-lint-marker-error")).shouldExist(); await then(get.element(".cm-lint-marker-error")).shouldExist();
}); });
test.skip("expand/collapse", () => {}); test("expand/collapse", async () => {
test.skip("modify", () => {}); const bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await then(get.element(".cm-content")).shouldBeVisible();
await when.toggleGroupInLayerEditor("JSON Editor");
await then(get.element(".cm-content")).shouldNotBeVisible();
await when.toggleGroupInLayerEditor("JSON Editor");
await then(get.element(".cm-content")).shouldBeVisible();
});
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 () => { test("parse error", async () => {
const bgId = await createBackground(); const bgId = await createBackground();
@@ -227,7 +582,7 @@ test.describe("layer editor", () => {
}); });
}); });
test.describe("sticky header", () => { describe("sticky header", () => {
test("should keep layer header visible when scrolling properties", async () => { test("should keep layer header visible when scrolling properties", async () => {
// Setup: Create a layer with many properties (e.g. symbol layer) // Setup: Create a layer with many properties (e.g. symbol layer)
await when.modal.fillLayers({ await when.modal.fillLayers({
@@ -239,9 +594,7 @@ test.describe("layer editor", () => {
const header = get.elementByTestId("layer-editor.header"); const header = get.elementByTestId("layer-editor.header");
await then(header).shouldBeVisible(); await then(header).shouldBeVisible();
await get await when.scrollToBottom(get.element(".maputnik-scroll-container"));
.element(".maputnik-scroll-container")
.evaluate((el) => el.scrollTo(0, el.scrollHeight));
await when.wait(200); await when.wait(200);
await then(header).shouldBeVisible(); await then(header).shouldBeVisible();
+47 -34
View File
@@ -1,18 +1,18 @@
import { test } from "./fixtures"; import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
test.describe("layers list", () => { describe("layers list", () => {
const { given, get, when, then } = new MaputnikDriver(); const { given, get, when, then } = new MaputnikDriver();
test.beforeEach(async () => { beforeEach(async () => {
await given.setupMockBackedResponses(); await given.setupMockBackedResponses();
await when.setStyle("both"); await when.setStyle("both");
await when.modal.open(); await when.modal.open();
}); });
test.describe("ops", () => { describe("ops", () => {
let id: string; let id: string;
test.beforeEach(async () => { beforeEach(async () => {
id = await when.modal.fillLayers({ type: "background" }); id = await when.modal.fillLayers({ type: "background" });
}); });
@@ -22,8 +22,8 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("when clicking delete", () => { describe("when clicking delete", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.click("layer-list-item:" + id + ":delete"); await when.click("layer-list-item:" + id + ":delete");
}); });
test("should empty layers in local storage", async () => { test("should empty layers in local storage", async () => {
@@ -33,8 +33,8 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("when clicking duplicate", () => { describe("when clicking duplicate", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.click("layer-list-item:" + id + ":copy"); await when.click("layer-list-item:" + id + ":copy");
}); });
test("should add copy layer in local storage", async () => { test("should add copy layer in local storage", async () => {
@@ -47,8 +47,8 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("when clicking hide", () => { describe("when clicking hide", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.click("layer-list-item:" + id + ":toggle-visibility"); await when.click("layer-list-item:" + id + ":toggle-visibility");
}); });
@@ -58,8 +58,8 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("when clicking show", () => { describe("when clicking show", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.click("layer-list-item:" + id + ":toggle-visibility"); await when.click("layer-list-item:" + id + ":toggle-visibility");
}); });
@@ -70,9 +70,9 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("when selecting a layer", () => { describe("when selecting a layer", () => {
let secondId: string; let secondId: string;
test.beforeEach(async () => { beforeEach(async () => {
await when.modal.open(); await when.modal.open();
secondId = await when.modal.fillLayers({ secondId = await when.modal.fillLayers({
id: "second-layer", id: "second-layer",
@@ -89,7 +89,7 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("background", () => { describe("background", () => {
test("add", async () => { test("add", async () => {
const id = await when.modal.fillLayers({ type: "background" }); const id = await when.modal.fillLayers({ type: "background" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -97,10 +97,18 @@ test.describe("layers list", () => {
}); });
}); });
test.skip("modify", () => {}); 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 } }],
});
});
}); });
test.describe("fill", () => { describe("fill", () => {
test("add", async () => { test("add", async () => {
const id = await when.modal.fillLayers({ type: "fill", layer: "example" }); const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -108,11 +116,16 @@ test.describe("layers list", () => {
}); });
}); });
// TODO: Change source test("change source", async () => {
test.skip("change source", () => {}); 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" }],
});
});
}); });
test.describe("line", () => { describe("line", () => {
test("add", async () => { test("add", async () => {
const id = await when.modal.fillLayers({ type: "line", layer: "example" }); const id = await when.modal.fillLayers({ type: "line", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -121,7 +134,6 @@ test.describe("layers list", () => {
}); });
test("groups", async () => { test("groups", async () => {
await when.modal.open();
const id1 = await when.modal.fillLayers({ id: "aa", type: "line", layer: "example" }); const id1 = await when.modal.fillLayers({ id: "aa", type: "line", layer: "example" });
await when.modal.open(); await when.modal.open();
@@ -151,7 +163,7 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("symbol", () => { describe("symbol", () => {
test("add", async () => { test("add", async () => {
const id = await when.modal.fillLayers({ type: "symbol", layer: "example" }); const id = await when.modal.fillLayers({ type: "symbol", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -189,7 +201,7 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("raster", () => { describe("raster", () => {
test("add", async () => { test("add", async () => {
const id = await when.modal.fillLayers({ type: "raster", layer: "raster" }); const id = await when.modal.fillLayers({ type: "raster", layer: "raster" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -198,7 +210,7 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("circle", () => { describe("circle", () => {
test("add", async () => { test("add", async () => {
const id = await when.modal.fillLayers({ type: "circle", layer: "example" }); const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -207,7 +219,7 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("fill extrusion", () => { describe("fill extrusion", () => {
test("add", async () => { test("add", async () => {
const id = await when.modal.fillLayers({ type: "fill-extrusion", layer: "example" }); const id = await when.modal.fillLayers({ type: "fill-extrusion", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -216,7 +228,7 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("hillshade", () => { describe("hillshade", () => {
test("add", async () => { test("add", async () => {
const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" }); const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -267,7 +279,7 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("color-relief", () => { describe("color-relief", () => {
test("add", async () => { test("add", async () => {
const id = await when.modal.fillLayers({ type: "color-relief", layer: "example" }); const id = await when.modal.fillLayers({ type: "color-relief", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -285,7 +297,7 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("groups", () => { describe("groups", () => {
test("simple", async () => { test("simple", async () => {
await when.setStyle("geojson"); await when.setStyle("geojson");
@@ -308,9 +320,8 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("drag and drop", () => { describe("drag and drop", () => {
test("move layer should update local storage", async () => { test("move layer should update local storage", async () => {
await when.modal.open();
const firstId = await when.modal.fillLayers({ id: "a", type: "background" }); const firstId = await when.modal.fillLayers({ id: "a", type: "background" });
await when.modal.open(); await when.modal.open();
const secondId = await when.modal.fillLayers({ id: "b", type: "background" }); const secondId = await when.modal.fillLayers({ id: "b", type: "background" });
@@ -329,10 +340,12 @@ test.describe("layers list", () => {
}); });
}); });
test.describe("sticky header", () => { describe("sticky header", () => {
test("should keep header visible when scrolling layer list", async () => { test("should keep header visible when scrolling layer list", async () => {
// Setup: Create multiple layers to enable scrolling // Setup: Create multiple layers to enable scrolling
for (let i = 0; i < 20; i++) { // 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.open();
await when.modal.fillLayers({ id: `layer-${i}`, type: "background" }); await when.modal.fillLayers({ id: `layer-${i}`, type: "background" });
} }
@@ -342,7 +355,7 @@ test.describe("layers list", () => {
await then(header).shouldBeVisible(); await then(header).shouldBeVisible();
// Scroll the layer list container // Scroll the layer list container
await get.elementByTestId("layer-list").evaluate((el) => el.scrollTo(0, el.scrollHeight)); await when.scrollToBottom(get.elementByTestId("layer-list"));
await when.wait(200); await when.wait(200);
await then(header).shouldBeVisible(); await then(header).shouldBeVisible();
await then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible(); await then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible();
+7 -7
View File
@@ -1,15 +1,15 @@
import { test } from "./fixtures"; import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
test.describe("map", () => { describe("map", () => {
const { given, get, when, then } = new MaputnikDriver(); const { given, get, when, then } = new MaputnikDriver();
test.beforeEach(async () => { beforeEach(async () => {
await given.setupMockBackedResponses(); await given.setupMockBackedResponses();
await when.setStyle("both"); await when.setStyle("both");
}); });
test.describe("zoom level", () => { describe("zoom level", () => {
test("via url", async () => { test("via url", async () => {
const zoomLevel = 12.37; const zoomLevel = 12.37;
await when.setStyle("geojson", zoomLevel); await when.setStyle("geojson", zoomLevel);
@@ -38,14 +38,14 @@ test.describe("map", () => {
}); });
}); });
test.describe("search", () => { describe("search", () => {
test("should exist", async () => { test("should exist", async () => {
await then(get.searchControl()).shouldBeVisible(); await then(get.searchControl()).shouldBeVisible();
}); });
}); });
test.describe("popup", () => { describe("popup", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.setStyle("rectangles"); await when.setStyle("rectangles");
await then(get.locationHash()).shouldExist(); await then(get.locationHash()).shouldExist();
}); });
+230 -451
View File
@@ -1,270 +1,31 @@
import fs from "node:fs"; import { PlaywrightHelper } from "./playwright-helper";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, type Locator, type Page, type Request } from "@playwright/test";
import { readCoverage } from "./coverage";
import { currentPage, recordCoverageChunk } from "./fixtures";
import { ModalDriver } from "./modal-driver"; import { ModalDriver } from "./modal-driver";
const baseUrl = "http://localhost:8888/"; const baseUrl = "http://localhost:8888/";
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DATA_ATTRIBUTE = "data-wd-key";
const isMac = process.platform === "darwin"; const isMac = process.platform === "darwin";
function testIdSelector(testId: string): string { /**
return `[${DATA_ATTRIBUTE}="${testId}"]`; * 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.
*/
export class MaputnikDriver {
private readonly helper = new PlaywrightHelper();
private readonly modalDriver = new ModalDriver();
export function readFixture(name: string): any { then = this.helper.then;
const contents = fs.readFileSync(path.join(FIXTURES_DIR, name), "utf-8");
return JSON.parse(contents);
}
/** Reads the maputnik style currently persisted in localStorage. */ /** Reads the maputnik style currently persisted in localStorage. */
function styleFromLocalStorage(page: Page): Promise<any> { private async readStoredStyle(): Promise<any> {
return page.evaluate(() => { const styleId = await this.helper.get.localStorageItem("maputnik:latest_style");
const styleId = window.localStorage.getItem("maputnik:latest_style"); const styleItem = await this.helper.get.localStorageItem(`maputnik:style:${styleId}`);
const styleItemKey = `maputnik:style:${styleId}`;
const styleItem = window.localStorage.getItem(styleItemKey);
if (!styleItem) throw new Error("Could not get styleItem from localStorage"); if (!styleItem) throw new Error("Could not get styleItem from localStorage");
return JSON.parse(styleItem); return JSON.parse(styleItem);
});
}
async function retry(assertion: () => Promise<void> | void, timeout = 10000, interval = 100): Promise<void> {
const start = Date.now();
let lastError: unknown;
for (;;) {
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, mirroring Cypress' retry-ability.
*/
export class Query<T> {
readonly __maputnikQuery = 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>).__maputnikQuery === 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 every top-level key in `expected` deep-equals its counterpart in `actual`. */
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown>): void {
for (const key of Object.keys(expected)) {
expect(actual?.[key], `property "${key}"`).toEqual(expected[key]);
}
}
export class MaputnikAssertable<T> {
constructor(private readonly target: T, private readonly page?: Page) {}
private locator(): Locator {
if (!isLocator(this.target)) throw new Error("Expected a Locator target for this assertion");
return this.target;
}
private 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>) =>
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
/**
* Asserts that the object under test (a fixture / response body) contains every
* top-level property of the style currently stored in localStorage.
*/
shouldEqualToStoredStyle = async () => {
if (!this.page) throw new Error("shouldEqualToStoredStyle requires a page-bound assertable");
const expected = await (this.target as any);
await retry(async () => {
const stored = await styleFromLocalStorage(this.page!);
assertDeepNestedInclude(expected, stored);
});
};
}
/**
* Translates a Cypress-style key sequence (e.g. "{meta}z", "{esc}", "0.") into
* Playwright keyboard actions on the currently focused element.
*/
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",
};
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 };
}
export class MaputnikDriver {
private scope: Locator | null = null;
private readonly recordedRequests = new Map<string, Request[]>();
private readonly modalDriver = new ModalDriver(this);
/**
* The page for the currently running test. Resolved lazily so a single driver
* instance can be created once per `describe` and reused across its tests.
*/
private get page(): Page {
return currentPage();
}
// ---- Element access ------------------------------------------------------
private root(): Page | Locator {
return this.scope ?? this.page;
}
private testId(testId: string): Locator {
return this.root().locator(testIdSelector(testId));
}
then = <T>(target: T) => new MaputnikAssertable(target, this.page);
// ---- given ---------------------------------------------------------------
public given = { public given = {
fixture: (_name: string, _alias?: string) => { ...this.helper.given,
// Fixtures are read directly from disk in Playwright, no registration needed.
},
intercept: async (url: string, alias: string, _method = "GET") => {
this.recordedRequests.set(alias, []);
// Convert the Cypress-style glob (which may contain "?" in a query string)
// into a regex so query parameters match reliably.
const pattern = new RegExp(
"^" + url.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, ".*") + "$"
);
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)
? readFixture((response as { fixture: string }).fixture)
: response;
route.fulfill({ json: body });
});
},
setupMockBackedResponses: async () => { setupMockBackedResponses: async () => {
const styleFixtures = [ const styleFixtures = [
@@ -277,18 +38,19 @@ export class MaputnikDriver {
"example-style-with-fonts.json", "example-style-with-fonts.json",
"example-style-with-zoom-7-and-center-0-51.json", "example-style-with-zoom-7-and-center-0-51.json",
"example-style-with-zoom-5-and-center-50-50.json", "example-style-with-zoom-5-and-center-50-50.json",
"access-token-style.json",
]; ];
for (const fixture of styleFixtures) { for (const fixture of styleFixtures) {
await this.given.interceptAndMockResponse({ await this.helper.given.interceptAndMockResponse({
method: "GET", method: "GET",
url: baseUrl + fixture, url: baseUrl + fixture,
response: { fixture }, response: { fixture },
alias: fixture === "example-style.json" ? "example-style.json" : undefined, alias: fixture === "example-style.json" ? "example-style.json" : undefined,
}); });
} }
await this.given.interceptAndMockResponse({ method: "GET", url: /example\.local\//, response: [] }); await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.local\//, response: [] });
await this.given.interceptAndMockResponse({ method: "GET", url: /example\.com\//, response: [] }); await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.com\//, response: [] });
await this.given.interceptAndMockResponse({ await this.helper.given.interceptAndMockResponse({
method: "GET", method: "GET",
url: "https://www.glyph-server.com/*", url: "https://www.glyph-server.com/*",
response: ["Font 1", "Font 2", "Font 3"], response: ["Font 1", "Font 2", "Font 3"],
@@ -296,97 +58,11 @@ export class MaputnikDriver {
}, },
}; };
// ---- when ----------------------------------------------------------------
public when = { public when = {
...this.helper.when,
modal: this.modalDriver.when, modal: this.modalDriver.when,
visit: async (url: string) => {
// Snapshot coverage before navigating, since a full page load resets it.
recordCoverageChunk(await readCoverage(this.page));
const target = url.startsWith("http") ? url : new URL(url, baseUrl).toString();
await this.page.goto(target);
},
wait: (ms: number) => this.page.waitForTimeout(ms),
tab: () => this.page.keyboard.press("Tab"),
typeKeys: (keys: string) => typeSequence(this.page, keys),
doWithin: async (selector: string, fn: () => Promise<void> | void) => {
const previous = this.scope;
this.scope = (previous ?? this.page).locator(testIdSelector(selector));
try {
await fn();
} finally {
this.scope = previous;
}
},
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 (selector: string, value: string) => {
await this.root().locator(testIdSelector(selector)).locator("select").selectOption(value);
},
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 (matching how the original
// Cypress suite typed), 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);
},
setValueToPropertyArray: async (selector: string, value: string) => {
await this.when.doWithin(selector, async () => {
const input = this.root().locator(".maputnik-array-block-content input").last();
await input.focus();
await typeSequence(this.page, "{selectall}" + value);
});
},
addValueToPropertyArray: async (selector: string, value: string) => {
await this.when.doWithin(selector, async () => {
await this.root().locator(".maputnik-array-add-value").click();
const input = this.root().locator(".maputnik-array-block-content input").last();
await input.focus();
await typeSequence(this.page, "{selectall}" + value);
});
},
setStyle: async ( setStyle: async (
styleProperties: styleProperties:
| "geojson" | "geojson"
@@ -396,6 +72,7 @@ export class MaputnikDriver {
| "rectangles" | "rectangles"
| "font" | "font"
| "zoom_7_center_0_51" | "zoom_7_center_0_51"
| "access_tokens"
| "", | "",
zoom?: number zoom?: number
) => { ) => {
@@ -407,6 +84,7 @@ export class MaputnikDriver {
rectangles: "rectangles-style.json", rectangles: "rectangles-style.json",
font: "example-style-with-fonts.json", font: "example-style-with-fonts.json",
zoom_7_center_0_51: "example-style-with-zoom-7-and-center-0-51.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); const url = new URL(baseUrl);
@@ -417,163 +95,264 @@ export class MaputnikDriver {
url.hash = `${zoom}/41.3805/2.1635`; url.hash = `${zoom}/41.3805/2.1635`;
} }
await this.when.visit(url.toString()); await this.helper.when.visit(url.toString());
const toolbarLink = this.testId("toolbar:link"); const toolbarLink = this.helper.get.elementByTestId("toolbar:link");
await toolbarLink.scrollIntoViewIfNeeded(); await toolbarLink.scrollIntoViewIfNeeded();
await expect(toolbarLink).toBeVisible(); await this.then(toolbarLink).shouldBeVisible();
}, },
openASecondStyleWithDifferentZoomAndCenter: async () => { openASecondStyleWithDifferentZoomAndCenter: async () => {
await this.page.getByRole("button", { name: "Open" }).click(); await this.helper.when.clickButtonByName("Open");
const input = this.testId("modal:open.url.input"); const input = this.helper.get.elementByTestId("modal:open.url.input");
await expect(input).toBeEnabled();
await input.fill("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json"); await input.fill("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json");
await input.press("Enter"); await input.press("Enter");
}, },
chooseExampleFile: async () => { chooseExampleFile: async () => {
await this.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input"); await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone");
await this.when.wait(200); await this.helper.when.wait(200);
},
/** 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);
}, },
dropExampleFile: async () => { dropExampleFile: async () => {
await this.dropFileByFixture("example-style.json", "modal:open.dropzone"); await this.helper.when.dropFileByFixture("example-style.json", "modal:open.dropzone");
await this.when.wait(200); await this.helper.when.wait(200);
}, },
clickZoomIn: async () => { clickZoomIn: async () => {
await this.page.locator(".maplibregl-ctrl-zoom-in").click(); await this.helper.get.element(".maplibregl-ctrl-zoom-in").click();
}, },
closePopup: async () => { closePopup: async () => {
await this.page.locator(".maplibregl-popup-close-button").click(); await this.helper.get.element(".maplibregl-popup-close-button").click();
},
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.when.wait(200);
await this.page.mouse.up();
}, },
collapseGroupInLayerEditor: async (index = 0) => { collapseGroupInLayerEditor: async (index = 0) => {
await this.page.locator(".maputnik-layer-editor-group__button").nth(index).click(); await this.helper.get.element(".maputnik-layer-editor-group__button").nth(index).click();
},
/** 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);
},
/**
* 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();
}, },
appendTextInJsonEditor: async (text: string) => { appendTextInJsonEditor: async (text: string) => {
await this.page.locator(".cm-line").first().click(); await this.helper.get.element(".cm-line").first().click();
// Move to the very start of the document so the inserted text breaks the // Move to the very start of the document so the inserted text breaks the
// root JSON structure (CodeMirror auto-closes brackets otherwise). // root JSON structure (CodeMirror auto-closes brackets otherwise).
await this.page.keyboard.press("Home"); await this.helper.when.typeKeys("{home}");
await typeSequence(this.page, text); await this.helper.when.typeText(text);
}, },
setTextInJsonEditor: async (text: string) => { setTextInJsonEditor: async (text: string) => {
const firstLine = this.page.locator(".cm-line").first(); await this.helper.get.element(".cm-line").first().click();
await firstLine.click(); await this.helper.when.typeKeys("{selectall}");
await this.page.keyboard.press(isMac ? "Meta+a" : "Control+a"); await this.helper.when.typeText(text);
await this.page.keyboard.type(text);
}, },
dragAndDropWithWait: async (source: string, target: string) => { setValueToPropertyArray: async (selector: string, value: string) => {
const from = await centerOf(this.testId(source)); const input = this.helper.get.elementByTestId(selector).locator(".maputnik-array-block-content input").last();
const to = await centerOf(this.testId(target)); await input.focus();
await this.page.mouse.move(from.x, from.y); await this.helper.when.typeKeys("{selectall}");
await this.page.mouse.down(); await this.helper.when.typeText(value);
await this.page.mouse.move(from.x, from.y + 10);
await this.page.mouse.move(to.x, to.y, { steps: 10 });
await this.when.wait(100);
await this.page.mouse.up();
}, },
waitForResponse: async (alias: string) => { addValueToPropertyArray: async (selector: string, value: string) => {
const requests = this.recordedRequests.get(alias); const block = this.helper.get.elementByTestId(selector);
if (!requests) throw new Error(`No intercept registered for alias "${alias}"`); await block.locator(".maputnik-array-add-value").click();
await retry(async () => { const input = block.locator(".maputnik-array-block-content input").last();
if (requests.length === 0) throw new Error(`No request recorded for alias "${alias}"`); await input.focus();
}); await this.helper.when.typeKeys("{selectall}");
return requests[requests.length - 1]; await this.helper.when.typeText(value);
}, },
waitForExampleFileResponse: () => this.when.waitForResponse("example-style.json"), 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 });
},
clearLocalStorage: () => this.page.evaluate(() => window.localStorage.clear()), 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-"),
}; };
// ---- get -----------------------------------------------------------------
public get = { public get = {
...this.helper.get,
isMac: () => isMac, isMac: () => isMac,
element: (selector: string) => this.root().locator(selector), canvas: () => this.helper.get.element("canvas"),
elementByTestId: (testId: string) => this.testId(testId), searchControl: () => this.helper.get.element(".maplibregl-ctrl-geocoder"),
elementByText: (text: string) => this.root().getByText(text), skipTargetLayerList: () => this.helper.get.elementByTestId("skip-target-layer-list"),
elementByAttribute: (attribute: string, value: string) => skipTargetLayerEditor: () => this.helper.get.elementByTestId("skip-target-layer-editor"),
this.root().locator(`[${attribute}="${value}"]`),
canvas: () => this.page.locator("canvas"), styleFromLocalStorage: () => this.helper.query(() => this.readStoredStyle()),
searchControl: () => this.page.locator(".maplibregl-ctrl-geocoder"), fixture: (name: string) => this.helper.readFixture(name),
skipTargetLayerList: () => this.testId("skip-target-layer-list"),
skipTargetLayerEditor: () => this.testId("skip-target-layer-editor"),
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),
styleFromLocalStorage: () => new Query<any>(() => styleFromLocalStorage(this.page)),
fixture: (name: string) => Promise.resolve(readFixture(name)),
responseBody: (alias: string) => { responseBody: (alias: string) => {
// Our mocked style responses always return the matching fixture. // Our mocked style responses always return the matching fixture.
const name = alias.endsWith(".json") ? alias : `${alias}.json`; const name = alias.endsWith(".json") ? alias : `${alias}.json`;
return Promise.resolve(readFixture(name)); return this.helper.readFixture(name);
}, },
exampleFileUrl: () => baseUrl + "example-style.json", exampleFileUrl: () => baseUrl + "example-style.json",
}; };
// ---- file open helpers ---------------------------------------------------
private async openFileByFixture(fixture: string, buttonTestId: string, inputTestId: string): Promise<void> {
const content = JSON.stringify(readFixture(fixture));
const hasPicker = await this.page.evaluate(() => "showOpenFilePicker" in window);
if (hasPicker) {
await this.page.evaluate((fileContent) => {
(window as any).showOpenFilePicker = async () => [
{ getFile: async () => ({ text: async () => fileContent }) },
];
}, content);
await this.testId(buttonTestId).click();
} else {
await this.testId(inputTestId).setInputFiles({
name: fixture,
mimeType: "application/json",
buffer: Buffer.from(content),
});
}
}
private async dropFileByFixture(fixture: string, dropzoneTestId: string): Promise<void> {
const content = JSON.stringify(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 });
}
} }
+51 -18
View File
@@ -1,27 +1,24 @@
import { v1 as uuid } from "uuid"; import { v1 as uuid } from "uuid";
import { expect } from "@playwright/test"; import { PlaywrightHelper } from "./playwright-helper";
import type { MaputnikDriver } from "./maputnik-driver";
export class ModalDriver { export class ModalDriver {
constructor(private readonly driver: MaputnikDriver) {} private readonly helper = new PlaywrightHelper();
public when = { public when = {
fillLayers: async (opts: { type: string; layer?: string; id?: string }) => { fillLayers: async (opts: { type: string; layer?: string; id?: string }) => {
const { when, get } = this.driver; const { when, get, then } = this.helper;
const id = opts.id ?? `${opts.type}:${uuid()}`; const id = opts.id ?? `${opts.type}:${uuid()}`;
await when.select("add-layer.layer-type.select", opts.type); await when.select("add-layer.layer-type.select", opts.type);
await when.type("add-layer.layer-id.input", id); await when.type("add-layer.layer-id.input", id);
if (opts.layer) { if (opts.layer) {
await when.doWithin("add-layer.layer-source-block", async () => { const input = get.elementByTestId("add-layer.layer-source-block").locator("input");
const input = get.element("input"); await input.click();
await input.click(); await input.fill(opts.layer);
await input.fill(opts.layer!); // The source input is a controlled downshift combobox; wait for React to
// The source input is a controlled downshift combobox; wait for React // settle on the typed value before submitting.
// to settle on the typed value before submitting. await then(input).shouldHaveValue(opts.layer);
await expect(input).toHaveValue(opts.layer!);
});
// Close the autocomplete menu so it does not intercept the add button. // Close the autocomplete menu so it does not intercept the add button.
await get.elementByTestId("add-layer.layer-id.input").click(); await get.elementByTestId("add-layer.layer-id.input").click();
} }
@@ -31,15 +28,51 @@ export class ModalDriver {
}, },
open: async () => { open: async () => {
// No-op when the add-layer modal is already open (some specs call open() await this.helper.when.click("layer-list:add-layer");
// both in a beforeEach and at the start of the test body).
const modal = this.driver.get.elementByTestId("modal:add-layer").first();
if (await modal.isVisible()) return;
await this.driver.when.click("layer-list:add-layer");
}, },
close: async (key: string) => { close: async (key: string) => {
await this.driver.when.click(key + ".close-modal"); 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();
}, },
}; };
} }
+233 -85
View File
@@ -1,20 +1,18 @@
import { test, expect } from "./fixtures"; import { test, expect, describe, beforeEach } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; 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" };
test.describe("modals", () => { describe("modals", () => {
const { given, get, when, then } = new MaputnikDriver(); const { given, get, when, then } = new MaputnikDriver();
test.beforeEach(async () => { beforeEach(async () => {
await given.setupMockBackedResponses(); await given.setupMockBackedResponses();
// Load a style first so it is persisted to localStorage, then reset the URL
// to the root (no style param) — several tests read the stored style.
await when.setStyle("both"); await when.setStyle("both");
await when.setStyle(""); await when.setStyle("");
}); });
test.describe("open", () => { describe("open", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.click("nav:open"); await when.click("nav:open");
}); });
@@ -25,16 +23,16 @@ test.describe("modals", () => {
test("upload", async () => { test("upload", async () => {
await when.chooseExampleFile(); await when.chooseExampleFile();
await then(get.fixture("example-style.json")).shouldEqualToStoredStyle(); await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
}); });
test("upload via drag and drop", async () => { test("upload via drag and drop", async () => {
await when.dropExampleFile(); await when.dropExampleFile();
await then(get.fixture("example-style.json")).shouldEqualToStoredStyle(); await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
}); });
test.describe("when click open url", () => { describe("when click open url", () => {
test.beforeEach(async () => { beforeEach(async () => {
const styleFileUrl = get.exampleFileUrl(); const styleFileUrl = get.exampleFileUrl();
await when.setValue("modal:open.url.input", styleFileUrl); await when.setValue("modal:open.url.input", styleFileUrl);
@@ -42,12 +40,22 @@ test.describe("modals", () => {
await when.wait(200); await when.wait(200);
}); });
test("load from url", async () => { test("load from url", async () => {
await then(get.responseBody("example-style.json")).shouldEqualToStoredStyle(); 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.describe("shortcuts", () => { describe("shortcuts", () => {
test("open/close", async () => { test("open/close", async () => {
await when.setStyle(""); await when.setStyle("");
await when.typeKeys("?"); await when.typeKeys("?");
@@ -56,8 +64,8 @@ test.describe("modals", () => {
}); });
}); });
test.describe("export", () => { describe("export", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.click("nav:export"); await when.click("nav:export");
}); });
@@ -66,18 +74,44 @@ test.describe("modals", () => {
await then(get.elementByTestId("modal:export")).shouldNotExist(); await then(get.elementByTestId("modal:export")).shouldNotExist();
}); });
// TODO: Work out how to download a file and check the contents test("download HTML and save the style", async () => {
test.skip("download", () => {}); // 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();
});
}); });
test.describe("sources", () => { describe("sources", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.setStyle("layer"); await when.setStyle("layer");
await when.click("nav:sources"); await when.click("nav:sources");
}); });
test.skip("active sources", () => {}); test("active sources are listed and can be deleted", async () => {
test.skip("public source", () => {}); 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("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", async () => {
const sourceId = "n1z2v3r"; const sourceId = "n1z2v3r";
@@ -86,8 +120,8 @@ test.describe("modals", () => {
await when.select("modal:sources.add.scheme_type", "tms"); await when.select("modal:sources.add.scheme_type", "tms");
await when.click("modal:sources.add.add_source"); await when.click("modal:sources.add.add_source");
await when.wait(200); await when.wait(200);
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
scheme: "tms", sources: { [sourceId]: { scheme: "tms" } },
}); });
}); });
@@ -120,13 +154,115 @@ test.describe("modals", () => {
await when.setValue("modal:sources.add.tile_size", "128"); await when.setValue("modal:sources.add.tile_size", "128");
await when.click("modal:sources.add.add_source"); await when.click("modal:sources.add.add_source");
await when.wait(200); await when.wait(200);
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
tileSize: 128, 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]] },
},
}); });
}); });
}); });
test.describe("inspect", () => { describe("inspect", () => {
test("toggle", async () => { test("toggle", async () => {
// There is no assertion in this test // There is no assertion in this test
await when.setStyle("geojson"); await when.setStyle("geojson");
@@ -134,13 +270,13 @@ test.describe("modals", () => {
}); });
}); });
test.describe("style settings", () => { describe("style settings", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.click("nav:settings"); await when.click("nav:settings");
}); });
test.describe("when click name filed spec information", () => { describe("when click name filed spec information", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.click("field-doc-button-Name"); await when.click("field-doc-button-Name");
}); });
@@ -149,8 +285,8 @@ test.describe("modals", () => {
}); });
}); });
test.describe("when set name and click owner", () => { describe("when set name and click owner", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.setValue("modal:settings.name", "foobar"); await when.setValue("modal:settings.name", "foobar");
await when.click("modal:settings.owner"); await when.click("modal:settings.owner");
await when.wait(200); await when.wait(200);
@@ -163,8 +299,8 @@ test.describe("modals", () => {
}); });
}); });
test.describe("when set owner and click name", () => { describe("when set owner and click name", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.setValue("modal:settings.owner", "foobar"); await when.setValue("modal:settings.owner", "foobar");
await when.click("modal:settings.name"); await when.click("modal:settings.name");
await when.wait(200); await when.wait(200);
@@ -206,8 +342,8 @@ test.describe("modals", () => {
const apiKey = "testing123"; const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey); await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey);
await when.click("modal:settings.name"); await when.click("modal:settings.name");
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
"maputnik:openmaptiles_access_token": apiKey, metadata: { "maputnik:openmaptiles_access_token": apiKey },
}); });
}); });
@@ -215,8 +351,8 @@ test.describe("modals", () => {
const apiKey = "testing123"; const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey); await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey);
await when.click("modal:settings.name"); await when.click("modal:settings.name");
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
"maputnik:thunderforest_access_token": apiKey, metadata: { "maputnik:thunderforest_access_token": apiKey },
}); });
}); });
@@ -224,8 +360,8 @@ test.describe("modals", () => {
const apiKey = "testing123"; const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey); await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey);
await when.click("modal:settings.name"); await when.click("modal:settings.name");
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
"maputnik:stadia_access_token": apiKey, metadata: { "maputnik:stadia_access_token": apiKey },
}); });
}); });
@@ -233,29 +369,67 @@ test.describe("modals", () => {
const apiKey = "testing123"; const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey); await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey);
await when.click("modal:settings.name"); await when.click("modal:settings.name");
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
"maputnik:locationiq_access_token": apiKey, metadata: { "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("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("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("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 projection mercator", async () => { test("style projection mercator", async () => {
await when.select("modal:settings.projection", "mercator"); await when.select("modal:settings.projection", "mercator");
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
type: "mercator", projection: { type: "mercator" },
}); });
}); });
test("style projection globe", async () => { test("style projection globe", async () => {
await when.select("modal:settings.projection", "globe"); await when.select("modal:settings.projection", "globe");
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
type: "globe", projection: { type: "globe" },
}); });
}); });
test("style projection vertical-perspective", async () => { test("style projection vertical-perspective", async () => {
await when.select("modal:settings.projection", "vertical-perspective"); await when.select("modal:settings.projection", "vertical-perspective");
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
type: "vertical-perspective", projection: { type: "vertical-perspective" },
}); });
}); });
@@ -273,7 +447,7 @@ test.describe("modals", () => {
await when.click("modal:settings.close-modal"); await when.click("modal:settings.close-modal");
await when.click("nav:open"); await when.click("nav:open");
await get.elementByAttribute("aria-label", "MapTiler Basic").click(); await when.clickByAttribute("aria-label", "MapTiler Basic");
await when.wait(1000); await when.wait(1000);
await when.click("nav:settings"); await when.click("nav:settings");
@@ -284,7 +458,7 @@ test.describe("modals", () => {
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol"); await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol");
await given.intercept( await given.intercept(
"https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=*", /https:\/\/api\.maptiler\.com\/tiles\/v3-openmaptiles\/tiles\.json\?key=.*/,
"tileRequest", "tileRequest",
"GET" "GET"
); );
@@ -299,8 +473,8 @@ test.describe("modals", () => {
}); });
}); });
test.describe("add layer", () => { describe("add layer", () => {
test.beforeEach(async () => { beforeEach(async () => {
await when.setStyle("layer"); await when.setStyle("layer");
await when.modal.open(); await when.modal.open();
}); });
@@ -313,12 +487,8 @@ test.describe("modals", () => {
}); });
}); });
test.describe("sources placeholder", () => { describe("global state", () => {
test.skip("toggle", () => {}); beforeEach(async () => {
});
test.describe("global state", () => {
test.beforeEach(async () => {
await when.click("nav:global-state"); await when.click("nav:global-state");
}); });
@@ -374,7 +544,7 @@ test.describe("modals", () => {
}); });
}); });
test.describe("error panel", () => { describe("error panel", () => {
test("not visible when no errors", async () => { test("not visible when no errors", async () => {
await then(get.element("maputnik-message-panel-error")).shouldNotExist(); await then(get.element("maputnik-message-panel-error")).shouldNotExist();
}); });
@@ -389,40 +559,18 @@ test.describe("modals", () => {
}); });
}); });
test.describe("Handle localStorage QuotaExceededError", () => { describe("Handle localStorage QuotaExceededError", () => {
test("handles quota exceeded error when opening style from URL", async ({ page }) => { test("handles quota exceeded error when opening style from URL", async () => {
// Clear localStorage to start fresh // Clear localStorage to start fresh
await when.clearLocalStorage(); await when.clearLocalStorage();
await when.fillLocalStorage();
// fill localStorage until we get a QuotaExceededError
await page.evaluate(() => {
let chunkSize = 1000;
const chunk = new Array(chunkSize).join("x");
let index = 0;
// Keep adding until we hit the quota
for (;;) {
try {
const key = `maputnik:fill-${index++}`;
window.localStorage.setItem(key, chunk);
} catch (e: any) {
// Verify it's a quota error
if (e.name === "QuotaExceededError") {
if (chunkSize <= 1) return;
chunkSize /= 2;
continue;
}
throw e; // Unexpected error
}
}
});
// Open the style via URL input // Open the style via URL input
await when.click("nav:open"); await when.click("nav:open");
await when.setValue("modal:open.url.input", get.exampleFileUrl()); await when.setValue("modal:open.url.input", get.exampleFileUrl());
await when.click("modal:open.url.button"); await when.click("modal:open.url.button");
await then(get.responseBody("example-style.json")).shouldEqualToStoredStyle(); await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json"));
await then(get.styleFromLocalStorage()).shouldExist(); await then(get.styleFromLocalStorage()).shouldExist();
}); });
}); });
+453
View File
@@ -0,0 +1,453 @@
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),
};
}
+82
View File
@@ -0,0 +1,82 @@
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,
};
+3 -1
View File
@@ -32,9 +32,11 @@ export default defineConfig({
"@stylistic": stylisticTs "@stylistic": stylisticTs
}, },
rules: { rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"react-refresh/only-export-components": [ "react-refresh/only-export-components": [
"warn", "warn",
{ allowConstantExport: true } { allowConstantExport: true, extraHOCs: ["withTranslation"] }
], ],
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
+491 -590
View File
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -28,9 +28,9 @@
"dependencies": { "dependencies": {
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/lint": "^6.9.7", "@codemirror/lint": "^6.9.7",
"@codemirror/state": "^6.7.0", "@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.5", "@codemirror/view": "^6.43.6",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -124,8 +124,6 @@
"@types/string-hash": "^1.1.3", "@types/string-hash": "^1.1.3",
"@types/wicg-file-system-access": "^2023.10.7", "@types/wicg-file-system-access": "^2023.10.7",
"@vitejs/plugin-react": "5.2", "@vitejs/plugin-react": "5.2",
"@vitest/browser": "^4.1.10",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"cors": "^2.8.6", "cors": "^2.8.6",
"eslint": "^10.6.0", "eslint": "^10.6.0",
@@ -147,7 +145,6 @@
"uuid": "^14.0.1", "uuid": "^14.0.1",
"vite": "^7.3.2", "vite": "^7.3.2",
"vite-plugin-istanbul": "^9.0.1", "vite-plugin-istanbul": "^9.0.1",
"vitest": "^4.1.10", "vitest": "^4.1.10"
"vitest-browser-react": "^2.2.0"
} }
} }
+2 -2
View File
@@ -9,8 +9,8 @@ const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:8888/";
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
testMatch: "**/*.spec.ts", testMatch: "**/*.spec.ts",
globalSetup: "./e2e/global-setup.ts", globalSetup: "./e2e/utils/e2e-setup.ts",
globalTeardown: "./e2e/global-teardown.ts", globalTeardown: "./e2e/utils/e2e-teardown.ts",
fullyParallel: true, fullyParallel: true,
forbidOnly: isCI, forbidOnly: isCI,
retries: isCI ? 2 : 0, retries: isCI ? 2 : 0,
+641 -598
View File
File diff suppressed because it is too large Load Diff
+31 -35
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import ScrollContainer from "./ScrollContainer"; import { ScrollContainer } from "./ScrollContainer";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { IconContext } from "react-icons"; import { IconContext } from "react-icons";
@@ -13,42 +13,38 @@ type AppLayoutInternalProps = {
modals?: React.ReactNode modals?: React.ReactNode
} & WithTranslation; } & WithTranslation;
class AppLayoutInternal extends React.Component<AppLayoutInternalProps> { const AppLayoutInternal: React.FC<AppLayoutInternalProps> = (props) => {
document.body.dir = props.i18n.dir();
render() { return <IconContext.Provider value={{size: "14px"}}>
document.body.dir = this.props.i18n.dir(); <div className="maputnik-layout">
{props.toolbar}
return <IconContext.Provider value={{size: "14px"}}> <div className="maputnik-layout-main">
<div className="maputnik-layout"> {props.codeEditor && <div className="maputnik-layout-code-editor">
{this.props.toolbar} <ScrollContainer>
<div className="maputnik-layout-main"> {props.codeEditor}
{this.props.codeEditor && <div className="maputnik-layout-code-editor"> </ScrollContainer>
<ScrollContainer>
{this.props.codeEditor}
</ScrollContainer>
</div>
}
{!this.props.codeEditor && <>
<div className="maputnik-layout-list">
{this.props.layerList}
</div>
<div className="maputnik-layout-drawer">
<ScrollContainer>
{this.props.layerEditor}
</ScrollContainer>
</div>
</>}
{this.props.map}
</div>
{this.props.bottom && <div className="maputnik-layout-bottom">
{this.props.bottom}
</div> </div>
} }
{this.props.modals} {!props.codeEditor && <>
<div className="maputnik-layout-list">
{props.layerList}
</div>
<div className="maputnik-layout-drawer">
<ScrollContainer>
{props.layerEditor}
</ScrollContainer>
</div>
</>}
{props.map}
</div> </div>
</IconContext.Provider>; {props.bottom && <div className="maputnik-layout-bottom">
} {props.bottom}
} </div>
}
{props.modals}
</div>
</IconContext.Provider>;
};
const AppLayout = withTranslation()(AppLayoutInternal); export const AppLayout = withTranslation()(AppLayoutInternal);
export default AppLayout;
+43 -47
View File
@@ -13,53 +13,49 @@ type AppMessagePanelInternalProps = {
selectedLayerIndex?: number selectedLayerIndex?: number
} & WithTranslation; } & WithTranslation;
class AppMessagePanelInternal extends React.Component<AppMessagePanelInternalProps> { const AppMessagePanelInternal: React.FC<AppMessagePanelInternalProps> = ({
static defaultProps = { onLayerSelect = () => { },
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>;
});
render() { const infos = props.infos?.map((m, i) => {
const { t, selectedLayerIndex } = this.props; return <p key={"info-" + i}>{m}</p>;
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>;
});
const infos = this.props.infos?.map((m, i) => { return <div className="maputnik-message-panel">
return <p key={"info-" + i}>{m}</p>; {errors}
}); {infos}
</div>;
};
return <div className="maputnik-message-panel"> export const AppMessagePanel = withTranslation()(AppMessagePanelInternal);
{errors}
{infos}
</div>;
}
}
const AppMessagePanel = withTranslation()(AppMessagePanelInternal);
export default AppMessagePanel;
+192 -213
View File
@@ -31,11 +31,9 @@ type IconTextProps = {
}; };
class IconText extends React.Component<IconTextProps> { const IconText: React.FC<IconTextProps> = (props) => {
render() { return <span className="maputnik-icon-text">{props.children}</span>;
return <span className="maputnik-icon-text">{this.props.children}</span>; };
}
}
type ToolbarLinkProps = { type ToolbarLinkProps = {
className?: string className?: string
@@ -43,35 +41,31 @@ type ToolbarLinkProps = {
href?: string href?: string
}; };
class ToolbarLink extends React.Component<ToolbarLinkProps> { const ToolbarLink: React.FC<ToolbarLinkProps> = (props) => {
render() { return <a
return <a className={classnames("maputnik-toolbar-link", props.className)}
className={classnames("maputnik-toolbar-link", this.props.className)} href={props.href}
href={this.props.href} rel="noopener noreferrer"
rel="noopener noreferrer" target="_blank"
target="_blank" data-wd-key="toolbar:link"
data-wd-key="toolbar:link" >
> {props.children}
{this.props.children} </a>;
</a>; };
}
}
type ToolbarSelectProps = { type ToolbarSelectProps = {
children?: React.ReactNode children?: React.ReactNode
wdKey?: string wdKey?: string
}; };
class ToolbarSelect extends React.Component<ToolbarSelectProps> { const ToolbarSelect: React.FC<ToolbarSelectProps> = (props) => {
render() { return <div
return <div className='maputnik-toolbar-select'
className='maputnik-toolbar-select' data-wd-key={props.wdKey}
data-wd-key={this.props.wdKey} >
> {props.children}
{this.props.children} </div>;
</div>; };
}
}
type ToolbarActionProps = { type ToolbarActionProps = {
children?: React.ReactNode children?: React.ReactNode
@@ -79,17 +73,15 @@ type ToolbarActionProps = {
wdKey?: string wdKey?: string
}; };
class ToolbarAction extends React.Component<ToolbarActionProps> { const ToolbarAction: React.FC<ToolbarActionProps> = (props) => {
render() { return <button
return <button className='maputnik-toolbar-action'
className='maputnik-toolbar-action' data-wd-key={props.wdKey}
data-wd-key={this.props.wdKey} onClick={props.onClick}
onClick={this.props.onClick} >
> {props.children}
{this.props.children} </button>;
</button>; };
}
}
export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deuteranopia" | "filter-protanopia" | "filter-tritanopia"; export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deuteranopia" | "filter-protanopia" | "filter-tritanopia";
@@ -108,26 +100,16 @@ type AppToolbarInternalProps = {
renderer?: string renderer?: string
} & WithTranslation; } & WithTranslation;
class AppToolbarInternal extends React.Component<AppToolbarInternalProps> { const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
state = { function handleSelection(val: MapState) {
isOpen: { props.onSetMapState(val);
settings: false,
sources: false,
open: false,
add: false,
export: false,
}
};
handleSelection(val: MapState) {
this.props.onSetMapState(val);
} }
handleLanguageChange(val: string) { function handleLanguageChange(val: string) {
this.props.i18n.changeLanguage(val); props.i18n.changeLanguage(val);
} }
onSkip = (target: string) => { const onSkip = (target: string) => {
if (target === "map") { if (target === "map") {
(document.querySelector(".maplibregl-canvas") as HTMLCanvasElement).focus(); (document.querySelector(".maplibregl-canvas") as HTMLCanvasElement).focus();
} }
@@ -137,174 +119,171 @@ class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
} }
}; };
render() { const t = props.t;
const t = this.props.t; const views = [
const views = [ {
{ id: "map",
id: "map", group: "general",
group: "general", title: t("Map"),
title: t("Map"), },
}, {
{ id: "inspect",
id: "inspect", group: "general",
group: "general", title: t("Inspect"),
title: t("Inspect"), disabled: props.renderer === "ol",
disabled: this.props.renderer === "ol", },
}, {
{ id: "filter-deuteranopia",
id: "filter-deuteranopia", group: "color-accessibility",
group: "color-accessibility", title: t("Deuteranopia filter"),
title: t("Deuteranopia filter"), disabled: !colorAccessibilityFiltersEnabled,
disabled: !colorAccessibilityFiltersEnabled, },
}, {
{ id: "filter-protanopia",
id: "filter-protanopia", group: "color-accessibility",
group: "color-accessibility", title: t("Protanopia filter"),
title: t("Protanopia filter"), disabled: !colorAccessibilityFiltersEnabled,
disabled: !colorAccessibilityFiltersEnabled, },
}, {
{ id: "filter-tritanopia",
id: "filter-tritanopia", group: "color-accessibility",
group: "color-accessibility", title: t("Tritanopia filter"),
title: t("Tritanopia filter"), disabled: !colorAccessibilityFiltersEnabled,
disabled: !colorAccessibilityFiltersEnabled, },
}, {
{ id: "filter-achromatopsia",
id: "filter-achromatopsia", group: "color-accessibility",
group: "color-accessibility", title: t("Achromatopsia filter"),
title: t("Achromatopsia filter"), disabled: !colorAccessibilityFiltersEnabled,
disabled: !colorAccessibilityFiltersEnabled, },
}, ];
];
const currentView = views.find((view) => { const currentView = views.find((view) => {
return view.id === this.props.mapState; return view.id === props.mapState;
}); });
return <nav className='maputnik-toolbar'> return <nav className='maputnik-toolbar'>
<div className="maputnik-toolbar__inner"> <div className="maputnik-toolbar__inner">
<div <div
className="maputnik-toolbar-logo-container" 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")}
> >
{/* Keyboard accessible quick links */} {t("Layers list")}
<button </button>
data-wd-key="root:skip:layer-list" <button
className="maputnik-toolbar-skip" data-wd-key="root:skip:layer-editor"
onClick={_e => this.onSkip("layer-list")} className="maputnik-toolbar-skip"
> onClick={_e => onSkip("layer-editor")}
{t("Layers list")} >
</button> {t("Layer editor")}
<button </button>
data-wd-key="root:skip:layer-editor" <button
className="maputnik-toolbar-skip" data-wd-key="root:skip:map-view"
onClick={_e => this.onSkip("layer-editor")} className="maputnik-toolbar-skip"
> onClick={_e => onSkip("map")}
{t("Layer editor")} >
</button> {t("Map view")}
<button </button>
data-wd-key="root:skip:map-view" <a
className="maputnik-toolbar-skip" className="maputnik-toolbar-logo"
onClick={_e => this.onSkip("map")} target="blank"
> rel="noreferrer noopener"
{t("Map view")} href="https://github.com/maplibre/maputnik"
</button> >
<a <img src={maputnikLogo} alt={t("Maputnik on GitHub")} />
className="maputnik-toolbar-logo" <h1>
target="blank" <span className="maputnik-toolbar-name">{pkgJson.name}</span>
rel="noreferrer noopener" <span className="maputnik-toolbar-version">v{pkgJson.version}</span>
href="https://github.com/maplibre/maputnik" </h1>
> </a>
<img src={maputnikLogo} alt={t("Maputnik on GitHub")} /> </div>
<h1> <div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar">
<span className="maputnik-toolbar-name">{pkgJson.name}</span> <ToolbarAction wdKey="nav:open" onClick={() => props.onToggleModal("open")}>
<span className="maputnik-toolbar-version">v{pkgJson.version}</span> <MdOpenInBrowser />
</h1> <IconText>{t("Open")}</IconText>
</a> </ToolbarAction>
</div> <ToolbarAction wdKey="nav:export" onClick={() => props.onToggleModal("export")}>
<div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar"> <MdSave />
<ToolbarAction wdKey="nav:open" onClick={() => this.props.onToggleModal("open")}> <IconText>{t("Save")}</IconText>
<MdOpenInBrowser /> </ToolbarAction>
<IconText>{t("Open")}</IconText> <ToolbarAction wdKey="nav:code-editor" onClick={() => props.onToggleModal("codeEditor")}>
</ToolbarAction> <MdCode />
<ToolbarAction wdKey="nav:export" onClick={() => this.props.onToggleModal("export")}> <IconText>{t("Code Editor")}</IconText>
<MdSave /> </ToolbarAction>
<IconText>{t("Save")}</IconText> <ToolbarAction wdKey="nav:sources" onClick={() => props.onToggleModal("sources")}>
</ToolbarAction> <MdLayers />
<ToolbarAction wdKey="nav:code-editor" onClick={() => this.props.onToggleModal("codeEditor")}> <IconText>{t("Data Sources")}</IconText>
<MdCode /> </ToolbarAction>
<IconText>{t("Code Editor")}</IconText> <ToolbarAction wdKey="nav:settings" onClick={() => props.onToggleModal("settings")}>
</ToolbarAction> <MdSettings />
<ToolbarAction wdKey="nav:sources" onClick={() => this.props.onToggleModal("sources")}> <IconText>{t("Style Settings")}</IconText>
<MdLayers /> </ToolbarAction>
<IconText>{t("Data Sources")}</IconText> <ToolbarAction wdKey="nav:global-state" onClick={() => props.onToggleModal("globalState")}>
</ToolbarAction> <MdPublic />
<ToolbarAction wdKey="nav:settings" onClick={() => this.props.onToggleModal("settings")}> <IconText>{t("Global State")}</IconText>
<MdSettings /> </ToolbarAction>
<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"> <ToolbarSelect wdKey="nav:inspect">
<MdFindInPage /> <MdFindInPage />
<IconText>{t("View")} <IconText>{t("View")}
<select <select
className="maputnik-select" className="maputnik-select"
data-wd-key="maputnik-select" data-wd-key="maputnik-select"
onChange={(e) => this.handleSelection(e.target.value as MapState)} onChange={(e) => handleSelection(e.target.value as MapState)}
value={currentView?.id} value={currentView?.id}
> >
{views.filter(v => v.group === "general").map((item) => { {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) => {
return ( return (
<option key={item.id} value={item.id} disabled={item.disabled} data-wd-key={item.id}> <option key={item.id} value={item.id} disabled={item.disabled}>
{item.title} {item.title}
</option> </option>
); );
})} })}
<optgroup label={t("Color accessibility")}> </optgroup>
{views.filter(v => v.group === "color-accessibility").map((item) => { </select>
return ( </IconText>
<option key={item.id} value={item.id} disabled={item.disabled}> </ToolbarSelect>
{item.title}
</option>
);
})}
</optgroup>
</select>
</IconText>
</ToolbarSelect>
<ToolbarSelect wdKey="nav:language"> <ToolbarSelect wdKey="nav:language">
<MdLanguage /> <MdLanguage />
<IconText>Language <IconText>Language
<select <select
className="maputnik-select" className="maputnik-select"
data-wd-key="maputnik-lang-select" data-wd-key="maputnik-lang-select"
onChange={(e) => this.handleLanguageChange(e.target.value)} onChange={(e) => handleLanguageChange(e.target.value)}
value={this.props.i18n.language} value={props.i18n.language}
> >
{Object.entries(supportedLanguages).map(([code, name]) => { {Object.entries(supportedLanguages).map(([code, name]) => {
return ( return (
<option key={code} value={code}> <option key={code} value={code}>
{name} {name}
</option> </option>
); );
})} })}
</select> </select>
</IconText> </IconText>
</ToolbarSelect> </ToolbarSelect>
<ToolbarLink href={"https://github.com/maplibre/maputnik/wiki"}> <ToolbarLink href={"https://github.com/maplibre/maputnik/wiki"}>
<MdHelpOutline /> <MdHelpOutline />
<IconText>{t("Help")}</IconText> <IconText>{t("Help")}</IconText>
</ToolbarLink> </ToolbarLink>
</div>
</div> </div>
</nav>; </div>
} </nav>;
} };
const AppToolbar = withTranslation()(AppToolbarInternal); export const AppToolbar = withTranslation()(AppToolbarInternal);
export default AppToolbar;
+48 -69
View File
@@ -1,7 +1,7 @@
import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent} from "react"; import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent, useRef, useState} from "react";
import classnames from "classnames"; import classnames from "classnames";
import FieldDocLabel from "./FieldDocLabel"; import { FieldDocLabel } from "./FieldDocLabel";
import Doc from "./Doc"; import { Doc } from "./Doc";
export type BlockProps = PropsWithChildren & { export type BlockProps = PropsWithChildren & {
"data-wd-key"?: string "data-wd-key"?: string
@@ -14,32 +14,13 @@ export type BlockProps = PropsWithChildren & {
error?: {message: string} error?: {message: string}
}; };
type BlockState = {
showDoc: boolean
};
/** Wrap a component with a label */ /** Wrap a component with a label */
export default class Block extends React.Component<BlockProps, BlockState> { export const Block: React.FC<BlockProps> = (props) => {
_blockEl: HTMLDivElement | null = null; const [showDoc, setShowDoc] = useState(false);
const blockEl = useRef<HTMLDivElement | null>(null);
constructor (props: BlockProps) { const onToggleDoc = (val: boolean) => {
super(props); setShowDoc(val);
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
});
}; };
/** /**
@@ -48,9 +29,9 @@ export default class Block extends React.Component<BlockProps, BlockState> {
* causing the picker to reopen. This causes a scenario where the picker can * causing the picker to reopen. This causes a scenario where the picker can
* never be closed once open. * never be closed once open.
*/ */
onLabelClick = (event: SyntheticEvent<any, any>) => { const onLabelClick = (event: SyntheticEvent<any, any>) => {
const el = event.nativeEvent.target; const el = event.nativeEvent.target;
const contains = this._blockEl?.contains(el); const contains = blockEl.current?.contains(el);
if (event.nativeEvent.target.nodeName !== "INPUT" && !contains) { if (event.nativeEvent.target.nodeName !== "INPUT" && !contains) {
event.stopPropagation(); event.stopPropagation();
@@ -60,45 +41,43 @@ export default class Block extends React.Component<BlockProps, BlockState> {
} }
}; };
render() { return <label style={props.style}
return <label style={this.props.style} data-wd-key={props["data-wd-key"]}
data-wd-key={this.props["data-wd-key"]} className={classnames({
className={classnames({ "maputnik-input-block": true,
"maputnik-input-block": true, "maputnik-input-block--wide": props.wideMode,
"maputnik-input-block--wide": this.props.wideMode, "maputnik-action-block": props.action,
"maputnik-action-block": this.props.action, "maputnik-input-block--error": props.error
"maputnik-input-block--error": this.props.error })}
})} onClick={onLabelClick}
onClick={this.onLabelClick} >
> {props.fieldSpec &&
{this.props.fieldSpec && <div className="maputnik-input-block-label">
<div className="maputnik-input-block-label"> <FieldDocLabel
<FieldDocLabel label={props.label}
label={this.props.label} onToggleDoc={onToggleDoc}
onToggleDoc={this.onToggleDoc} fieldSpec={props.fieldSpec}
fieldSpec={this.props.fieldSpec} />
/>
</div>
}
{!this.props.fieldSpec &&
<div className="maputnik-input-block-label">
{this.props.label}
</div>
}
<div className="maputnik-input-block-action">
{this.props.action}
</div> </div>
<div className="maputnik-input-block-content" ref={el => {this._blockEl = el;}}> }
{this.props.children} {!props.fieldSpec &&
<div className="maputnik-input-block-label">
{props.label}
</div> </div>
{this.props.fieldSpec && }
<div <div className="maputnik-input-block-action">
className="maputnik-doc-inline" {props.action}
style={{display: this.state.showDoc ? "" : "none"}} </div>
> <div className="maputnik-input-block-content" ref={blockEl}>
<Doc fieldSpec={this.props.fieldSpec} /> {props.children}
</div> </div>
} {props.fieldSpec &&
</label>; <div
} className="maputnik-doc-inline"
} style={{display: showDoc ? "" : "none"}}
>
<Doc fieldSpec={props.fieldSpec} />
</div>
}
</label>;
};
+2 -4
View File
@@ -1,4 +1,4 @@
import InputJson from "./InputJson"; import { InputJson } from "./InputJson";
import React from "react"; import React from "react";
import { withTranslation, type WithTranslation } from "react-i18next"; import { withTranslation, type WithTranslation } from "react-i18next";
import { type StyleSpecification } from "maplibre-gl"; import { type StyleSpecification } from "maplibre-gl";
@@ -24,6 +24,4 @@ const CodeEditorInternal: React.FC<CodeEditorProps> = (props) => {
</>; </>;
}; };
const CodeEditor = withTranslation()(CodeEditorInternal); export const CodeEditor = withTranslation()(CodeEditorInternal);
export default CodeEditor;
+15 -21
View File
@@ -9,25 +9,19 @@ type CollapseProps = {
}; };
export default class Collapse extends React.Component<CollapseProps> { export const Collapse: React.FC<CollapseProps> = ({isActive = true, children}) => {
static defaultProps = { if (reducedMotionEnabled()) {
isActive: true return (
}; <div style={{display: isActive ? "block" : "none"}}>
{children}
render() { </div>
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>
);
}
};
+8 -10
View File
@@ -6,13 +6,11 @@ type CollapserProps = {
style?: object style?: object
}; };
export default class Collapser extends React.Component<CollapserProps> { export const Collapser: React.FC<CollapserProps> = (props) => {
render() { const iconStyle = {
const iconStyle = { width: 20,
width: 20, height: 20,
height: 20, ...props.style,
...this.props.style, };
}; return props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />;
return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />; };
}
}
+371
View File
@@ -0,0 +1,371 @@
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
@@ -0,0 +1,24 @@
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);
+76 -80
View File
@@ -23,88 +23,84 @@ type DocProps = {
} }
}; };
export default class Doc extends React.Component<DocProps> { export const Doc: React.FC<DocProps> = ({fieldSpec}) => {
render () { const {doc, values, docUrl, docUrlLinkText} = fieldSpec;
const {fieldSpec} = this.props; const sdkSupport = fieldSpec["sdk-support"];
const {doc, values, docUrl, docUrlLinkText} = fieldSpec; const renderValues = (
const sdkSupport = fieldSpec["sdk-support"]; !!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 renderValues = ( const sdkSupportToJsx = (value: string) => {
!!values && const supportValue = value.toLowerCase();
// HACK: Currently we merge additional values into the style spec, so this is required if (supportValue.startsWith("https://")) {
// See <https://github.com/maplibre/maputnik/blob/main/src/components/PropertyGroup.jsx#L16> return <a href={supportValue} target="_blank" rel="noreferrer">{"#" + supportValue.split("/").pop()}</a>;
!Array.isArray(values) }
); return value;
};
const sdkSupportToJsx = (value: string) => { return (
const supportValue = value.toLowerCase(); <>
if (supportValue.startsWith("https://")) { {doc &&
return <a href={supportValue} target="_blank" rel="noreferrer">{"#" + supportValue.split("/").pop()}</a>; <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>
} }
return value; {sdkSupport &&
}; <div className="SpecDoc__sdk-support">
<table className="SpecDoc__sdk-support__table">
return ( <thead>
<> <tr>
{doc && <th></th>
<div className="SpecDoc"> {Object.values(headers).map(header => {
<div className="SpecDoc__doc" data-wd-key='spec-field-doc'> return <th key={header}>{header}</th>;
<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> </tr>
} </thead>
</div> <tbody>
} {Object.entries(sdkSupport).map(([key, supportObj]) => {
{sdkSupport && return (
<div className="SpecDoc__sdk-support"> <tr key={key}>
<table className="SpecDoc__sdk-support__table"> <td>{key}</td>
<thead> {Object.keys(headers).map((k) => {
<tr> if (Object.prototype.hasOwnProperty.call(supportObj, k)) {
<th></th> return <td key={k}>{sdkSupportToJsx(supportObj[k as keyof typeof headers])}</td>;
{Object.values(headers).map(header => { }
return <th key={header}>{header}</th>; else {
})} return <td key={k}>no</td>;
</tr> }
</thead> })}
<tbody> </tr>
{Object.entries(sdkSupport).map(([key, supportObj]) => { );
return ( })}
<tr key={key}> </tbody>
<td>{key}</td> </table>
{Object.keys(headers).map((k) => { </div>
if (Object.prototype.hasOwnProperty.call(supportObj, k)) { }
return <td key={k}>{sdkSupportToJsx(supportObj[k as keyof typeof headers])}</td>; {docUrl && docUrlLinkText &&
} <div className="SpecDoc__learn-more">
else { <a href={docUrl} target="_blank" rel="noreferrer">{docUrlLinkText}</a>
return <td key={k}>no</td>; </div>
} }
})} </>
</tr> );
); };
})}
</tbody>
</table>
</div>
}
{docUrl && docUrlLinkText &&
<div className="SpecDoc__learn-more">
<a href={docUrl} target="_blank" rel="noreferrer">{docUrlLinkText}</a>
</div>
}
</>
);
}
}
+85
View File
@@ -0,0 +1,85 @@
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);
+3 -5
View File
@@ -1,5 +1,5 @@
import InputArray, { type InputArrayProps } from "./InputArray"; import { InputArray, type InputArrayProps } from "./InputArray";
import Fieldset from "./Fieldset"; import { Fieldset } from "./Fieldset";
type FieldArrayProps = InputArrayProps & { type FieldArrayProps = InputArrayProps & {
name?: string name?: string
@@ -8,12 +8,10 @@ type FieldArrayProps = InputArrayProps & {
} }
}; };
const FieldArray: React.FC<FieldArrayProps> = (props) => { export const FieldArray: React.FC<FieldArrayProps> = (props) => {
return ( return (
<Fieldset label={props.label} fieldSpec={props.fieldSpec}> <Fieldset label={props.label} fieldSpec={props.fieldSpec}>
<InputArray {...props} /> <InputArray {...props} />
</Fieldset> </Fieldset>
); );
}; };
export default FieldArray;
+3 -5
View File
@@ -1,5 +1,5 @@
import Block from "./Block"; import { Block } from "./Block";
import InputAutocomplete, { type InputAutocompleteProps } from "./InputAutocomplete"; import { InputAutocomplete, type InputAutocompleteProps } from "./InputAutocomplete";
type FieldAutocompleteProps = InputAutocompleteProps & { type FieldAutocompleteProps = InputAutocompleteProps & {
@@ -7,12 +7,10 @@ type FieldAutocompleteProps = InputAutocompleteProps & {
}; };
const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => { export const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => {
return ( return (
<Block label={props.label}> <Block label={props.label}>
<InputAutocomplete {...props} /> <InputAutocomplete {...props} />
</Block> </Block>
); );
}; };
export default FieldAutocomplete;
+3 -5
View File
@@ -1,5 +1,5 @@
import Block from "./Block"; import { Block } from "./Block";
import InputCheckbox, {type InputCheckboxProps} from "./InputCheckbox"; import { InputCheckbox, type InputCheckboxProps } from "./InputCheckbox";
type FieldCheckboxProps = InputCheckboxProps & { type FieldCheckboxProps = InputCheckboxProps & {
@@ -7,12 +7,10 @@ type FieldCheckboxProps = InputCheckboxProps & {
}; };
const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => { export const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => {
return ( return (
<Block label={props.label}> <Block label={props.label}>
<InputCheckbox {...props} /> <InputCheckbox {...props} />
</Block> </Block>
); );
}; };
export default FieldCheckbox;
+3 -5
View File
@@ -1,5 +1,5 @@
import Block from "./Block"; import { Block } from "./Block";
import InputColor, {type InputColorProps} from "./InputColor"; import { InputColor, type InputColorProps } from "./InputColor";
type FieldColorProps = InputColorProps & { type FieldColorProps = InputColorProps & {
@@ -10,12 +10,10 @@ type FieldColorProps = InputColorProps & {
}; };
const FieldColor: React.FC<FieldColorProps> = (props) => { export const FieldColor: React.FC<FieldColorProps> = (props) => {
return ( return (
<Block label={props.label} fieldSpec={props.fieldSpec}> <Block label={props.label} fieldSpec={props.fieldSpec}>
<InputColor {...props} /> <InputColor {...props} />
</Block> </Block>
); );
}; };
export default FieldColor;
+3 -4
View File
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import Block from "./Block"; import { Block } from "./Block";
import InputString from "./InputString"; import { InputString } from "./InputString";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
type FieldCommentInternalProps = { type FieldCommentInternalProps = {
@@ -36,5 +36,4 @@ const FieldCommentInternal: React.FC<FieldCommentInternalProps> = (props) => {
); );
}; };
const FieldComment = withTranslation()(FieldCommentInternal); export const FieldComment = withTranslation()(FieldCommentInternal);
export default FieldComment;
+1 -3
View File
@@ -10,7 +10,7 @@ type FieldDocLabelProps = {
}; };
const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => { export const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const onToggleDoc = (state: boolean) => { const onToggleDoc = (state: boolean) => {
@@ -49,5 +49,3 @@ const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
} }
return <div />; return <div />;
}; };
export default FieldDocLabel;
+3 -5
View File
@@ -1,16 +1,14 @@
import InputDynamicArray, {type InputDynamicArrayProps} from "./InputDynamicArray"; import { InputDynamicArray, type InputDynamicArrayProps } from "./InputDynamicArray";
import Fieldset from "./Fieldset"; import { Fieldset } from "./Fieldset";
type FieldDynamicArrayProps = InputDynamicArrayProps & { type FieldDynamicArrayProps = InputDynamicArrayProps & {
name?: string name?: string
}; };
const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => { export const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => {
return ( return (
<Fieldset label={props.label}> <Fieldset label={props.label}>
<InputDynamicArray {...props} /> <InputDynamicArray {...props} />
</Fieldset> </Fieldset>
); );
}; };
export default FieldDynamicArray;
+3 -5
View File
@@ -1,5 +1,5 @@
import InputEnum, {type InputEnumProps} from "./InputEnum"; import { InputEnum, type InputEnumProps } from "./InputEnum";
import Fieldset from "./Fieldset"; import { Fieldset } from "./Fieldset";
type FieldEnumProps = InputEnumProps & { type FieldEnumProps = InputEnumProps & {
@@ -10,12 +10,10 @@ type FieldEnumProps = InputEnumProps & {
}; };
const FieldEnum: React.FC<FieldEnumProps> = (props) => { export const FieldEnum: React.FC<FieldEnumProps> = (props) => {
return ( return (
<Fieldset label={props.label} fieldSpec={props.fieldSpec}> <Fieldset label={props.label} fieldSpec={props.fieldSpec}>
<InputEnum {...props} /> <InputEnum {...props} />
</Fieldset> </Fieldset>
); );
}; };
export default FieldEnum;
+5 -7
View File
@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import SpecProperty from "./_SpecProperty"; import { SpecProperty } from "./SpecProperty";
import DataProperty, { type Stop } from "./_DataProperty"; import { DataProperty, type Stop } from "./DataProperty";
import ZoomProperty from "./_ZoomProperty"; import { ZoomProperty } from "./ZoomProperty";
import ExpressionProperty from "./_ExpressionProperty"; import { ExpressionProperty } from "./ExpressionProperty";
import {function as styleFunction} from "@maplibre/maplibre-gl-style-spec"; import {function as styleFunction} from "@maplibre/maplibre-gl-style-spec";
import {findDefaultFromSpec} from "../libs/spec-helper"; import {findDefaultFromSpec} from "../libs/spec-helper";
import { type MappedLayerErrors } from "../libs/definitions"; import { type MappedLayerErrors } from "../libs/definitions";
@@ -128,7 +128,7 @@ type FieldFunctionProps = {
/** Supports displaying spec field for zoom function objects /** Supports displaying spec field for zoom function objects
* https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property * https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property
*/ */
const FieldFunction: React.FC<FieldFunctionProps> = (props) => { export const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
const [dataType, setDataType] = React.useState( const [dataType, setDataType] = React.useState(
getDataType(props.value, props.fieldSpec) getDataType(props.value, props.fieldSpec)
); );
@@ -402,5 +402,3 @@ const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
</div> </div>
); );
}; };
export default FieldFunction;
+3 -5
View File
@@ -1,7 +1,7 @@
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import Block from "./Block"; import { Block } from "./Block";
import InputString from "./InputString"; import { InputString } from "./InputString";
type FieldIdProps = { type FieldIdProps = {
value: string value: string
@@ -10,7 +10,7 @@ type FieldIdProps = {
error?: {message: string} error?: {message: string}
}; };
const FieldId: React.FC<FieldIdProps> = (props) => { export const FieldId: React.FC<FieldIdProps> = (props) => {
return ( return (
<Block label="ID" fieldSpec={latest.layer.id} <Block label="ID" fieldSpec={latest.layer.id}
data-wd-key={props.wdKey} data-wd-key={props.wdKey}
@@ -24,5 +24,3 @@ const FieldId: React.FC<FieldIdProps> = (props) => {
</Block> </Block>
); );
}; };
export default FieldId;
+2 -4
View File
@@ -1,11 +1,9 @@
import InputJson, {type InputJsonProps} from "./InputJson"; import { InputJson, type InputJsonProps } from "./InputJson";
type FieldJsonProps = InputJsonProps & {}; type FieldJsonProps = InputJsonProps & {};
const FieldJson: React.FC<FieldJsonProps> = (props) => { export const FieldJson: React.FC<FieldJsonProps> = (props) => {
return <InputJson {...props} />; return <InputJson {...props} />;
}; };
export default FieldJson;
+3 -4
View File
@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import Block from "./Block"; import { Block } from "./Block";
import InputNumber from "./InputNumber"; import { InputNumber } from "./InputNumber";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
type FieldMaxZoomInternalProps = { type FieldMaxZoomInternalProps = {
@@ -31,5 +31,4 @@ const FieldMaxZoomInternal: React.FC<FieldMaxZoomInternalProps> = (props) => {
); );
}; };
const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal); export const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal);
export default FieldMaxZoom;
+3 -4
View File
@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import Block from "./Block"; import { Block } from "./Block";
import InputNumber from "./InputNumber"; import { InputNumber } from "./InputNumber";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
type FieldMinZoomInternalProps = { type FieldMinZoomInternalProps = {
@@ -31,5 +31,4 @@ const FieldMinZoomInternal: React.FC<FieldMinZoomInternalProps> = (props) => {
); );
}; };
const FieldMinZoom = withTranslation()(FieldMinZoomInternal); export const FieldMinZoom = withTranslation()(FieldMinZoomInternal);
export default FieldMinZoom;
+3 -5
View File
@@ -1,5 +1,5 @@
import InputMultiInput, {type InputMultiInputProps} from "./InputMultiInput"; import { InputMultiInput, type InputMultiInputProps } from "./InputMultiInput";
import Fieldset from "./Fieldset"; import { Fieldset } from "./Fieldset";
type FieldMultiInputProps = InputMultiInputProps & { type FieldMultiInputProps = InputMultiInputProps & {
@@ -7,12 +7,10 @@ type FieldMultiInputProps = InputMultiInputProps & {
}; };
const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => { export const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => {
return ( return (
<Fieldset label={props.label}> <Fieldset label={props.label}>
<InputMultiInput {...props} /> <InputMultiInput {...props} />
</Fieldset> </Fieldset>
); );
}; };
export default FieldMultiInput;
+3 -5
View File
@@ -1,5 +1,5 @@
import InputNumber, {type InputNumberProps} from "./InputNumber"; import { InputNumber, type InputNumberProps } from "./InputNumber";
import Block from "./Block"; import { Block } from "./Block";
type FieldNumberProps = InputNumberProps & { type FieldNumberProps = InputNumberProps & {
@@ -10,12 +10,10 @@ type FieldNumberProps = InputNumberProps & {
}; };
const FieldNumber: React.FC<FieldNumberProps> = (props) => { export const FieldNumber: React.FC<FieldNumberProps> = (props) => {
return ( return (
<Block label={props.label} fieldSpec={props.fieldSpec}> <Block label={props.label} fieldSpec={props.fieldSpec}>
<InputNumber {...props} /> <InputNumber {...props} />
</Block> </Block>
); );
}; };
export default FieldNumber;
+3 -5
View File
@@ -1,5 +1,5 @@
import Block from "./Block"; import { Block } from "./Block";
import InputSelect, {type InputSelectProps} from "./InputSelect"; import { InputSelect, type InputSelectProps } from "./InputSelect";
type FieldSelectProps = InputSelectProps & { type FieldSelectProps = InputSelectProps & {
@@ -10,12 +10,10 @@ type FieldSelectProps = InputSelectProps & {
}; };
const FieldSelect: React.FC<FieldSelectProps> = (props) => { export const FieldSelect: React.FC<FieldSelectProps> = (props) => {
return ( return (
<Block label={props.label} fieldSpec={props.fieldSpec}> <Block label={props.label} fieldSpec={props.fieldSpec}>
<InputSelect {...props} /> <InputSelect {...props} />
</Block> </Block>
); );
}; };
export default FieldSelect;
+3 -4
View File
@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import Block from "./Block"; import { Block } from "./Block";
import InputAutocomplete from "./InputAutocomplete"; import { InputAutocomplete } from "./InputAutocomplete";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
type FieldSourceInternalProps = { type FieldSourceInternalProps = {
@@ -38,5 +38,4 @@ const FieldSourceInternal: React.FC<FieldSourceInternalProps> = ({
}; };
const FieldSource = withTranslation()(FieldSourceInternal); export const FieldSource = withTranslation()(FieldSourceInternal);
export default FieldSource;
+3 -4
View File
@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import {latest} from "@maplibre/maplibre-gl-style-spec"; import {latest} from "@maplibre/maplibre-gl-style-spec";
import Block from "./Block"; import { Block } from "./Block";
import InputAutocomplete from "./InputAutocomplete"; import { InputAutocomplete } from "./InputAutocomplete";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
type FieldSourceLayerInternalProps = { type FieldSourceLayerInternalProps = {
@@ -35,5 +35,4 @@ const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = ({
); );
}; };
const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal); export const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
export default FieldSourceLayer;
+4 -6
View File
@@ -1,6 +1,6 @@
import Block, { type BlockProps } from "./Block"; import { Block, type BlockProps } from "./Block";
import InputSpec, { type FieldSpecType, type InputSpecProps } from "./InputSpec"; import { InputSpec, type FieldSpecType, type InputSpecProps } from "./InputSpec";
import Fieldset, { type FieldsetProps } from "./Fieldset"; import { Fieldset, type FieldsetProps } from "./Fieldset";
function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[] }): typeof Fieldset | typeof Block { function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[] }): typeof Fieldset | typeof Block {
switch(fieldSpec.type) { switch(fieldSpec.type) {
@@ -36,7 +36,7 @@ function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[
export type FieldSpecProps = InputSpecProps & BlockProps & FieldsetProps; export type FieldSpecProps = InputSpecProps & BlockProps & FieldsetProps;
const FieldSpec: React.FC<FieldSpecProps> = (props) => { export const FieldSpec: React.FC<FieldSpecProps> = (props) => {
const TypeBlock = getElementFromType(props.fieldSpec!); const TypeBlock = getElementFromType(props.fieldSpec!);
return ( return (
@@ -45,5 +45,3 @@ const FieldSpec: React.FC<FieldSpecProps> = (props) => {
</TypeBlock> </TypeBlock>
); );
}; };
export default FieldSpec;
+3 -5
View File
@@ -1,5 +1,5 @@
import Block from "./Block"; import { Block } from "./Block";
import InputString, {type InputStringProps} from "./InputString"; import { InputString, type InputStringProps } from "./InputString";
type FieldStringProps = InputStringProps & { type FieldStringProps = InputStringProps & {
name?: string name?: string
@@ -9,12 +9,10 @@ type FieldStringProps = InputStringProps & {
} }
}; };
const FieldString: React.FC<FieldStringProps> = (props) => { export const FieldString: React.FC<FieldStringProps> = (props) => {
return ( return (
<Block label={props.label} fieldSpec={props.fieldSpec}> <Block label={props.label} fieldSpec={props.fieldSpec}>
<InputString {...props} /> <InputString {...props} />
</Block> </Block>
); );
}; };
export default FieldString;
+4 -5
View File
@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import {v8} from "@maplibre/maplibre-gl-style-spec"; import {v8} from "@maplibre/maplibre-gl-style-spec";
import Block from "./Block"; import { Block } from "./Block";
import InputSelect from "./InputSelect"; import { InputSelect } from "./InputSelect";
import InputString from "./InputString"; import { InputString } from "./InputString";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { startCase } from "lodash"; import { startCase } from "lodash";
@@ -43,5 +43,4 @@ const FieldTypeInternal: React.FC<FieldTypeInternalProps> = ({
); );
}; };
const FieldType = withTranslation()(FieldTypeInternal); export const FieldType = withTranslation()(FieldTypeInternal);
export default FieldType;
+3 -5
View File
@@ -1,5 +1,5 @@
import InputUrl, {type FieldUrlProps as InputUrlProps} from "./InputUrl"; import { InputUrl, type FieldUrlProps as InputUrlProps } from "./InputUrl";
import Block from "./Block"; import { Block } from "./Block";
type FieldUrlProps = InputUrlProps & { type FieldUrlProps = InputUrlProps & {
@@ -10,12 +10,10 @@ type FieldUrlProps = InputUrlProps & {
}; };
const FieldUrl: React.FC<FieldUrlProps> = (props) => { export const FieldUrl: React.FC<FieldUrlProps> = (props) => {
return ( return (
<Block label={props.label} fieldSpec={props.fieldSpec}> <Block label={props.label} fieldSpec={props.fieldSpec}>
<InputUrl {...props} /> <InputUrl {...props} />
</Block> </Block>
); );
}; };
export default FieldUrl;
+4 -6
View File
@@ -1,8 +1,8 @@
import React, { type PropsWithChildren, type ReactElement } from "react"; import React, { type PropsWithChildren, type ReactElement } from "react";
import classnames from "classnames"; import classnames from "classnames";
import FieldDocLabel from "./FieldDocLabel"; import { FieldDocLabel } from "./FieldDocLabel";
import Doc from "./Doc"; import { Doc } from "./Doc";
import generateUniqueId from "../libs/document-uid"; import { generateUniqueId } from "../libs/document-uid";
export type FieldsetProps = PropsWithChildren & { export type FieldsetProps = PropsWithChildren & {
label?: string, label?: string,
@@ -12,7 +12,7 @@ export type FieldsetProps = PropsWithChildren & {
}; };
const Fieldset: React.FC<FieldsetProps> = (props) => { export const Fieldset: React.FC<FieldsetProps> = (props) => {
const [showDoc, setShowDoc] = React.useState(false); const [showDoc, setShowDoc] = React.useState(false);
const labelId = React.useRef(generateUniqueId("fieldset_label_")); const labelId = React.useRef(generateUniqueId("fieldset_label_"));
@@ -49,5 +49,3 @@ const Fieldset: React.FC<FieldsetProps> = (props) => {
</div> </div>
); );
}; };
export default Fieldset;
+150 -181
View File
@@ -1,4 +1,4 @@
import React from "react"; import React, { useState } from "react";
import { TbMathFunction } from "react-icons/tb"; import { TbMathFunction } from "react-icons/tb";
import { PiListPlusBold } from "react-icons/pi"; import { PiListPlusBold } from "react-icons/pi";
import {isEqual} from "lodash"; 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 latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import {combiningFilterOps} from "../libs/filterops"; import {combiningFilterOps} from "../libs/filterops";
import InputSelect from "./InputSelect"; import { InputSelect } from "./InputSelect";
import Block from "./Block"; import { Block } from "./Block";
import SingleFilterEditor from "./SingleFilterEditor"; import { SingleFilterEditor } from "./SingleFilterEditor";
import FilterEditorBlock from "./FilterEditorBlock"; import { FilterEditorBlock } from "./FilterEditorBlock";
import InputButton from "./InputButton"; import { InputButton } from "./InputButton";
import Doc from "./Doc"; import { Doc } from "./Doc";
import ExpressionProperty from "./_ExpressionProperty"; import { ExpressionProperty } from "./ExpressionProperty";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import type { MappedLayerErrors, StyleSpecificationWithId } from "../libs/definitions"; import type { MappedLayerErrors, StyleSpecificationWithId } from "../libs/definitions";
@@ -100,221 +100,190 @@ type FilterEditorInternalProps = {
onChange(value: LegacyFilterSpecification | ExpressionSpecification): void onChange(value: LegacyFilterSpecification | ExpressionSpecification): void
} & WithTranslation; } & WithTranslation;
type FilterEditorState = { const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["all"], ...rest }) => {
showDoc: boolean const props = { filter, ...rest } as FilterEditorInternalProps;
displaySimpleFilter: boolean
valueIsSimpleFilter?: boolean
};
class FilterEditorInternal extends React.Component<FilterEditorInternalProps, FilterEditorState> { // Nothing ever toggles this: the Block below renders its own documentation
static defaultProps = { // toggle, so this component's inline doc panel stays hidden (as it did before).
filter: ["all"], const [showDoc] = useState(false);
}; const [displaySimpleFilter, setDisplaySimpleFilter] = useState(() =>
checkIfSimpleFilter(combiningFilter(props))
);
constructor (props: FilterEditorInternalProps) { // Replaces getDerivedStateFromProps. "Upgrade but never downgrade": once the
super(props); // filter stops being expressible in the simple editor, switch to the
this.state = { // expression editor and stay there.
showDoc: false, const isSimpleFilter = checkIfSimpleFilter(combiningFilter(props));
displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)), if (!isSimpleFilter && displaySimpleFilter) {
}; setDisplaySimpleFilter(false);
} }
// 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 // Convert filter to combining filter
onFilterPartChanged(filterIdx: number, newPart: any[]) { function onFilterPartChanged(filterIdx: number, newPart: any[]) {
const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilter = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
newFilter[filterIdx] = newPart; newFilter[filterIdx] = newPart;
this.props.onChange(newFilter); props.onChange(newFilter);
} }
deleteFilterItem(filterIdx: number) { function deleteFilterItem(filterIdx: number) {
const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilter = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
newFilter.splice(filterIdx + 1, 1); newFilter.splice(filterIdx + 1, 1);
this.props.onChange(newFilter); props.onChange(newFilter);
} }
addFilterItem = () => { const addFilterItem = () => {
const newFilterItem = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilterItem = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
(newFilterItem as any[]).push(["==", "name", ""]); (newFilterItem as any[]).push(["==", "name", ""]);
this.props.onChange(newFilterItem); props.onChange(newFilterItem);
}; };
onToggleDoc = (val: boolean) => { const makeFilter = () => {
this.setState({ setDisplaySimpleFilter(true);
showDoc: val
});
}; };
makeFilter = () => { const makeExpression = () => {
this.setState({ const currentFilter = combiningFilter(props);
displaySimpleFilter: true, props.onChange(migrateFilter(currentFilter));
}); setDisplaySimpleFilter(false);
}; };
makeExpression = () => {
const filter = combiningFilter(this.props); const {errors, t} = props;
this.props.onChange(migrateFilter(filter)); const fieldSpec={
this.setState({ doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
displaySimpleFilter: false,
});
}; };
const defaultFilter = ["all"] as LegacyFilterSpecification | ExpressionSpecification;
static getDerivedStateFromProps(props: Readonly<FilterEditorInternalProps>, state: FilterEditorState) { const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(props));
const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props));
// Upgrade but never downgrade if (isNestedCombiningFilter) {
if (!displaySimpleFilter && state.displaySimpleFilter === true) { return <div className="maputnik-filter-editor-unsupported">
return { <p>
displaySimpleFilter: false, {t("Nested filters are not supported.")}
valueIsSimpleFilter: false, </p>
}; <InputButton
} onClick={makeExpression}
else if (displaySimpleFilter && state.displaySimpleFilter === false) { title={t("Convert to expression")}
return { >
valueIsSimpleFilter: true, <TbMathFunction />
}; {t("Upgrade to expression")}
} </InputButton>
else { </div>;
return {
valueIsSimpleFilter: false,
};
}
} }
else if (displaySimpleFilter) {
const filter = combiningFilter(props);
const combiningOp = filter[0];
const filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[];
render() { const actions = (
const {errors, t} = this.props; <div>
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 <InputButton
onClick={this.makeExpression} onClick={makeExpression}
title={t("Convert to expression")} title={t("Convert to expression")}
className="maputnik-make-zoom-function"
data-wd-key="filter-convert-to-expression"
> >
<TbMathFunction /> <TbMathFunction />
{t("Upgrade to expression")}
</InputButton> </InputButton>
</div>; </div>
} );
else if (displaySimpleFilter) {
const filter = combiningFilter(this.props);
const combiningOp = filter[0];
const filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[];
const actions = ( const editorBlocks = filters.map((f, idx) => {
<div> const error = errors![`filter[${idx+1}]`];
<InputButton
onClick={this.makeExpression} return (
title={t("Convert to expression")} <div key={`block-${idx}`}>
className="maputnik-make-zoom-function" <FilterEditorBlock key={idx} onDelete={deleteFilterItem.bind(null, idx)}>
> <SingleFilterEditor
<TbMathFunction /> properties={props.properties}
</InputButton> filter={f}
onChange={onFilterPartChanged.bind(null, idx + 1)}
/>
</FilterEditorBlock>
{error &&
<div key="error" className="maputnik-inline-error">{error.message}</div>
}
</div> </div>
); );
});
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 ( return (
<> <>
<Block <Block
key="top" key="top"
fieldSpec={fieldSpec} fieldSpec={fieldSpec}
label={t("Filter")} label={t("Filter")}
action={actions} action={actions}
> data-wd-key="filter-combining-operator"
<InputSelect >
value={combiningOp} <InputSelect
onChange={(v: [string, any]) => this.onFilterPartChanged(0, v)} value={combiningOp}
options={[ onChange={(v: [string, any]) => onFilterPartChanged(0, v)}
["all", t("every filter matches")], options={[
["none", t("no filter matches")], ["all", t("every filter matches")],
["any", t("any 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 && </Block>
{editorBlocks}
<div
key="buttons"
className="maputnik-filter-editor-add-wrapper"
>
<InputButton
data-wd-key="layer-filter-button"
className="maputnik-add-filter"
onClick={addFilterItem}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add filter")}
</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 &&
<div className="maputnik-expr-infobox"> <div className="maputnik-expr-infobox">
{t("You've entered an old style filter.")} {t("You've entered an old style filter.")}
{" "} {" "}
<button <button
onClick={this.makeFilter} onClick={makeFilter}
className="maputnik-expr-infobox__button" className="maputnik-expr-infobox__button"
> >
{t("Switch to filter editor.")} {t("Switch to filter editor.")}
</button> </button>
</div> </div>
} }
</> </>
); );
}
} }
} };
const FilterEditor = withTranslation()(FilterEditorInternal); export const FilterEditor = withTranslation()(FilterEditorInternal);
export default FilterEditor;
+19 -22
View File
@@ -1,5 +1,5 @@
import React, { type PropsWithChildren } from "react"; import React, { type PropsWithChildren } from "react";
import InputButton from "./InputButton"; import { InputButton } from "./InputButton";
import {MdDelete} from "react-icons/md"; import {MdDelete} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
@@ -7,25 +7,22 @@ type FilterEditorBlockInternalProps = PropsWithChildren & {
onDelete(...args: unknown[]): unknown onDelete(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
class FilterEditorBlockInternal extends React.Component<FilterEditorBlockInternalProps> { const FilterEditorBlockInternal: React.FC<FilterEditorBlockInternalProps> = (props) => {
render() { const t = props.t;
const t = this.props.t; return <div className="maputnik-filter-editor-block">
return <div className="maputnik-filter-editor-block"> <div className="maputnik-filter-editor-block-content">
<div className="maputnik-filter-editor-block-content"> {props.children}
{this.props.children} </div>
</div> <div className="maputnik-filter-editor-block-action">
<div className="maputnik-filter-editor-block-action"> <InputButton
<InputButton className="maputnik-icon-button"
className="maputnik-icon-button" onClick={props.onDelete}
onClick={this.props.onDelete} title={t("Delete filter block")}
title={t("Delete filter block")} >
> <MdDelete />
<MdDelete /> </InputButton>
</InputButton> </div>
</div> </div>;
</div>; };
}
}
const FilterEditorBlock = withTranslation()(FilterEditorBlockInternal); export const FilterEditorBlock = withTranslation()(FilterEditorBlockInternal);
export default FilterEditorBlock;
+68
View File
@@ -0,0 +1,68 @@
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);
+1 -3
View File
@@ -13,7 +13,7 @@ type IconLayerProps = {
className?: string className?: string
}; };
const IconLayer: React.FC<IconLayerProps> = (props) => { export const IconLayer: React.FC<IconLayerProps> = (props) => {
const iconProps = { style: props.style }; const iconProps = { style: props.style };
switch(props.type) { switch(props.type) {
case "fill-extrusion": return <IoMdCube {...iconProps} />; case "fill-extrusion": return <IoMdCube {...iconProps} />;
@@ -29,5 +29,3 @@ const IconLayer: React.FC<IconLayerProps> = (props) => {
default: return <MdPriorityHigh {...iconProps} />; default: return <MdPriorityHigh {...iconProps} />;
} }
}; };
export default IconLayer;
+71 -105
View File
@@ -1,6 +1,6 @@
import React from "react"; import React, { useState } from "react";
import InputString from "./InputString"; import { InputString } from "./InputString";
import InputNumber from "./InputNumber"; import { InputNumber } from "./InputNumber";
export type InputArrayProps = { export type InputArrayProps = {
value: (string | number | undefined)[] value: (string | number | undefined)[]
@@ -12,106 +12,72 @@ export type InputArrayProps = {
label?: string label?: string
}; };
type InputArrayState = { export const InputArray: React.FC<InputArrayProps> = ({
value: (string | number | undefined)[] value: propsValue = [],
initialPropsValue: unknown[] default: propsDefault = [],
...rest
}) => {
const props = { value: propsValue, default: propsDefault, ...rest };
// 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));
function isComplete(val: unknown[]) {
return Array(props.length).fill(null).every((_, i) => {
const v = val[i];
return !(v === undefined || v === "");
});
}
function changeValue(idx: number, newValue: string | number | undefined) {
const nextValue = value.slice(0);
nextValue[idx] = newValue;
setValue(nextValue);
if (isComplete(nextValue) && props.onChange) {
props.onChange(nextValue);
}
else if (props.onChange) {
// Unset until complete
props.onChange(undefined);
}
}
const containsValues = (
value.length > 0 &&
!value.every(val => {
return (val === "" || val === undefined);
})
);
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}
/>;
}
});
return (
<div className="maputnik-array">
{inputs}
</div>
);
}; };
export default class InputArray extends React.Component<InputArrayProps, InputArrayState> {
static defaultProps = {
value: [],
default: [],
};
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 === "");
});
}
changeValue(idx: number, newValue: string | number | undefined) {
const value = this.state.value.slice(0);
value[idx] = newValue;
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);
}
});
}
render() {
const {value} = this.state;
const containsValues = (
value.length > 0 &&
!value.every(val => {
return (val === "" || val === undefined);
})
);
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>
);
}
}
@@ -1,24 +0,0 @@
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";
import InputAutocomplete from "./InputAutocomplete";
const fruits = ["apple", "banana", "cherry"];
test("filters options when typing", async () => {
render(<InputAutocomplete aria-label="Fruit" options={fruits.map((f) => [f, f])} />);
const input = page.getByLabelText("Fruit");
await input.click();
const menuItems = page.getByRole("option");
await expect.element(menuItems.first()).toBeVisible();
expect(menuItems.all()).toHaveLength(3);
await input.fill("ch");
await expect.element(page.getByText("cherry")).toBeVisible();
expect(page.getByRole("option").all()).toHaveLength(1);
await page.getByText("cherry").click();
await expect.element(input).toHaveValue("cherry");
});
+1 -1
View File
@@ -11,7 +11,7 @@ export type InputAutocompleteProps = {
"aria-label"?: string "aria-label"?: string
}; };
export default function InputAutocomplete({ export function InputAutocomplete({
value, value,
options = [], options = [],
onChange = () => {}, onChange = () => {},
+15 -17
View File
@@ -14,20 +14,18 @@ type InputButtonProps = {
title?: string title?: string
}; };
export default class InputButton extends React.Component<InputButtonProps> { export const InputButton: React.FC<InputButtonProps> = (props) => {
render() { return <button
return <button id={props.id}
id={this.props.id} title={props.title}
title={this.props.title} type={props.type}
type={this.props.type} onClick={props.onClick}
onClick={this.props.onClick} disabled={props.disabled}
disabled={this.props.disabled} aria-label={props["aria-label"]}
aria-label={this.props["aria-label"]} className={classnames("maputnik-button", props.className)}
className={classnames("maputnik-button", this.props.className)} data-wd-key={props["data-wd-key"]}
data-wd-key={this.props["data-wd-key"]} style={props.style}
style={this.props.style} >
> {props.children}
{this.props.children} </button>;
</button>; };
}
}
+21 -27
View File
@@ -6,32 +6,26 @@ export type InputCheckboxProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
}; };
export default class InputCheckbox extends React.Component<InputCheckboxProps> { export const InputCheckbox: React.FC<InputCheckboxProps> = ({value = false, ...props}) => {
static defaultProps = { const onChange = () => {
value: false, props.onChange(!value);
}; };
onChange = () => { return <div className="maputnik-checkbox-wrapper">
this.props.onChange(!this.props.value); <input
}; className="maputnik-checkbox"
type="checkbox"
render() { style={props.style}
return <div className="maputnik-checkbox-wrapper"> onChange={onChange}
<input onClick={onChange}
className="maputnik-checkbox" checked={value}
type="checkbox" />
style={this.props.style} <div className="maputnik-checkbox-box">
onChange={this.onChange} <svg style={{
onClick={this.onChange} display: value ? "inline" : "none"
checked={this.props.value} }} className="maputnik-checkbox-icon" viewBox='0 0 32 32'>
/> <path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' />
<div className="maputnik-checkbox-box"> </svg>
<svg style={{ </div>
display: this.props.value ? "inline" : "none" </div>;
}} 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>;
}
}
+76 -77
View File
@@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import Color from "color"; import Color from "color";
import ChromePicker from "react-color/lib/components/chrome/Chrome"; import ChromePicker from "react-color/lib/components/chrome/Chrome";
import {type ColorResult} from "react-color"; import {type ColorResult} from "react-color";
@@ -20,26 +20,27 @@ export type InputColorProps = {
}; };
/*** Number fields with support for min, max and units and documentation*/ /*** Number fields with support for min, max and units and documentation*/
export default class InputColor extends React.Component<InputColorProps> { export const InputColor: React.FC<InputColorProps> = (props) => {
state = { const [pickerOpened, setPickerOpened] = useState(false);
pickerOpened: false const colorInput = useRef<HTMLInputElement | null>(null);
};
colorInput: HTMLInputElement | null = null;
constructor (props: InputColorProps) { // Keep the latest `onChange` available to the throttled callback, which is
super(props); // created only once so that throttling actually takes effect.
this.onChangeNoCheck = lodash.throttle(this.onChangeNoCheck, 1000/30); const onChangeProp = useRef(props.onChange);
} useEffect(() => {
onChangeProp.current = props.onChange;
});
onChangeNoCheck(v: string) { const onChangeNoCheck = useMemo(
this.props.onChange(v); () => lodash.throttle((v: string) => onChangeProp.current(v), 1000/30),
} []
);
//TODO: I much rather would do this with absolute positioning //TODO: I much rather would do this with absolute positioning
//but I am too stupid to get it to work together with fixed position //but I am too stupid to get it to work together with fixed position
//and scrollbars so I have to fallback to JavaScript //and scrollbars so I have to fallback to JavaScript
calcPickerOffset = () => { const calcPickerOffset = () => {
const elem = this.colorInput; const elem = colorInput.current;
if(elem) { if(elem) {
const pos = elem.getBoundingClientRect(); const pos = elem.getBoundingClientRect();
return { return {
@@ -54,82 +55,80 @@ export default class InputColor extends React.Component<InputColorProps> {
} }
}; };
togglePicker = () => { const togglePicker = () => {
this.setState({ pickerOpened: !this.state.pickerOpened }); setPickerOpened(opened => !opened);
}; };
get color() { const getColor = () => {
// Catch invalid color. // Catch invalid color.
try { try {
return Color(this.props.value).rgb(); return Color(props.value).rgb();
} }
catch(err) { catch(err) {
console.warn("Error parsing color: ", err); console.warn("Error parsing color: ", err);
return Color("rgb(255,255,255)"); return Color("rgb(255,255,255)");
} }
} };
onChange (v: string) { const onChange = (v: string) => {
this.props.onChange(v === "" ? undefined : v); props.onChange(v === "" ? undefined : v);
} };
render() { const offset = calcPickerOffset();
const offset = this.calcPickerOffset(); const currentColor = getColor().object();
const currentColor = this.color.object(); const currentChromeColor = {
const currentChromeColor = { r: currentColor.r,
r: currentColor.r, g: currentColor.g,
g: currentColor.g, b: currentColor.b,
b: currentColor.b, // Rename alpha -> a for ChromePicker
// Rename alpha -> a for ChromePicker a: currentColor.alpha!
a: currentColor.alpha! };
};
const picker = <div const picker = <div
className="maputnik-color-picker-offset"
style={{
position: "fixed",
zIndex: 1,
left: offset.left,
top: offset.top,
}}>
<ChromePicker
color={currentChromeColor}
onChange={c => onChangeNoCheck(formatColor(c))}
/>
<div
className="maputnik-color-picker-offset" className="maputnik-color-picker-offset"
onClick={togglePicker}
style={{ style={{
zIndex: -1,
position: "fixed", position: "fixed",
zIndex: 1, top: "0px",
left: offset.left, right: "0px",
top: offset.top, bottom: "0px",
}}> left: "0px",
<ChromePicker }}
color={currentChromeColor} />
onChange={c => this.onChangeNoCheck(formatColor(c))} </div>;
/>
<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 = { const swatchStyle = {
backgroundColor: this.props.value backgroundColor: props.value
}; };
return <div className="maputnik-color-wrapper"> return <div className="maputnik-color-wrapper">
{this.state.pickerOpened && picker} {pickerOpened && picker}
<div className="maputnik-color-swatch" style={swatchStyle}></div> <div className="maputnik-color-swatch" style={swatchStyle}></div>
<input <input
aria-label={this.props["aria-label"]} aria-label={props["aria-label"]}
spellCheck="false" spellCheck="false"
autoComplete="off" autoComplete="off"
className="maputnik-color" className="maputnik-color"
ref={(input) => {this.colorInput = input;}} ref={colorInput}
onClick={this.togglePicker} onClick={togglePicker}
style={this.props.style} style={props.style}
name={this.props.name} name={props.name}
placeholder={this.props.default} placeholder={props.default}
value={this.props.value ? this.props.value : ""} value={props.value ? props.value : ""}
onChange={(e) => this.onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
</div>; </div>;
} };
}
+120 -128
View File
@@ -3,13 +3,13 @@ import capitalize from "lodash.capitalize";
import {MdDelete} from "react-icons/md"; import {MdDelete} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import InputString from "./InputString"; import { InputString } from "./InputString";
import InputNumber from "./InputNumber"; import { InputNumber } from "./InputNumber";
import InputButton from "./InputButton"; import { InputButton } from "./InputButton";
import FieldDocLabel from "./FieldDocLabel"; import { FieldDocLabel } from "./FieldDocLabel";
import InputEnum from "./InputEnum"; import { InputEnum } from "./InputEnum";
import InputUrl from "./InputUrl"; import { InputUrl } from "./InputUrl";
import InputColor from "./InputColor"; import { InputColor } from "./InputColor";
export type InputDynamicArrayProps = { export type InputDynamicArrayProps = {
@@ -27,138 +27,130 @@ export type InputDynamicArrayProps = {
type InputDynamicArrayInternalProps = InputDynamicArrayProps & WithTranslation; type InputDynamicArrayInternalProps = InputDynamicArrayProps & WithTranslation;
class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInternalProps> { const InputDynamicArrayInternal: React.FC<InputDynamicArrayInternalProps> = (props) => {
changeValue(idx: number, newValue: string | number | undefined) { const values = props.value || props.default || [];
const values = this.values.slice(0);
values[idx] = newValue;
if (this.props.onChange) this.props.onChange(values);
}
get values() { const changeValue = (idx: number, newValue: string | number | undefined) => {
return this.props.value || this.props.default || []; const newValues = values.slice(0);
} newValues[idx] = newValue;
if (props.onChange) props.onChange(newValues);
addValue = () => {
const values = this.values.slice(0);
if (this.props.type === "number") {
values.push(0);
}
else if (this.props.type === "url") {
values.push("");
}
else if (this.props.type === "enum") {
const {fieldSpec} = this.props;
const defaultValue = Object.keys(fieldSpec!.values)[0];
values.push(defaultValue);
} else if (this.props.type === "color") {
values.push("#000000");
} else {
values.push("");
}
if (this.props.onChange) this.props.onChange(values);
}; };
deleteValue(valueIdx: number) { const addValue = () => {
const values = this.values.slice(0); const newValues = values.slice(0);
values.splice(valueIdx, 1); if (props.type === "number") {
newValues.push(0);
}
else if (props.type === "url") {
newValues.push("");
}
else if (props.type === "enum") {
const {fieldSpec} = props;
const defaultValue = Object.keys(fieldSpec!.values)[0];
newValues.push(defaultValue);
} else if (props.type === "color") {
newValues.push("#000000");
} else {
newValues.push("");
}
if (this.props.onChange) this.props.onChange(values.length > 0 ? values : undefined); if (props.onChange) props.onChange(newValues);
} };
render() { const deleteValue = (valueIdx: number) => {
const t = this.props.t; const newValues = values.slice(0);
const i18nProps = { t, i18n: this.props.i18n, tReady: this.props.tReady }; newValues.splice(valueIdx, 1);
const inputs = this.values.map((v, i) => {
const deleteValueBtn= <DeleteValueInputButton if (props.onChange) props.onChange(newValues.length > 0 ? newValues : undefined);
onClick={this.deleteValue.bind(this, i)} };
{...i18nProps}
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}
/>; />;
let input; }
if(this.props.type === "url") { else if (props.type === "number") {
input = <InputUrl input = <InputNumber
value={v as string} value={v as number}
onChange={this.changeValue.bind(this, i)} onChange={changeValue.bind(null, i)}
aria-label={this.props["aria-label"] || this.props.label} aria-label={props["aria-label"] || props.label}
/>; />;
} }
else if (this.props.type === "number") { else if (props.type === "enum") {
input = <InputNumber const options = Object.keys(props.fieldSpec?.values).map(v => [v, capitalize(v)]);
value={v as number} input = <InputEnum
onChange={this.changeValue.bind(this, i)} options={options}
aria-label={this.props["aria-label"] || this.props.label} value={v as string}
/>; onChange={changeValue.bind(null, i)}
} aria-label={props["aria-label"] || props.label}
else if (this.props.type === "enum") { />;
const options = Object.keys(this.props.fieldSpec?.values).map(v => [v, capitalize(v)]); }
input = <InputEnum else if (props.type === "color") {
options={options} input = <InputColor
value={v as string} value={v as string}
onChange={this.changeValue.bind(this, i)} onChange={changeValue.bind(null, i)}
aria-label={this.props["aria-label"] || this.props.label} aria-label={props["aria-label"] || props.label}
/>; />;
} }
else if (this.props.type === "color") { else {
input = <InputColor input = <InputString
value={v as string} value={v as string}
onChange={this.changeValue.bind(this, i)} onChange={changeValue.bind(null, i)}
aria-label={this.props["aria-label"] || this.props.label} aria-label={props["aria-label"] || 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 return <div
style={this.props.style} style={props.style}
key={i} key={i}
className="maputnik-array-block" className="maputnik-array-block"
> >
<div className="maputnik-array-block-action"> <div className="maputnik-array-block-action">
{deleteValueBtn} {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> </div>
); <div className="maputnik-array-block-content">
} {input}
} </div>
</div>;
});
const InputDynamicArray = withTranslation()(InputDynamicArrayInternal); return (
export default InputDynamicArray; <div className="maputnik-array">
{inputs}
<InputButton
className="maputnik-array-add-value"
onClick={addValue}
>
{t("Add value")}
</InputButton>
</div>
);
};
export const InputDynamicArray = withTranslation()(InputDynamicArrayInternal);
type DeleteValueInputButtonProps = { type DeleteValueInputButtonProps = {
onClick?(...args: unknown[]): unknown onClick?(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
class DeleteValueInputButton extends React.Component<DeleteValueInputButtonProps> { const DeleteValueInputButton: React.FC<DeleteValueInputButtonProps> = (props) => {
render() { const t = props.t;
const t = this.props.t; return <InputButton
return <InputButton className="maputnik-delete-stop"
className="maputnik-delete-stop" onClick={props.onClick}
onClick={this.props.onClick} title={t("Remove array item")}
title={t("Remove array item")} >
> <FieldDocLabel
<FieldDocLabel label={<MdDelete />}
label={<MdDelete />} />
/> </InputButton>;
</InputButton>; };
}
}
+20 -22
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import InputSelect from "./InputSelect"; import { InputSelect } from "./InputSelect";
import InputMultiInput from "./InputMultiInput"; import { InputMultiInput } from "./InputMultiInput";
function optionsLabelLength(options: any[]) { function optionsLabelLength(options: any[]) {
@@ -25,25 +25,23 @@ export type InputEnumProps = {
}; };
export default class InputEnum extends React.Component<InputEnumProps> { export const InputEnum: React.FC<InputEnumProps> = (props) => {
render() { const {options, value, onChange, name, label} = props;
const {options, value, onChange, name, label} = this.props;
if(options.length <= 3 && optionsLabelLength(options) <= 20) { if(options.length <= 3 && optionsLabelLength(options) <= 20) {
return <InputMultiInput return <InputMultiInput
name={name} name={name}
options={options} options={options}
value={(value || this.props.default)!} value={(value || props.default)!}
onChange={onChange} onChange={onChange}
aria-label={this.props["aria-label"] || label} aria-label={props["aria-label"] || label}
/>; />;
} else { } else {
return <InputSelect return <InputSelect
options={options} options={options}
value={(value || this.props.default)!} value={(value || props.default)!}
onChange={onChange} onChange={onChange}
aria-label={this.props["aria-label"] || label} aria-label={props["aria-label"] || label}
/>; />;
}
} }
} };
+29 -33
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import InputAutocomplete from "./InputAutocomplete"; import { InputAutocomplete } from "./InputAutocomplete";
export type InputFontProps = { export type InputFontProps = {
name: string name: string
@@ -11,13 +11,9 @@ export type InputFontProps = {
"aria-label"?: string "aria-label"?: string
}; };
export default class InputFont extends React.Component<InputFontProps> { export const InputFont: React.FC<InputFontProps> = ({fonts = [], ...props}) => {
static defaultProps = { const getValues = () => {
fonts: [] const out = props.value || props.default || [];
};
get values() {
const out = this.props.value || this.props.default || [];
// Always put a "" in the last field to you can keep adding entries // Always put a "" in the last field to you can keep adding entries
if (out[out.length-1] !== ""){ if (out[out.length-1] !== ""){
@@ -26,36 +22,36 @@ export default class InputFont extends React.Component<InputFontProps> {
else { else {
return out; return out;
} }
} };
changeFont(idx: number, newValue: string) { const values = getValues();
const changedValues = this.values.slice(0);
const changeFont = (idx: number, newValue: string) => {
const changedValues = values.slice(0);
changedValues[idx] = newValue; changedValues[idx] = newValue;
const filteredValues = changedValues const filteredValues = changedValues
.filter(v => v !== undefined) .filter(v => v !== undefined)
.filter(v => v !== ""); .filter(v => v !== "");
this.props.onChange(filteredValues); props.onChange(filteredValues);
} };
render() { const inputs = values.map((value, i) => {
const inputs = this.values.map((value, i) => { return <li
return <li key={i}
key={i} >
> <InputAutocomplete
<InputAutocomplete aria-label={props["aria-label"] || props.name}
aria-label={this.props["aria-label"] || this.props.name} value={value}
value={value} options={fonts.map(f => [f, f])}
options={this.props.fonts?.map(f => [f, f])} onChange={changeFont.bind(null, i)}
onChange={this.changeFont.bind(this, i)} />
/> </li>;
</li>; });
});
return ( return (
<ul className="maputnik-font"> <ul className="maputnik-font">
{inputs} {inputs}
</ul> </ul>
); );
} };
}
+89 -89
View File
@@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect, useRef } from "react";
import classnames from "classnames"; import classnames from "classnames";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
@@ -25,88 +25,49 @@ export type InputJsonProps = {
}; };
type InputJsonInternalProps = InputJsonProps & WithTranslation; type InputJsonInternalProps = InputJsonProps & WithTranslation;
type InputJsonState = { function getPrettyJson(data: any) {
isEditing: boolean return stringifyPretty(data, {indent: 2, maxLength: 40});
prevValue: string }
};
class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJsonState> { const InputJsonInternal: React.FC<InputJsonInternalProps> = ({
static defaultProps = { value,
onFocus: () => {}, className,
onBlur: () => {}, onChange,
withScroll: false onFocus = () => {},
}; onBlur = () => {},
_view: EditorView | undefined; lintType,
_el: HTMLDivElement | null = null; spec,
_cancelNextChange: boolean = false; 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);
constructor(props: InputJsonInternalProps) { const handleFocus = () => {
super(props); if (onFocus) onFocus();
this.state = { isEditing.current = true;
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,
});
}; };
onBlur = () => { const handleBlur = () => {
if (this.props.onBlur) this.props.onBlur(); if (onBlur) onBlur();
this.setState({ isEditing.current = false;
isEditing: false,
});
}; };
componentDidUpdate(prevProps: InputJsonProps) { const handleChange = () => {
if (!this.state.isEditing && prevProps.value !== this.props.value) { if (cancelNextChange.current) {
this._cancelNextChange = true; cancelNextChange.current = false;
const transactionSpec: TransactionSpec = { prevValue.current = view.current!.state.doc.toString();
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; return;
} }
const newCode = this._view!.state.doc.toString(); const newCode = view.current!.state.doc.toString();
if (this.state.prevValue !== newCode) { if (prevValue.current !== newCode) {
let parsedLayer, err; let parsedLayer, err;
try { try {
parsedLayer = JSON.parse(newCode); parsedLayer = JSON.parse(newCode);
@@ -116,24 +77,63 @@ class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJso
} }
if (!err) { if (!err) {
if (this.props.onChange) this.props.onChange(parsedLayer); if (onChange) onChange(parsedLayer);
} }
} }
this.setState({ prevValue.current = newCode;
prevValue: newCode,
});
}; };
render() { // The editor is created once on mount, so its callbacks have to go through a
return <div className="json-editor" data-wd-key="json-editor" aria-hidden="true" style={{cursor: "text"}}> // ref to always see the latest props.
<div const handlers = useRef({handleChange, handleFocus, handleBlur});
className={classnames("codemirror-container", this.props.className)} useEffect(() => {
ref={(el) => {this._el = el;}} handlers.current = {handleChange, handleFocus, handleBlur};
/> });
</div>;
}
}
const InputJson = withTranslation()(InputJsonInternal); useEffect(() => {
export default InputJson; 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);
+25 -27
View File
@@ -9,31 +9,29 @@ export type InputMultiInputProps = {
"aria-label"?: string "aria-label"?: string
}; };
export default class InputMultiInput extends React.Component<InputMultiInputProps> { export const InputMultiInput: React.FC<InputMultiInputProps> = (props) => {
render() { let options = props.options;
let options = this.props.options; if(options.length > 0 && !Array.isArray(options[0])) {
if(options.length > 0 && !Array.isArray(options[0])) { options = options.map(v => [v, v]);
options = options.map(v => [v, v]);
}
const selectedValue = this.props.value || options[0][0];
const 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>;
};
+141 -169
View File
@@ -1,5 +1,4 @@
import React, { type BaseSyntheticEvent } from "react"; import React, { type BaseSyntheticEvent, useRef, useState } from "react";
import generateUniqueId from "../libs/document-uid";
export type InputNumberProps = { export type InputNumberProps = {
value?: number value?: number
@@ -14,233 +13,206 @@ export type InputNumberProps = {
"aria-label"?: string "aria-label"?: string
}; };
type InputNumberState = { export const InputNumber: React.FC<InputNumberProps> = (props) => {
uuid: number const { rangeStep = 1 } = props;
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
};
export default class InputNumber extends React.Component<InputNumberProps, InputNumberState> { const [editing, setEditing] = useState(false);
static defaultProps = { const [editingRange, setEditingRange] = useState(false);
rangeStep: 1 const [value, setValue] = useState<number | undefined>(props.value);
}; /** The value currently being edited. It can be an invalid value. */
_keyboardEvent: boolean = false; const [dirtyValue, setDirtyValue] = useState<number | string | undefined>(props.value);
constructor(props: InputNumberProps) { const keyboardEvent = useRef(false);
super(props);
this.state = { // Replaces getDerivedStateFromProps: while not editing, track the prop.
uuid: +generateUniqueId(), if (!editing && props.value !== value) {
editing: false, setValue(props.value);
value: props.value, setDirtyValue(props.value);
dirtyValue: props.value,
};
} }
static getDerivedStateFromProps(props: Readonly<InputNumberProps>, state: InputNumberState) { function isValid(v: number | string | undefined) {
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) { if (v === undefined) {
return true; return true;
} }
const value = +v; const val = +v;
if(isNaN(value)) { if(isNaN(val)) {
return false; return false;
} }
if(!isNaN(this.props.min!) && value < this.props.min!) { if(!isNaN(props.min!) && val < props.min!) {
return false; return false;
} }
if(!isNaN(this.props.max!) && value > this.props.max!) { if(!isNaN(props.max!) && val > props.max!) {
return false; return false;
} }
return true; return true;
} }
resetValue = () => { function changeValue(newValue: number | string | undefined) {
this.setState({editing: false}); 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);
// Reset explicitly to default value if value has been cleared // Reset explicitly to default value if value has been cleared
if(!this.state.value) { if(!value) {
return; return;
} }
// If set value is invalid fall back to the last valid value from props or at last resort the default value // If set value is invalid fall back to the last valid value from props or at last resort the default value
if (!this.isValid(this.state.value)) { if (!isValid(value)) {
if(this.isValid(this.props.value)) { if(isValid(props.value)) {
this.changeValue(this.props.value); changeValue(props.value);
this.setState({dirtyValue: this.props.value}); setDirtyValue(props.value);
} else { } else {
this.changeValue(undefined); changeValue(undefined);
this.setState({dirtyValue: undefined}); setDirtyValue(undefined);
} }
} }
}; };
onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => { const onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
let value = parseFloat(e.target.value); let newValue = parseFloat(e.target.value);
const step = this.props.rangeStep; const step = rangeStep;
let dirtyValue = value; let newDirtyValue = newValue;
if(step) { 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. // 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 = value % step; const snap = newValue % step;
// Round up/down to step // Round up/down to step
if (this._keyboardEvent) { if (keyboardEvent.current) {
// If it's keyboard event we might get a low positive/negative value, // 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 // 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 // that came from a keyboard event we always want to increase by a
// single step value. // single step value.
if (value < +this.state.dirtyValue!) { if (newValue < +dirtyValue!) {
value = this.state.value! - step; newValue = value! - step;
} }
else { else {
value = this.state.value! + step; newValue = value! + step;
} }
dirtyValue = value; newDirtyValue = newValue;
} }
else { else {
if (snap < step/2) { if (snap < step/2) {
value = value - snap; newValue = newValue - snap;
} }
else { else {
value = value + (step - snap); newValue = newValue + (step - snap);
} }
} }
} }
this._keyboardEvent = false; keyboardEvent.current = false;
// Clamp between min/max // Clamp between min/max
value = Math.max(this.props.min!, Math.min(this.props.max!, value)); newValue = Math.max(props.min!, Math.min(props.max!, newValue));
this.setState({value, dirtyValue}); setValue(newValue);
if (this.props.onChange) this.props.onChange(value); setDirtyValue(newDirtyValue);
if (props.onChange) props.onChange(newValue);
}; };
render() { if(
if( Object.prototype.hasOwnProperty.call(props, "min") &&
Object.prototype.hasOwnProperty.call(this.props, "min") && Object.prototype.hasOwnProperty.call(props, "max") &&
Object.prototype.hasOwnProperty.call(this.props, "max") && props.min !== undefined && props.max !== undefined &&
this.props.min !== undefined && this.props.max !== undefined && props.allowRange
this.props.allowRange ) {
) { const currentValue = editing ? dirtyValue : value;
const value = this.state.editing ? this.state.dirtyValue : this.state.value; const defaultValue = props.default === undefined ? "" : props.default;
const defaultValue = this.props.default === undefined ? "" : this.props.default; let inputValue;
let inputValue; if (editingRange) {
if (this.state.editingRange) { inputValue = value;
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 { else {
const value = this.state.editing ? this.state.dirtyValue : this.state.value; inputValue = currentValue;
}
return <input return <div className="maputnik-number-container">
aria-label={this.props["aria-label"]} <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"
spellCheck="false" spellCheck="false"
className="maputnik-number" className="maputnik-number"
placeholder={this.props.default?.toString()} placeholder={props.default?.toString()}
value={value === undefined ? "" : value} value={inputValue === undefined ? "" : inputValue}
onChange={e => this.changeValue(e.target.value)} onFocus={_e => {
onFocus={() => { setEditing(true);
this.setState({editing: true});
}} }}
onBlur={this.resetValue} onChange={e => {
required={this.props.required} changeValue(e.target.value);
data-wd-key={this.props["data-wd-key"]} }}
/>; onBlur={_e => {
} setEditing(false);
resetValue();
}}
data-wd-key={props["data-wd-key"] + "-text"}
/>
</div>;
} }
} 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"]}
/>;
}
};
+17 -19
View File
@@ -10,23 +10,21 @@ export type InputSelectProps = {
"aria-label"?: string "aria-label"?: string
}; };
export default class InputSelect extends React.Component<InputSelectProps> { export const InputSelect: React.FC<InputSelectProps> = (props) => {
render() { let options = props.options;
let options = this.props.options; if(options.length > 0 && !Array.isArray(options[0])) {
if(options.length > 0 && !Array.isArray(options[0])) { options = options.map((v) => [v, v]) as [string, any][];
options = options.map((v) => [v, v]) as [string, any][];
}
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>;
} }
}
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>;
};
+47 -49
View File
@@ -1,14 +1,14 @@
import React, { type ReactElement } from "react"; import React, { type ReactElement } from "react";
import InputColor, { type InputColorProps } from "./InputColor"; import { InputColor, type InputColorProps } from "./InputColor";
import InputNumber, { type InputNumberProps } from "./InputNumber"; import { InputNumber, type InputNumberProps } from "./InputNumber";
import InputCheckbox, { type InputCheckboxProps } from "./InputCheckbox"; import { InputCheckbox, type InputCheckboxProps } from "./InputCheckbox";
import InputString, { type InputStringProps } from "./InputString"; import { InputString, type InputStringProps } from "./InputString";
import InputArray, { type InputArrayProps } from "./InputArray"; import { InputArray, type InputArrayProps } from "./InputArray";
import InputDynamicArray, { type InputDynamicArrayProps } from "./InputDynamicArray"; import { InputDynamicArray, type InputDynamicArrayProps } from "./InputDynamicArray";
import InputFont, { type InputFontProps } from "./InputFont"; import { InputFont, type InputFontProps } from "./InputFont";
import InputAutocomplete, { type InputAutocompleteProps } from "./InputAutocomplete"; import { InputAutocomplete, type InputAutocompleteProps } from "./InputAutocomplete";
import InputEnum, { type InputEnumProps } from "./InputEnum"; import { InputEnum, type InputEnumProps } from "./InputEnum";
import capitalize from "lodash.capitalize"; import capitalize from "lodash.capitalize";
const iconProperties = ["background-pattern", "fill-pattern", "line-pattern", "fill-extrusion-pattern", "icon-image"]; const iconProperties = ["background-pattern", "fill-pattern", "line-pattern", "fill-extrusion-pattern", "icon-image"];
@@ -38,31 +38,31 @@ export type InputSpecProps = {
/** Display any field from the Maplibre GL style spec and /** Display any field from the Maplibre GL style spec and
* choose the correct field component based on the @{fieldSpec} * choose the correct field component based on the @{fieldSpec}
* to display @{value}. */ * to display @{value}. */
export default class InputSpec extends React.Component<InputSpecProps> { export const InputSpec: React.FC<InputSpecProps> = (props) => {
childNodes() { const childNodes = () => {
const commonProps = { const commonProps = {
fieldSpec: this.props.fieldSpec, fieldSpec: props.fieldSpec,
label: this.props.label, label: props.label,
action: this.props.action, action: props.action,
style: this.props.style, style: props.style,
value: this.props.value, value: props.value,
default: this.props.fieldSpec?.default, default: props.fieldSpec?.default,
name: this.props.fieldName, name: props.fieldName,
"data-wd-key": "spec-field-input:" + this.props.fieldName, "data-wd-key": "spec-field-input:" + props.fieldName,
onChange: (newValue: number | undefined | (string | number | undefined)[]) => this.props.onChange!(this.props.fieldName, newValue), onChange: (newValue: number | undefined | (string | number | undefined)[]) => props.onChange!(props.fieldName, newValue),
"aria-label": this.props["aria-label"], "aria-label": props["aria-label"],
}; };
switch(this.props.fieldSpec?.type) { switch(props.fieldSpec?.type) {
case "number": return ( case "number": return (
<InputNumber <InputNumber
{...commonProps as InputNumberProps} {...commonProps as InputNumberProps}
min={this.props.fieldSpec.minimum} min={props.fieldSpec.minimum}
max={this.props.fieldSpec.maximum} max={props.fieldSpec.maximum}
/> />
); );
case "enum": { case "enum": {
const options = Object.keys(this.props.fieldSpec.values || []).map(v => [v, capitalize(v)]); const options = Object.keys(props.fieldSpec.values || []).map(v => [v, capitalize(v)]);
return <InputEnum return <InputEnum
{...commonProps as Omit<InputEnumProps, "options">} {...commonProps as Omit<InputEnumProps, "options">}
@@ -72,8 +72,8 @@ export default class InputSpec extends React.Component<InputSpecProps> {
case "resolvedImage": case "resolvedImage":
case "formatted": case "formatted":
case "string": case "string":
if (iconProperties.indexOf(this.props.fieldName!) >= 0) { if (iconProperties.indexOf(props.fieldName!) >= 0) {
const options = this.props.fieldSpec.values || []; const options = props.fieldSpec.values || [];
return <InputAutocomplete return <InputAutocomplete
{...commonProps as Omit<InputAutocompleteProps, "options">} {...commonProps as Omit<InputAutocompleteProps, "options">}
options={options.map(f => [f, f])} options={options.map(f => [f, f])}
@@ -94,61 +94,59 @@ export default class InputSpec extends React.Component<InputSpecProps> {
/> />
); );
case "array": case "array":
if(this.props.fieldName === "text-font") { if(props.fieldName === "text-font") {
return <InputFont return <InputFont
{...commonProps as InputFontProps} {...commonProps as InputFontProps}
fonts={this.props.fieldSpec.values} fonts={props.fieldSpec.values}
/>; />;
} else { } else {
if (this.props.fieldSpec.length) { if (props.fieldSpec.length) {
return <InputArray return <InputArray
{...commonProps as InputArrayProps} {...commonProps as InputArrayProps}
type={this.props.fieldSpec.value} type={props.fieldSpec.value}
length={this.props.fieldSpec.length} length={props.fieldSpec.length}
/>; />;
} else { } else {
return <InputDynamicArray return <InputDynamicArray
{...commonProps as InputDynamicArrayProps} {...commonProps as InputDynamicArrayProps}
fieldSpec={this.props.fieldSpec} fieldSpec={props.fieldSpec}
type={this.props.fieldSpec.value as InputDynamicArrayProps["type"]} type={props.fieldSpec.value as InputDynamicArrayProps["type"]}
/>; />;
} }
} }
case "numberArray": return ( case "numberArray": return (
<InputDynamicArray <InputDynamicArray
{...commonProps as InputDynamicArrayProps} {...commonProps as InputDynamicArrayProps}
fieldSpec={this.props.fieldSpec} fieldSpec={props.fieldSpec}
type="number" type="number"
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]} value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]}
/> />
); );
case "colorArray": return ( case "colorArray": return (
<InputDynamicArray <InputDynamicArray
{...commonProps as InputDynamicArrayProps} {...commonProps as InputDynamicArrayProps}
fieldSpec={this.props.fieldSpec} fieldSpec={props.fieldSpec}
type="color" type="color"
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]} value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]}
/> />
); );
case "padding": return ( case "padding": return (
<InputArray <InputArray
{...commonProps as InputArrayProps} {...commonProps as InputArrayProps}
type="number" type="number"
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]} value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]}
length={4} length={4}
/> />
); );
default: default:
console.warn(`No proper field input for ${this.props.fieldName} type: ${this.props.fieldSpec?.type}`); console.warn(`No proper field input for ${props.fieldName} type: ${props.fieldSpec?.type}`);
return null; return null;
} }
} };
render() { return (
return ( <div data-wd-key={"spec-field:"+props.fieldName}>
<div data-wd-key={"spec-field:"+this.props.fieldName}> {childNodes()}
{this.childNodes()} </div>
</div> );
); };
}
}
+63 -82
View File
@@ -1,4 +1,4 @@
import React from "react"; import React, { useState } from "react";
export type InputStringProps = { export type InputStringProps = {
"data-wd-key"?: string "data-wd-key"?: string
@@ -15,85 +15,66 @@ export type InputStringProps = {
title?: string title?: string
}; };
type InputStringState = { export const InputString: React.FC<InputStringProps> = (props) => {
editing: boolean const { onInput = () => {} } = props;
value?: string 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,
});
}; };
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,
});
}
}
+28 -48
View File
@@ -1,6 +1,6 @@
import React, { type JSX } from "react"; import React, { type JSX, useState } from "react";
import InputString from "./InputString"; import { InputString } from "./InputString";
import SmallError from "./SmallError"; import { SmallError } from "./SmallError";
import { Trans, type WithTranslation, withTranslation } from "react-i18next"; import { Trans, type WithTranslation, withTranslation } from "react-i18next";
import { type TFunction } from "i18next"; import { type TFunction } from "i18next";
import { ErrorType, validate } from "../libs/urlopen"; import { ErrorType, validate } from "../libs/urlopen";
@@ -46,50 +46,30 @@ export type FieldUrlProps = {
type InputUrlInternalProps = FieldUrlProps & WithTranslation; type InputUrlInternalProps = FieldUrlProps & WithTranslation;
type InputUrlState = { const InputUrlInternal: React.FC<InputUrlInternalProps> = ({onInput = () => {}, ...props}) => {
error?: ErrorType 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>
);
}; };
class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlState> { export const InputUrl = withTranslation()(InputUrlInternal);
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;
+195 -206
View File
@@ -1,4 +1,4 @@
import React, { type JSX } from "react"; import React, { type JSX, useState } from "react";
import { Wrapper, Button, Menu, MenuItem } from "react-aria-menubutton"; import { Wrapper, Button, Menu, MenuItem } from "react-aria-menubutton";
import { Accordion } from "react-accessible-accordion"; import { Accordion } from "react-accessible-accordion";
import { MdMoreVert } from "react-icons/md"; import { MdMoreVert } from "react-icons/md";
@@ -6,18 +6,20 @@ import { IconContext } from "react-icons";
import { type BackgroundLayerSpecification, type LayerSpecification, type SourceSpecification } from "maplibre-gl"; import { type BackgroundLayerSpecification, type LayerSpecification, type SourceSpecification } from "maplibre-gl";
import { v8 } from "@maplibre/maplibre-gl-style-spec"; import { v8 } from "@maplibre/maplibre-gl-style-spec";
import FieldJson from "./FieldJson"; import { FieldJson } from "./FieldJson";
import FilterEditor from "./FilterEditor"; import { FilterEditor } from "./FilterEditor";
import PropertyGroup from "./PropertyGroup"; import { PropertyGroup } from "./PropertyGroup";
import LayerEditorGroup from "./LayerEditorGroup"; import { LayerEditorGroup } from "./LayerEditorGroup";
import FieldType from "./FieldType"; import { FieldType } from "./FieldType";
import FieldId from "./FieldId"; import { FieldId } from "./FieldId";
import FieldMinZoom from "./FieldMinZoom"; import { FieldMinZoom } from "./FieldMinZoom";
import FieldMaxZoom from "./FieldMaxZoom"; import { FieldMaxZoom } from "./FieldMaxZoom";
import FieldComment from "./FieldComment"; import { FieldComment } from "./FieldComment";
import FieldSource from "./FieldSource"; import { FieldSource } from "./FieldSource";
import FieldSourceLayer from "./FieldSourceLayer"; import { FieldSourceLayer } from "./FieldSourceLayer";
import { changeType, changeProperty } from "../libs/layer"; // 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 { formatLayerId } from "../libs/format"; import { formatLayerId } from "../libs/format";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { type TFunction } from "i18next"; import { type TFunction } from "i18next";
@@ -131,67 +133,56 @@ type LayerEditorInternalProps = {
errors?: MappedError[] errors?: MappedError[]
} & WithTranslation; } & WithTranslation;
type LayerEditorState = {
editorGroups: { [keys: string]: boolean }
};
/** Layer editor supporting multiple types of layers. */ /** Layer editor supporting multiple types of layers. */
class LayerEditorInternal extends React.Component<LayerEditorInternalProps, LayerEditorState> { const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
static defaultProps = { onLayerChanged = () => { },
onLayerChanged: () => { }, onLayerIdChange = () => { },
onLayerIdChange: () => { }, ...rest
onLayerDestroyed: () => { }, }) => {
}; const props = { onLayerChanged, onLayerIdChange, ...rest } as LayerEditorInternalProps;
constructor(props: LayerEditorInternalProps) { const [editorGroups, setEditorGroups] = useState<{ [keys: string]: boolean }>(() => {
super(props); const groups: { [keys: string]: boolean } = {};
for (const group of layoutGroups(props.layer.type, props.t)) {
const editorGroups: { [keys: string]: boolean } = {}; groups[group.title] = true;
for (const group of layoutGroups(this.props.layer.type, props.t)) {
editorGroups[group.title] = true;
} }
return groups;
});
this.state = { editorGroups }; // 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;
}
setEditorGroups(additionalGroups);
} }
static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) { function changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
const additionalGroups = { ...state.editorGroups }; props.onLayerChanged(
props.layerIndex,
for (const group of getLayoutForType(props.layer.type, props.t)) { changeLayerProperty(props.layer, group, property, newValue)
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)
); );
} }
onGroupToggle(groupTitle: string, active: boolean) { function onGroupToggle(groupTitle: string, active: boolean) {
const changedActiveGroups = { const changedActiveGroups = {
...this.state.editorGroups, ...editorGroups,
[groupTitle]: active, [groupTitle]: active,
}; };
this.setState({ setEditorGroups(changedActiveGroups);
editorGroups: changedActiveGroups
});
} }
renderGroupType(type: string, fields?: string[]): JSX.Element { function renderGroupType(type: string, fields?: string[]): JSX.Element {
let comment = ""; let comment = "";
if (this.props.layer.metadata) { if (props.layer.metadata) {
comment = (this.props.layer.metadata as any)["maputnik:comment"]; comment = (props.layer.metadata as any)["maputnik:comment"];
} }
const { errors, layerIndex } = this.props; const { errors, layerIndex } = props;
const errorData: MappedLayerErrors = {}; const errorData: MappedLayerErrors = {};
errors!.forEach(error => { errors!.forEach(error => {
@@ -207,84 +198,85 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
}); });
let sourceLayerIds; let sourceLayerIds;
const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>; const layer = props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
if (Object.prototype.hasOwnProperty.call(this.props.sources, layer.source)) { if (Object.prototype.hasOwnProperty.call(props.sources, layer.source)) {
sourceLayerIds = this.props.sources[layer.source].layers; sourceLayerIds = props.sources[layer.source].layers;
} }
switch (type) { switch (type) {
case "layer": return <div> case "layer": return <div>
<FieldId <FieldId
value={this.props.layer.id} value={props.layer.id}
wdKey="layer-editor.layer-id" wdKey="layer-editor.layer-id"
error={errorData.id} error={errorData.id}
onChange={newId => this.props.onLayerIdChange(this.props.layerIndex, this.props.layer.id, newId)} onChange={newId => props.onLayerIdChange(props.layerIndex, props.layer.id, newId)}
/> />
<FieldType <FieldType
disabled={true} disabled={true}
error={errorData.type} error={errorData.type}
value={this.props.layer.type} value={props.layer.type}
onChange={newType => this.props.onLayerChanged( onChange={newType => props.onLayerChanged(
this.props.layerIndex, props.layerIndex,
changeType(this.props.layer, newType) changeType(props.layer, newType)
)} )}
/> />
{this.props.layer.type !== "background" && <FieldSource {props.layer.type !== "background" && <FieldSource
wdKey="layer-editor.layer-source"
error={errorData.source} error={errorData.source}
sourceIds={Object.keys(this.props.sources!)} sourceIds={Object.keys(props.sources!)}
value={this.props.layer.source} value={props.layer.source}
onChange={v => this.changeProperty(null, "source", v)} onChange={v => changeProperty(null, "source", v)}
/> />
} }
{!NON_SOURCE_LAYERS.includes(this.props.layer.type) && {!NON_SOURCE_LAYERS.includes(props.layer.type) &&
<FieldSourceLayer <FieldSourceLayer
error={errorData["source-layer"]} error={errorData["source-layer"]}
sourceLayerIds={sourceLayerIds} sourceLayerIds={sourceLayerIds}
value={(this.props.layer as any)["source-layer"]} value={(props.layer as any)["source-layer"]}
onChange={v => this.changeProperty(null, "source-layer", v)} onChange={v => changeProperty(null, "source-layer", v)}
/> />
} }
<FieldMinZoom <FieldMinZoom
error={errorData.minzoom} error={errorData.minzoom}
value={this.props.layer.minzoom} value={props.layer.minzoom}
onChange={v => this.changeProperty(null, "minzoom", v)} onChange={v => changeProperty(null, "minzoom", v)}
/> />
<FieldMaxZoom <FieldMaxZoom
error={errorData.maxzoom} error={errorData.maxzoom}
value={this.props.layer.maxzoom} value={props.layer.maxzoom}
onChange={v => this.changeProperty(null, "maxzoom", v)} onChange={v => changeProperty(null, "maxzoom", v)}
/> />
<FieldComment <FieldComment
error={errorData.comment} error={errorData.comment}
value={comment} value={comment}
onChange={v => this.changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)} onChange={v => changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)}
/> />
</div>; </div>;
case "filter": return <div> case "filter": return <div>
<div className="maputnik-filter-editor-wrapper"> <div className="maputnik-filter-editor-wrapper">
<FilterEditor <FilterEditor
errors={errorData} errors={errorData}
filter={(this.props.layer as any).filter} filter={(props.layer as any).filter}
properties={this.props.vectorLayers[(this.props.layer as any)["source-layer"]]} properties={props.vectorLayers[(props.layer as any)["source-layer"]]}
onChange={f => this.changeProperty(null, "filter", f)} onChange={f => changeProperty(null, "filter", f)}
/> />
</div> </div>
</div>; </div>;
case "properties": case "properties":
return <PropertyGroup return <PropertyGroup
errors={errorData} errors={errorData}
layer={this.props.layer} layer={props.layer}
groupFields={fields!} groupFields={fields!}
spec={this.props.spec} spec={props.spec}
onChange={this.changeProperty.bind(this)} onChange={changeProperty.bind(null)}
/>; />;
case "jsoneditor": case "jsoneditor":
return <FieldJson return <FieldJson
lintType="layer" lintType="layer"
value={this.props.layer} value={props.layer}
onChange={(layer: LayerSpecification) => { onChange={(layer: LayerSpecification) => {
this.props.onLayerChanged( props.onLayerChanged(
this.props.layerIndex, props.layerIndex,
layer layer
); );
}} }}
@@ -293,131 +285,128 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
} }
} }
moveLayer(offset: number) { function moveLayer(offset: number) {
this.props.onMoveLayer({ props.onMoveLayer({
oldIndex: this.props.layerIndex, oldIndex: props.layerIndex,
newIndex: this.props.layerIndex + offset newIndex: props.layerIndex + offset
}); });
} }
render() { const t = props.t;
const t = this.props.t;
const groupIds: string[] = []; const groupIds: string[] = [];
const layerType = this.props.layer.type; const layerType = props.layer.type;
const groups = layoutGroups(layerType, t).filter(group => { const groups = layoutGroups(layerType, t).filter(group => {
return !(layerType === "background" && group.type === "source"); return !(layerType === "background" && group.type === "source");
}).map(group => { }).map(group => {
const groupId = group.id; const groupId = group.id;
groupIds.push(groupId); groupIds.push(groupId);
return <LayerEditorGroup return <LayerEditorGroup
data-wd-key={group.title} data-wd-key={group.title}
id={groupId} id={groupId}
key={groupId} key={groupId}
title={group.title} title={group.title}
isActive={this.state.editorGroups[group.title]} isActive={editorGroups[group.title]}
onActiveToggle={this.onGroupToggle.bind(this, group.title)} onActiveToggle={onGroupToggle.bind(null, group.title)}
> >
{this.renderGroupType(group.type, group.fields)} {renderGroupType(group.type, group.fields)}
</LayerEditorGroup>; </LayerEditorGroup>;
}); });
const layout = this.props.layer.layout || {}; const layout = props.layer.layout || {};
const items: { const items: {
[key: string]: { [key: string]: {
text: string, text: string,
handler: () => void, handler: () => void,
disabled?: boolean, disabled?: boolean,
wdKey?: string 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();
} }
} = {
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"
}
};
return <IconContext.Provider value={{ size: "14px", color: "#8e8e8e" }}> function handleSelection(id: string, event: React.SyntheticEvent) {
<section className="maputnik-layer-editor" event.stopPropagation();
role="main" items[id].handler();
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); return <IconContext.Provider value={{ size: "14px", color: "#8e8e8e" }}>
export default LayerEditor; <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}
>
{groups}
</Accordion>
</section>
</IconContext.Provider>;
};
export const LayerEditor = withTranslation()(LayerEditorInternal);
+17 -19
View File
@@ -18,22 +18,20 @@ type LayerEditorGroupProps = {
}; };
export default class LayerEditorGroup extends React.Component<LayerEditorGroupProps> { export const LayerEditorGroup: React.FC<LayerEditorGroupProps> = (props) => {
render() { return <AccordionItem uuid={props.id}>
return <AccordionItem uuid={this.props.id}> <AccordionItemHeading className="maputnik-layer-editor-group"
<AccordionItemHeading className="maputnik-layer-editor-group" data-wd-key={"layer-editor-group:"+props["data-wd-key"]}
data-wd-key={"layer-editor-group:"+this.props["data-wd-key"]} onClick={_e => props.onActiveToggle(!props.isActive)}
onClick={_e => this.props.onActiveToggle(!this.props.isActive)} >
> <AccordionItemButton className="maputnik-layer-editor-group__button">
<AccordionItemButton className="maputnik-layer-editor-group__button"> <span style={{flexGrow: 1, alignContent: "center"}}>{props.title}</span>
<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>
<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>
<MdArrowDropDown size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"></MdArrowDropDown> </AccordionItemButton>
</AccordionItemButton> </AccordionItemHeading>
</AccordionItemHeading> <AccordionItemPanel>
<AccordionItemPanel> {props.children}
{this.props.children} </AccordionItemPanel>
</AccordionItemPanel> </AccordionItem>;
</AccordionItem>; };
}
}
+228 -241
View File
@@ -1,4 +1,4 @@
import React, {type JSX} from "react"; import React, {type JSX, useEffect, useRef, useState} from "react";
import classnames from "classnames"; import classnames from "classnames";
import lodash from "lodash"; import lodash from "lodash";
import { import {
@@ -14,12 +14,12 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import LayerListGroup from "./LayerListGroup"; import { LayerListGroup } from "./LayerListGroup";
import LayerListItem from "./LayerListItem"; import { LayerListItem } from "./LayerListItem";
import ModalAdd from "./modals/ModalAdd"; import { ModalAdd } from "./modals/ModalAdd";
import type {LayerSpecification, SourceSpecification} from "maplibre-gl"; 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 { findClosestCommonPrefix, layerPrefix } from "../libs/layer";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { type MappedError, type OnMoveLayerCallback } from "../libs/definitions"; import { type MappedError, type OnMoveLayerCallback } from "../libs/definitions";
@@ -37,62 +37,98 @@ type LayerListContainerProps = {
}; };
type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation; type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation;
type LayerListContainerState = { const noopLayerSelect = () => {};
collapsedGroups: {[ket: string]: boolean}
areAllGroupsExpanded: boolean // Replaces the previous `shouldComponentUpdate`. Note the inversion: this
keys: {[key: string]: number} // returns true when the props are EQUAL (i.e. no re-render is needed), whereas
isOpen: {[key: string]: boolean} // `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;
}
// List of collapsible layer editors // List of collapsible layer editors
class LayerListContainerInternal extends React.Component<LayerListContainerInternalProps, LayerListContainerState> { function LayerListContainerInternal({
static defaultProps = { layers: propsLayers,
onLayerSelect: () => {}, selectedLayerIndex,
}; onLayersChange,
selectedItemRef: React.RefObject<any>; onLayerSelect = noopLayerSelect,
scrollContainerRef: React.RefObject<HTMLElement | null>; onLayerDestroy,
onLayerCopy,
onLayerVisibilityToggle,
sources,
errors,
t,
}: LayerListContainerInternalProps) {
const selectedItemRef = useRef<any>(null);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const hasMountedRef = useRef(false);
constructor(props: LayerListContainerInternalProps) { const [collapsedGroups, setCollapsedGroups] = useState<{[key: string]: boolean}>({});
super(props); const [areAllGroupsExpanded, setAreAllGroupsExpanded] = useState(false);
this.selectedItemRef = React.createRef(); const [keys, setKeys] = useState<{[key: string]: number}>(() => ({
this.scrollContainerRef = React.createRef(); add: +generateUniqueId(),
this.state = { }));
collapsedGroups: {}, const [isOpen, setIsOpen] = useState<{[key: string]: boolean}>({
areAllGroupsExpanded: false, add: false,
keys: { });
add: +generateUniqueId(),
}, function toggleModal(modalName: string) {
isOpen: { setKeys(prevKeys => ({
add: false, ...prevKeys,
} [modalName]: +generateUniqueId(),
}; }));
setIsOpen(prevIsOpen => ({
...prevIsOpen,
[modalName]: !prevIsOpen[modalName]
}));
} }
toggleModal(modalName: string) { const toggleLayers = () => {
this.setState({
keys: {
...this.state.keys,
[modalName]: +generateUniqueId(),
},
isOpen: {
...this.state.isOpen,
[modalName]: !this.state.isOpen[modalName]
}
});
}
toggleLayers = () => {
let idx = 0; let idx = 0;
const newGroups: {[key:string]: boolean} = {}; const newGroups: {[key:string]: boolean} = {};
this.groupedLayers().forEach(layers => { groupedLayers().forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id); const groupPrefix = layerPrefix(layers[0].id);
const lookupKey = [groupPrefix, idx].join("-"); const lookupKey = [groupPrefix, idx].join("-");
if (layers.length > 1) { if (layers.length > 1) {
newGroups[lookupKey] = this.state.areAllGroupsExpanded; newGroups[lookupKey] = areAllGroupsExpanded;
} }
layers.forEach((_layer) => { layers.forEach((_layer) => {
@@ -100,19 +136,17 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
}); });
}); });
this.setState({ setCollapsedGroups(newGroups);
collapsedGroups: newGroups, setAreAllGroupsExpanded(!areAllGroupsExpanded);
areAllGroupsExpanded: !this.state.areAllGroupsExpanded
});
}; };
groupedLayers(): (LayerSpecification & {key: string})[][] { function groupedLayers(): (LayerSpecification & {key: string})[][] {
const groups = []; const groups = [];
const layerIdCount = new Map(); const layerIdCount = new Map();
for (let i = 0; i < this.props.layers.length; i++) { for (let i = 0; i < propsLayers.length; i++) {
const origLayer = this.props.layers[i]; const origLayer = propsLayers[i];
const previousLayer = this.props.layers[i-1]; const previousLayer = propsLayers[i-1];
layerIdCount.set(origLayer.id, layerIdCount.set(origLayer.id,
layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0 layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0
); );
@@ -130,213 +164,168 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
return groups; return groups;
} }
toggleLayerGroup(groupPrefix: string, idx: number) { function toggleLayerGroup(groupPrefix: string, idx: number) {
const lookupKey = [groupPrefix, idx].join("-"); const lookupKey = [groupPrefix, idx].join("-");
const newGroups = { ...this.state.collapsedGroups }; setCollapsedGroups(prevCollapsedGroups => {
if(lookupKey in this.state.collapsedGroups) { const newGroups = { ...prevCollapsedGroups };
newGroups[lookupKey] = !this.state.collapsedGroups[lookupKey]; if(lookupKey in prevCollapsedGroups) {
} else { newGroups[lookupKey] = !prevCollapsedGroups[lookupKey];
newGroups[lookupKey] = false; } else {
} newGroups[lookupKey] = false;
this.setState({ }
collapsedGroups: newGroups return newGroups;
}); });
} }
isCollapsed(groupPrefix: string, idx: number) { function isCollapsed(groupPrefix: string, idx: number) {
const collapsed = this.state.collapsedGroups[[groupPrefix, idx].join("-")]; const collapsed = collapsedGroups[[groupPrefix, idx].join("-")];
return collapsed === undefined ? true : collapsed; return collapsed === undefined ? true : collapsed;
} }
shouldComponentUpdate (nextProps: LayerListContainerProps, nextState: LayerListContainerState) { useEffect(() => {
// Always update on state change // `componentDidUpdate` did not run on mount, so skip the first run here too.
if (this.state !== nextState) { if (!hasMountedRef.current) {
return true; hasMountedRef.current = true;
return;
} }
const selectedItemNode = selectedItemRef.current;
// This component tree only requires id and visibility from the layers if (selectedItemNode && selectedItemNode.node) {
// objects const target = selectedItemNode.node;
function getRequiredProps(layer: LayerSpecification) { const options = {
const out: {id: string, layout?: { visibility: any}} = { root: scrollContainerRef.current,
id: layer.id, threshold: 1.0
}; };
const observer = new IntersectionObserver(entries => {
if (layer.layout) { observer.unobserve(target);
out.layout = { if (entries.length > 0 && entries[0].intersectionRatio < 1) {
visibility: layer.layout.visibility target.scrollIntoView();
};
}
return out;
}
const layersEqual = lodash.isEqual(
nextProps.layers.map(getRequiredProps),
this.props.layers.map(getRequiredProps),
);
function withoutLayers(props: LayerListContainerProps) {
const out = {
...props
} as LayerListContainerProps & { 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(this.props),
withoutLayers(nextProps)
);
const propsChanged = !(layersEqual && propsEqual);
return propsChanged;
}
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);
}
layers.forEach((layer, idxInGroup) => {
const groupIdx = findClosestCommonPrefix(this.props.layers, idx);
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;
} }
}, options);
const listItem = <LayerListItem observer.observe(target);
className={classnames({ }
"maputnik-layer-list-item-collapsed": layers.length > 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex, }, [selectedLayerIndex]);
"maputnik-layer-list-item-group-last": idxInGroup == layers.length - 1 && layers.length > 1,
"maputnik-layer-list-item--error": !!layerError const listItems: JSX.Element[] = [];
})} let idx = 0;
key={layer.key} const layersByGroup = groupedLayers();
id={layer.key} layersByGroup.forEach(layers => {
layerId={layer.id} const groupPrefix = layerPrefix(layers[0].id);
layerIndex={idx} if(layers.length > 1) {
layerType={layer.type} const currentIdx = idx;
visibility={(layer.layout || {}).visibility} const grp = <LayerListGroup
isSelected={idx === this.props.selectedLayerIndex} data-wd-key={[groupPrefix, idx].join("-")}
onLayerSelect={this.props.onLayerSelect} aria-controls={layers.map(l => l.key).join(" ")}
onLayerDestroy={this.props.onLayerDestroy?.bind(this)} key={`group-${groupPrefix}-${idx}`}
onLayerCopy={this.props.onLayerCopy.bind(this)} title={groupPrefix}
onLayerVisibilityToggle={this.props.onLayerVisibilityToggle.bind(this)} isActive={!isCollapsed(groupPrefix, idx) || idx === selectedLayerIndex}
{...additionalProps} onActiveToggle={() => toggleLayerGroup(groupPrefix, currentIdx)}
/>; />;
listItems.push(listItem); listItems.push(grp);
idx += 1; }
layers.forEach((layer, idxInGroup) => {
const groupIdx = findClosestCommonPrefix(propsLayers, idx);
const layerError = errors.find(error => {
return (
error.parsed &&
error.parsed.type === "layer" &&
error.parsed.data.index == idx
);
}); });
const additionalProps: {ref?: React.RefObject<any>} = {};
if (idx === selectedLayerIndex) {
additionalProps.ref = selectedItemRef;
}
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;
}); });
});
const t = this.props.t; return <section
className="maputnik-layer-list"
return <section data-wd-key="layer-list"
className="maputnik-layer-list" role="complementary"
data-wd-key="layer-list" aria-label={t("Layers list")}
role="complementary" ref={scrollContainerRef}
aria-label={t("Layers list")} >
ref={this.scrollContainerRef} <ModalAdd
> key={keys.add}
<ModalAdd layers={propsLayers}
key={this.state.keys.add} sources={sources}
layers={this.props.layers} isOpen={isOpen.add}
sources={this.props.sources} onOpenToggle={() => toggleModal("add")}
isOpen={this.state.isOpen.add} onLayersChange={onLayersChange}
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>
<header className="maputnik-layer-list-header" data-wd-key="layer-list.header"> <span className="maputnik-space" />
<span className="maputnik-layer-list-header-title">{t("Layers")}</span> <div className="maputnik-default-property">
<span className="maputnik-space" /> <div className="maputnik-multibutton">
<div className="maputnik-default-property"> <button
<div className="maputnik-multibutton"> id="skip-target-layer-list"
<button data-wd-key="skip-target-layer-list"
id="skip-target-layer-list" onClick={toggleLayers}
data-wd-key="skip-target-layer-list" className="maputnik-button">
onClick={this.toggleLayers} {areAllGroupsExpanded === true ?
className="maputnik-button"> t("Collapse")
{this.state.areAllGroupsExpanded === true ? :
t("Collapse") t("Expand")
: }
t("Expand") </button>
}
</button>
</div>
</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> </div>
</section>; <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"
aria-label={t("Layers list")}
>
<ul className="maputnik-layer-list-container">
{listItems}
</ul>
</div>
</section>;
} }
const LayerListContainer = withTranslation()(LayerListContainerInternal); const LayerListContainer = withTranslation()(React.memo(LayerListContainerInternal, arePropsEqual));
type LayerListProps = LayerListContainerProps & { type LayerListProps = LayerListContainerProps & {
onMoveLayer: OnMoveLayerCallback onMoveLayer: OnMoveLayerCallback
}; };
const LayerList: React.FC<LayerListProps> = (props) => { export const LayerList: React.FC<LayerListProps> = (props) => {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
@@ -361,5 +350,3 @@ const LayerList: React.FC<LayerListProps> = (props) => {
</DndContext> </DndContext>
); );
}; };
export default LayerList;
+21 -23
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import Collapser from "./Collapser"; import { Collapser } from "./Collapser";
type LayerListGroupProps = { type LayerListGroupProps = {
title: string title: string
@@ -9,26 +9,24 @@ type LayerListGroupProps = {
"aria-controls"?: string "aria-controls"?: string
}; };
export default class LayerListGroup extends React.Component<LayerListGroupProps> { export const LayerListGroup: React.FC<LayerListGroupProps> = (props) => {
render() { return <li className="maputnik-layer-list-group">
return <li className="maputnik-layer-list-group"> <div className="maputnik-layer-list-group-header"
<div className="maputnik-layer-list-group-header" data-wd-key={"layer-list-group:"+props["data-wd-key"]}
data-wd-key={"layer-list-group:"+this.props["data-wd-key"]} onClick={_e => props.onActiveToggle(!props.isActive)}
onClick={_e => this.props.onActiveToggle(!this.props.isActive)} >
<button
className="maputnik-layer-list-group-title"
aria-controls={props["aria-controls"]}
aria-expanded={props.isActive}
> >
<button {props.title}
className="maputnik-layer-list-group-title" </button>
aria-controls={this.props["aria-controls"]} <span className="maputnik-space" />
aria-expanded={this.props.isActive} <Collapser
> style={{ height: 14, width: 14 }}
{this.props.title} isCollapsed={props.isActive}
</button> />
<span className="maputnik-space" /> </div>
<Collapser </li>;
style={{ height: 14, width: 14 }} };
isCollapsed={this.props.isActive}
/>
</div>
</li>;
}
}
+23 -27
View File
@@ -5,7 +5,7 @@ import { IconContext } from "react-icons";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import IconLayer from "./IconLayer"; import { IconLayer } from "./IconLayer";
import type { VisibilitySpecification } from "maplibre-gl"; import type { VisibilitySpecification } from "maplibre-gl";
@@ -38,9 +38,9 @@ type IconActionProps = {
classBlockModifier?: string classBlockModifier?: string
}; };
class IconAction extends React.Component<IconActionProps> { const IconAction: React.FC<IconActionProps> = (props) => {
renderIcon() { function renderIcon() {
switch (this.props.action) { switch (props.action) {
case "duplicate": return <MdContentCopy />; case "duplicate": return <MdContentCopy />;
case "show": return <MdVisibility />; case "show": return <MdVisibility />;
case "hide": return <MdVisibilityOff />; case "hide": return <MdVisibilityOff />;
@@ -48,30 +48,28 @@ class IconAction extends React.Component<IconActionProps> {
} }
} }
render() { const { classBlockName, classBlockModifier } = props;
const { classBlockName, classBlockModifier } = this.props;
let classAdditions = ""; let classAdditions = "";
if (classBlockName) { if (classBlockName) {
classAdditions = `maputnik-layer-list-icon-action__${classBlockName}`; classAdditions = `maputnik-layer-list-icon-action__${classBlockName}`;
if (classBlockModifier) { if (classBlockModifier) {
classAdditions += ` maputnik-layer-list-icon-action__${classBlockName}--${classBlockModifier}`; classAdditions += ` maputnik-layer-list-icon-action__${classBlockName}--${classBlockModifier}`;
}
} }
return <button
tabIndex={-1}
title={this.props.action}
className={`maputnik-layer-list-icon-action ${classAdditions}`}
data-wd-key={this.props.wdKey}
onClick={this.props.onClick}
aria-hidden="true"
>
{this.renderIcon()}
</button>;
} }
}
return <button
tabIndex={-1}
title={props.action}
className={`maputnik-layer-list-icon-action ${classAdditions}`}
data-wd-key={props.wdKey}
onClick={props.onClick}
aria-hidden="true"
>
{renderIcon()}
</button>;
};
type LayerListItemProps = { type LayerListItemProps = {
id?: string id?: string
@@ -87,7 +85,7 @@ type LayerListItemProps = {
onLayerVisibilityToggle?(...args: unknown[]): unknown onLayerVisibilityToggle?(...args: unknown[]): unknown
}; };
const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => { export const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => {
const { const {
isSelected = false, isSelected = false,
visibility = "visible", visibility = "visible",
@@ -162,5 +160,3 @@ const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props
</li> </li>
</IconContext.Provider>; </IconContext.Provider>;
}); });
export default LayerListItem;
+238 -209
View File
@@ -1,12 +1,12 @@
import React from "react"; import React, {useCallback, useEffect, useReducer, useRef} from "react";
import {createRoot} from "react-dom/client"; import {createRoot} from "react-dom/client";
import MapLibreGl, {type LayerSpecification, type LngLat, type Map, type MapOptions, type SourceSpecification, type StyleSpecification} from "maplibre-gl"; import MapLibreGl, {type LayerSpecification, type LngLat, type Map, type MapOptions, type SourceSpecification, type StyleSpecification} from "maplibre-gl";
import MaplibreInspect from "@maplibre/maplibre-gl-inspect"; import MaplibreInspect from "@maplibre/maplibre-gl-inspect";
import colors from "@maplibre/maplibre-gl-inspect/lib/colors"; import colors from "@maplibre/maplibre-gl-inspect/lib/colors";
import MapMaplibreGlLayerPopup from "./MapMaplibreGlLayerPopup"; import { FeatureLayerPopup as MapMaplibreGlLayerPopup } from "./MapMaplibreGlLayerPopup";
import MapMaplibreGlFeaturePropertyPopup, { type InspectFeature } from "./MapMaplibreGlFeaturePropertyPopup"; import { FeaturePropertyPopup as MapMaplibreGlFeaturePropertyPopup, type InspectFeature } from "./MapMaplibreGlFeaturePropertyPopup";
import Color from "color"; import Color from "color";
import ZoomControl from "../libs/zoomcontrol"; import { ZoomControl } from "../libs/zoomcontrol";
import { type HighlightedLayer, colorHighlightedLayer } from "../libs/highlight"; import { type HighlightedLayer, colorHighlightedLayer } from "../libs/highlight";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import "../maplibregl.css"; import "../maplibregl.css";
@@ -71,199 +71,65 @@ type MapMaplibreGlInternalProps = {
onChange(value: {center: LngLat, zoom: number, _from: "map" | "app"}): unknown onChange(value: {center: LngLat, zoom: number, _from: "map" | "app"}): unknown
} & WithTranslation; } & WithTranslation;
type MapMaplibreGlState = { /**
map: Map | null; * Replacement for the previous `shouldComponentUpdate`. `React.memo` expects the
inspect: MaplibreInspect | null; * inverse: `true` means "props are equal, skip the render", whereas
geocoder: MaplibreGeocoder | null; * `shouldComponentUpdate` returned `true` when it *should* re-render.
zoomControl: ZoomControl | null; * As before, if the props cannot be serialized we treat them as equal and skip
zoom?: number; * the render ("no biggie, carry on").
}; */
function arePropsEqual(prevProps: MapMaplibreGlInternalProps, nextProps: MapMaplibreGlInternalProps) {
class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps, MapMaplibreGlState> { let should = false;
static defaultProps = { try {
onMapLoaded: () => {}, should = JSON.stringify(prevProps) !== JSON.stringify(nextProps);
onDataChange: () => {}, } catch(_e) {
onLayerSelect: () => {}, // no biggie, carry on
onChange: () => {},
options: {} as MapOptions,
};
container: HTMLDivElement | null = null;
constructor(props: MapMaplibreGlInternalProps) {
super(props);
this.state = {
map: null,
inspect: null,
geocoder: null,
zoomControl: null,
};
i18next.on("languageChanged", () => {
this.forceUpdate();
});
} }
return !should;
}
const MapMaplibreGlInternal = ({
onDataChange = () => {},
onLayerSelect = () => {},
onChange = () => {},
options = {},
mapStyle,
mapView,
inspectModeEnabled,
highlightedLayer,
replaceAccessTokens,
t,
}: MapMaplibreGlInternalProps) => {
const container = useRef<HTMLDivElement | null>(null);
shouldComponentUpdate(nextProps: MapMaplibreGlInternalProps, nextState: MapMaplibreGlState) { // These used to live in `this.state`, but were never able to trigger a
let should = false; // re-render: `shouldComponentUpdate` stringified the state, which throws for
try { // the (circular) maplibre `Map`, so the comparison was swallowed and the
should = JSON.stringify(this.props) !== JSON.stringify(nextProps) || JSON.stringify(this.state) !== JSON.stringify(nextState); // component only ever re-rendered on prop changes. They are imperative
} catch(_e) { // handles, so they are refs here and mutating them does not re-render.
// no biggie, carry on const map = useRef<Map | null>(null);
} const inspect = useRef<MaplibreInspect | null>(null);
return should; const geocoder = useRef<MaplibreGeocoder | null>(null);
} const zoomControl = useRef<ZoomControl | null>(null);
const zoom = useRef<number | undefined>(undefined);
componentDidUpdate() { // `componentDidUpdate` did not run on mount, so neither may the effect below.
const map = this.state.map; const hasMounted = useRef(false);
const styleWithTokens = this.props.replaceAccessTokens(this.props.mapStyle); const [, forceUpdate] = useReducer((tick: number) => tick + 1, 0);
if (map) {
// Maplibre GL now does diffing natively so we don't need to calculate
// the necessary operations ourselves!
// We also need to update the style for inspect to work properly
map.setStyle(styleWithTokens, {diff: true});
map.showTileBoundaries = this.props.options?.showTileBoundaries!;
map.showCollisionBoxes = this.props.options?.showCollisionBoxes!;
map.showOverdrawInspector = this.props.options?.showOverdrawInspector!;
// set the map view when the prop was updated from outside // The maplibre event handlers/callbacks below are registered once (on mount)
if (this.props.mapView._from === "app") { // but read `this.props` at call time in the class version, so they must not
map.jumpTo(this.props.mapView); // close over the props of the first render.
} const latestProps = useRef({onDataChange, onLayerSelect, onChange, mapStyle, inspectModeEnabled, highlightedLayer, t});
} latestProps.current = {onDataChange, onLayerSelect, onChange, mapStyle, inspectModeEnabled, highlightedLayer, t};
if(this.state.inspect && this.props.inspectModeEnabled !== this.state.inspect._showInspectMap) { const onLayerSelectById = useCallback((id: string) => {
this.state.inspect.toggleInspector(); const index = latestProps.current.mapStyle.layers.findIndex(layer => layer.id === id);
} latestProps.current.onLayerSelect(index);
if (this.state.inspect && this.props.inspectModeEnabled) { }, []);
this.state.inspect.setOriginalStyle(styleWithTokens);
// In case the sources are the same, there's a need to refresh the style
setTimeout(() => {
this.state.inspect!.render();
}, 500);
}
} const initGeocoder = useCallback((mapInstance: Map) => {
componentDidMount() {
const mapOpts = {
...this.props.options,
container: this.container!,
style: this.props.mapStyle,
hash: true,
maxZoom: 24,
// make root relative urls in stylefiles work as maplibre gl js does
// not support this for everything:
// https://github.com/maplibre/maplibre-gl-js/issues/6818
transformRequest: (url) => {
if (url.startsWith("/")) {
url = `${window.location.origin}${url}`;
}
return { url };
},
// setting to always load glyphs of CJK fonts from server
// https://maplibre.org/maplibre-gl-js/docs/examples/local-ideographs/
localIdeographFontFamily: false
} satisfies MapOptions;
const protocol = new Protocol({metadata: true});
MapLibreGl.addProtocol("pmtiles",protocol.tile);
const map = new MapLibreGl.Map(mapOpts);
const mapViewChange = () => {
const center = map.getCenter();
const zoom = map.getZoom();
this.props.onChange({center, zoom, _from: "map"});
};
mapViewChange();
map.showTileBoundaries = mapOpts.showTileBoundaries!;
map.showCollisionBoxes = mapOpts.showCollisionBoxes!;
map.showOverdrawInspector = mapOpts.showOverdrawInspector!;
const geocoder = this.initGeocoder(map);
const zoomControl = new ZoomControl();
map.addControl(zoomControl, "top-right");
const nav = new MapLibreGl.NavigationControl({visualizePitch:true});
map.addControl(nav, "top-right");
const tmpNode = document.createElement("div");
const root = createRoot(tmpNode);
const inspectPopup = new MapLibreGl.Popup({
closeOnClick: false
});
const inspect = new MaplibreInspect({
popup: inspectPopup,
showMapPopup: true,
showMapPopupOnHover: false,
showInspectMapPopupOnHover: true,
showInspectButton: false,
blockHoverPopupOnClick: true,
assignLayerColor: (layerId: string, alpha: number) => {
return Color(colors.brightColor(layerId, alpha)).desaturate(0.5).string();
},
buildInspectStyle: (originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[]) => buildInspectStyle(originalMapStyle, coloredLayers, this.props.highlightedLayer),
renderPopup: (features: InspectFeature[]) => {
if(this.props.inspectModeEnabled) {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlFeaturePropertyPopup features={features} />);
});
return tmpNode;
} else {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlLayerPopup
features={features}
onLayerSelect={this.onLayerSelectById}
zoom={this.state.zoom}
/>,);
});
return tmpNode;
}
}
});
map.addControl(inspect);
map.on("style.load", () => {
this.setState({
map,
inspect,
geocoder,
zoomControl,
zoom: map.getZoom()
});
});
map.on("data", e => {
if(e.dataType !== "tile") return;
this.props.onDataChange!({
map: this.state.map
});
});
map.on("error", e => {
console.log("ERROR", e);
});
map.on("zoom", _e => {
this.setState({
zoom: map.getZoom()
});
});
map.on("dragend", mapViewChange);
map.on("zoomend", mapViewChange);
}
onLayerSelectById = (id: string) => {
const index = this.props.mapStyle.layers.findIndex(layer => layer.id === id);
this.props.onLayerSelect(index);
};
initGeocoder(map: Map) {
const geocoderConfig = { const geocoderConfig = {
forwardGeocode: async (config: MaplibreGeocoderApiConfig) => { forwardGeocode: async (config: MaplibreGeocoderApiConfig) => {
const features = []; const features = [];
@@ -300,27 +166,190 @@ class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps,
}; };
}, },
} as unknown as MaplibreGeocoderApi; } as unknown as MaplibreGeocoderApi;
const geocoder = new MaplibreGeocoder(geocoderConfig, { const geocoderInstance = new MaplibreGeocoder(geocoderConfig, {
placeholder: this.props.t("Search"), placeholder: latestProps.current.t("Search"),
maplibregl: MapLibreGl, maplibregl: MapLibreGl,
}); });
map.addControl(geocoder, "top-left"); mapInstance.addControl(geocoderInstance, "top-left");
return geocoder; return geocoderInstance;
} }, []);
render() { // Was the `i18next.on("languageChanged", () => this.forceUpdate())` in the
const t = this.props.t; // constructor. `forceUpdate` bypassed `shouldComponentUpdate`; a state update
this.state.geocoder?.setPlaceholder(t("Search")); // likewise bypasses `React.memo`.
this.state.zoomControl?.setLabel(t("Zoom:")); useEffect(() => {
return <div const onLanguageChanged = () => {
className="maputnik-map__map" forceUpdate();
role="region" };
aria-label={t("Map view")} i18next.on("languageChanged", onLanguageChanged);
ref={x => {this.container = x;}} return () => {
data-wd-key="maplibre:map" i18next.off("languageChanged", onLanguageChanged);
></div>; };
} }, []);
}
const MapMaplibreGl = withTranslation()(MapMaplibreGlInternal); // componentDidMount
export default MapMaplibreGl; useEffect(() => {
const mapOpts = {
...options,
container: container.current!,
style: mapStyle,
hash: true,
maxZoom: 24,
// make root relative urls in stylefiles work as maplibre gl js does
// not support this for everything:
// https://github.com/maplibre/maplibre-gl-js/issues/6818
transformRequest: (url) => {
if (url.startsWith("/")) {
url = `${window.location.origin}${url}`;
}
return { url };
},
// setting to always load glyphs of CJK fonts from server
// https://maplibre.org/maplibre-gl-js/docs/examples/local-ideographs/
localIdeographFontFamily: false
} satisfies MapOptions;
const protocol = new Protocol({metadata: true});
MapLibreGl.addProtocol("pmtiles",protocol.tile);
const mapInstance = new MapLibreGl.Map(mapOpts);
const mapViewChange = () => {
const center = mapInstance.getCenter();
const currentZoom = mapInstance.getZoom();
latestProps.current.onChange({center, zoom: currentZoom, _from: "map"});
};
mapViewChange();
mapInstance.showTileBoundaries = mapOpts.showTileBoundaries!;
mapInstance.showCollisionBoxes = mapOpts.showCollisionBoxes!;
mapInstance.showOverdrawInspector = mapOpts.showOverdrawInspector!;
const geocoderInstance = initGeocoder(mapInstance);
const zoomControlInstance = new ZoomControl();
mapInstance.addControl(zoomControlInstance, "top-right");
const nav = new MapLibreGl.NavigationControl({visualizePitch:true});
mapInstance.addControl(nav, "top-right");
const tmpNode = document.createElement("div");
const root = createRoot(tmpNode);
const inspectPopup = new MapLibreGl.Popup({
closeOnClick: false
});
const inspectInstance = new MaplibreInspect({
popup: inspectPopup,
showMapPopup: true,
showMapPopupOnHover: false,
showInspectMapPopupOnHover: true,
showInspectButton: false,
blockHoverPopupOnClick: true,
assignLayerColor: (layerId: string, alpha: number) => {
return Color(colors.brightColor(layerId, alpha)).desaturate(0.5).string();
},
buildInspectStyle: (originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[]) => buildInspectStyle(originalMapStyle, coloredLayers, latestProps.current.highlightedLayer),
renderPopup: (features: InspectFeature[]) => {
if(latestProps.current.inspectModeEnabled) {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlFeaturePropertyPopup features={features} />);
});
return tmpNode;
} else {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlLayerPopup
features={features}
onLayerSelect={onLayerSelectById}
zoom={zoom.current}
/>,);
});
return tmpNode;
}
}
});
mapInstance.addControl(inspectInstance);
mapInstance.on("style.load", () => {
map.current = mapInstance;
inspect.current = inspectInstance;
geocoder.current = geocoderInstance;
zoomControl.current = zoomControlInstance;
zoom.current = mapInstance.getZoom();
});
mapInstance.on("data", e => {
if(e.dataType !== "tile") return;
latestProps.current.onDataChange!({
map: map.current
});
});
mapInstance.on("error", e => {
console.log("ERROR", e);
});
mapInstance.on("zoom", _e => {
zoom.current = mapInstance.getZoom();
});
mapInstance.on("dragend", mapViewChange);
mapInstance.on("zoomend", mapViewChange);
// Mount only, exactly like componentDidMount: the map is created from the
// props of the first render and kept up to date imperatively below (the
// handlers read `latestProps`, so no prop belongs in the dependencies). The
// class had no componentWillUnmount, so there is no teardown here either.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only: adding mapStyle/options would re-create the map
}, [initGeocoder, onLayerSelectById]);
// componentDidUpdate. It had no `prevProps` guards, so it ran after *every*
// re-render; the equivalent is an effect without a dependency array. Since the
// component only re-renders when `arePropsEqual` reports a prop change (or on
// a language change, as before), this runs exactly as often as it used to.
useEffect(() => {
if (!hasMounted.current) {
// componentDidUpdate does not run on mount.
hasMounted.current = true;
return;
}
const styleWithTokens = replaceAccessTokens(mapStyle);
if (map.current) {
// Maplibre GL now does diffing natively so we don't need to calculate
// the necessary operations ourselves!
// We also need to update the style for inspect to work properly
map.current.setStyle(styleWithTokens, {diff: true});
map.current.showTileBoundaries = options?.showTileBoundaries!;
map.current.showCollisionBoxes = options?.showCollisionBoxes!;
map.current.showOverdrawInspector = options?.showOverdrawInspector!;
// set the map view when the prop was updated from outside
if (mapView._from === "app") {
map.current.jumpTo(mapView);
}
}
if(inspect.current && inspectModeEnabled !== inspect.current._showInspectMap) {
inspect.current.toggleInspector();
}
if (inspect.current && inspectModeEnabled) {
inspect.current.setOriginalStyle(styleWithTokens);
// In case the sources are the same, there's a need to refresh the style
setTimeout(() => {
inspect.current!.render();
}, 500);
}
});
geocoder.current?.setPlaceholder(t("Search"));
zoomControl.current?.setLabel(t("Zoom:"));
return <div
className="maputnik-map__map"
role="region"
aria-label={t("Map view")}
ref={container}
data-wd-key="maplibre:map"
></div>;
};
export const MapMaplibreGl = withTranslation()(React.memo(MapMaplibreGlInternal, arePropsEqual));
@@ -63,18 +63,13 @@ type FeaturePropertyPopupProps = {
features: InspectFeature[] features: InspectFeature[]
}; };
class FeaturePropertyPopup extends React.Component<FeaturePropertyPopupProps> { export const FeaturePropertyPopup: React.FC<FeaturePropertyPopupProps> = (props) => {
render() { const features = removeDuplicatedFeatures(props.features);
const features = removeDuplicatedFeatures(this.props.features); return <div className="maputnik-feature-property-popup" dir="ltr" data-wd-key="feature-property-popup">
return <div className="maputnik-feature-property-popup" dir="ltr" data-wd-key="feature-property-popup"> <table className="maputnik-popup-table">
<table className="maputnik-popup-table"> <tbody>
<tbody> {features.map(renderFeature)}
{features.map(renderFeature)} </tbody>
</tbody> </table>
</table> </div>;
</div>; };
}
}
export default FeaturePropertyPopup;
+38 -43
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import IconLayer from "./IconLayer"; import { IconLayer } from "./IconLayer";
import type {InspectFeature} from "./MapMaplibreGlFeaturePropertyPopup"; import type {InspectFeature} from "./MapMaplibreGlFeaturePropertyPopup";
function groupFeaturesBySourceLayer(features: InspectFeature[]) { function groupFeaturesBySourceLayer(features: InspectFeature[]) {
@@ -32,8 +32,8 @@ type FeatureLayerPopupProps = {
zoom?: number zoom?: number
}; };
class FeatureLayerPopup extends React.Component<FeatureLayerPopupProps> { export const FeatureLayerPopup: React.FC<FeatureLayerPopupProps> = (props) => {
_getFeatureColor(feature: InspectFeature, _zoom?: number) { function _getFeatureColor(feature: InspectFeature, _zoom?: number) {
// Guard because openlayers won't have this // Guard because openlayers won't have this
if (!feature.layer.paint) { if (!feature.layer.paint) {
return; return;
@@ -65,50 +65,45 @@ class FeatureLayerPopup extends React.Component<FeatureLayerPopupProps> {
} }
} }
render() { const sources = groupFeaturesBySourceLayer(props.features);
const sources = groupFeaturesBySourceLayer(this.props.features);
const items = Object.keys(sources).map(vectorLayerId => { const items = Object.keys(sources).map(vectorLayerId => {
const layers = sources[vectorLayerId].map((feature: InspectFeature, idx: number) => { const layers = sources[vectorLayerId].map((feature: InspectFeature, idx: number) => {
const featureColor = this._getFeatureColor(feature, this.props.zoom); const featureColor = _getFeatureColor(feature, props.zoom);
return <div return <div
key={idx} key={idx}
className="maputnik-popup-layer" className="maputnik-popup-layer"
>
<div
className="maputnik-popup-layer__swatch"
style={{background: featureColor}}
></div>
<label
className="maputnik-popup-layer__label"
onClick={() => {
props.onLayerSelect(feature.layer.id);
}}
> >
<div {feature.layer.type &&
className="maputnik-popup-layer__swatch" <IconLayer type={feature.layer.type} style={{
style={{background: featureColor}} width: 14,
></div> height: 14,
<label paddingRight: 3
className="maputnik-popup-layer__label" }}/>
onClick={() => { }
this.props.onLayerSelect(feature.layer.id); {feature.layer.id}
}} {feature.counter && <span> × {feature.counter}</span>}
> </label>
{feature.layer.type &&
<IconLayer type={feature.layer.type} style={{
width: 14,
height: 14,
paddingRight: 3
}}/>
}
{feature.layer.id}
{feature.counter && <span> × {feature.counter}</span>}
</label>
</div>;
});
return <div key={vectorLayerId}>
<div className="maputnik-popup-layer-id">{vectorLayerId}</div>
{layers}
</div>; </div>;
}); });
return <div key={vectorLayerId}>
return <div className="maputnik-feature-layer-popup" data-wd-key="feature-layer-popup" dir="ltr"> <div className="maputnik-popup-layer-id">{vectorLayerId}</div>
{items} {layers}
</div>; </div>;
} });
}
return <div className="maputnik-feature-layer-popup" data-wd-key="feature-layer-popup" dir="ltr">
export default FeatureLayerPopup; {items}
</div>;
};
+140 -130
View File
@@ -1,8 +1,8 @@
import React from "react"; import {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {throttle} from "lodash"; import {throttle} from "lodash";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import MapMaplibreGlLayerPopup from "./MapMaplibreGlLayerPopup"; import { FeatureLayerPopup as MapMaplibreGlLayerPopup } from "./MapMaplibreGlLayerPopup";
import "ol/ol.css"; import "ol/ol.css";
//@ts-ignore //@ts-ignore
@@ -35,56 +35,52 @@ type MapOpenLayersInternalProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
type MapOpenLayersState = { const MapOpenLayersInternal = ({
zoom: string onLayerSelect = () => {},
rotation: string mapStyle,
cursor: string[] style,
center: string[] debugToolbox,
selectedFeatures?: any[] replaceAccessTokens,
}; onChange,
t,
}: MapOpenLayersInternalProps) => {
const [zoom, setZoom] = useState("0");
const [rotation, setRotation] = useState("0");
const [cursor, setCursor] = useState<string[]>([]);
const [center, setCenter] = useState<string[]>([]);
// Never assigned in the class version either, kept for the popup below.
const [selectedFeatures] = useState<any[] | undefined>(undefined);
class MapOpenLayersInternal extends React.Component<MapOpenLayersInternalProps, MapOpenLayersState> { // Imperative handles: they were plain instance fields, so they are refs and
static defaultProps = { // mutating them must not re-render.
onMapLoaded: () => {}, const map = useRef<Map | null>(null);
onDataChange: () => {}, const container = useRef<HTMLDivElement | null>(null);
onLayerSelect: () => {}, const overlay = useRef<Overlay | undefined>(undefined);
}; const popupContainer = useRef<HTMLDivElement | null>(null);
updateStyle: any;
map: any;
container: HTMLDivElement | null = null;
overlay: Overlay | undefined;
popupContainer: HTMLElement | null = null;
constructor(props: MapOpenLayersInternalProps) { // componentDidUpdate did not run on mount, and componentDidMount already
super(props); // applies the initial style, so the style effect below must skip the mount.
this.state = { const hasMounted = useRef(false);
zoom: "0",
rotation: "0",
cursor: [] as string[],
center: [],
};
this.updateStyle = throttle(this._updateStyle.bind(this), 200);
}
_updateStyle(newMapStyle: StyleSpecification) { // The openlayers event handlers are registered on mount but read `this.props`
if(!this.map) return; // at call time in the class version.
const latestProps = useRef({mapStyle, replaceAccessTokens, onChange});
latestProps.current = {mapStyle, replaceAccessTokens, onChange};
// Was `this.updateStyle = throttle(this._updateStyle.bind(this), 200)` in the
// constructor: created once per instance, so it is memoized once here.
const updateStyle = useMemo(() => throttle((newMapStyle: StyleSpecification) => {
if(!map.current) return;
// See <https://github.com/openlayers/ol-mapbox-style/issues/215#issuecomment-493198815> // See <https://github.com/openlayers/ol-mapbox-style/issues/215#issuecomment-493198815>
this.map.getLayers().clear(); map.current.getLayers().clear();
apply(this.map, newMapStyle); apply(map.current, newMapStyle);
} }, 200), []);
componentDidUpdate(prevProps: MapOpenLayersInternalProps) { // componentDidMount
if (this.props.mapStyle !== prevProps.mapStyle) { useEffect(() => {
this.updateStyle( overlay.current = new Overlay({
this.props.replaceAccessTokens(this.props.mapStyle) element: popupContainer.current!,
);
}
}
componentDidMount() {
this.overlay = new Overlay({
element: this.popupContainer!,
autoPan: { autoPan: {
animation: { animation: {
duration: 250 duration: 250
@@ -92,117 +88,131 @@ class MapOpenLayersInternal extends React.Component<MapOpenLayersInternalProps,
}, },
}); });
const map = new Map({ const mapInstance = new Map({
target: this.container!, target: container.current!,
overlays: [this.overlay], overlays: [overlay.current],
view: new View({ view: new View({
zoom: 1, zoom: 1,
center: [180, -90], center: [180, -90],
}) })
}); });
map.on("pointermove", (evt) => { mapInstance.on("pointermove", (evt) => {
const coords = toLonLat(evt.coordinate); const coords = toLonLat(evt.coordinate);
this.setState({ setCursor([
cursor: [ coords[0].toFixed(2),
coords[0].toFixed(2), coords[1].toFixed(2)
coords[1].toFixed(2) ]);
]
});
}); });
const onMoveEnd = () => { const onMoveEnd = () => {
const zoom = map.getView().getZoom(); const currentZoom = mapInstance.getView().getZoom();
const center = toLonLat(map.getView().getCenter()!); const currentCenter = toLonLat(mapInstance.getView().getCenter()!);
this.props.onChange({ latestProps.current.onChange({
zoom, zoom: currentZoom,
center: { center: {
lng: center[0], lng: currentCenter[0],
lat: center[1], lat: currentCenter[1],
}, },
}); });
}; };
onMoveEnd(); onMoveEnd();
map.on("moveend", onMoveEnd); mapInstance.on("moveend", onMoveEnd);
map.on("postrender", (_e) => { mapInstance.on("postrender", (_e) => {
const center = toLonLat(map.getView().getCenter()!); const currentCenter = toLonLat(mapInstance.getView().getCenter()!);
this.setState({ setCenter([
center: [ currentCenter[0].toFixed(2),
center[0].toFixed(2), currentCenter[1].toFixed(2),
center[1].toFixed(2), ]);
], setRotation(mapInstance.getView().getRotation().toFixed(2));
rotation: map.getView().getRotation().toFixed(2), setZoom(mapInstance.getView().getZoom()!.toFixed(2));
zoom: map.getView().getZoom()!.toFixed(2)
});
}); });
this.map = map; map.current = mapInstance;
this.updateStyle( updateStyle(
this.props.replaceAccessTokens(this.props.mapStyle) latestProps.current.replaceAccessTokens(latestProps.current.mapStyle) as StyleSpecification
); );
} // Mount only, like componentDidMount. The class had no componentWillUnmount,
// so there is no teardown here either. The props read above are read from
// latestProps/the first render on purpose, so they are not dependencies.
}, [updateStyle]);
closeOverlay = (e: any) => { // componentDidUpdate: `if (this.props.mapStyle !== prevProps.mapStyle)`.
useEffect(() => {
if (!hasMounted.current) {
// componentDidUpdate does not run on mount; componentDidMount already
// applied the initial style, and applying it twice would push a second
// (throttled) clear + apply through ol-mapbox-style.
hasMounted.current = true;
return;
}
updateStyle(
replaceAccessTokens(mapStyle) as StyleSpecification
);
// `replaceAccessTokens` is deliberately *not* a dependency: the parent
// re-creates it on every render, so depending on it would re-apply the style
// on every render instead of only when `mapStyle` changes, which is exactly
// what the `this.props.mapStyle !== prevProps.mapStyle` guard checked.
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above: replaceAccessTokens is a new identity each render
}, [mapStyle, updateStyle]);
const closeOverlay = useCallback((e: any) => {
e.target.blur(); e.target.blur();
this.overlay!.setPosition(undefined); overlay.current!.setPosition(undefined);
}; }, []);
render() { return <div className="maputnik-ol-container">
const t = this.props.t; <div
return <div className="maputnik-ol-container"> ref={popupContainer}
<div style={{background: "black"}}
ref={x => {this.popupContainer = x;}} className="maputnik-popup"
style={{background: "black"}} >
className="maputnik-popup" <button
className="maplibregl-popup-close-button"
onClick={closeOverlay}
aria-label={t("Close popup")}
> >
<button ×
className="maplibregl-popup-close-button" </button>
onClick={this.closeOverlay} <MapMaplibreGlLayerPopup
aria-label={t("Close popup")} features={selectedFeatures || []}
> onLayerSelect={onLayerSelect}
× />
</button> </div>
<MapMaplibreGlLayerPopup <div className="maputnik-ol-zoom">
features={this.state.selectedFeatures || []} {t("Zoom:")} {zoom}
onLayerSelect={this.props.onLayerSelect} </div>
/> {debugToolbox &&
</div> <div className="maputnik-ol-debug">
<div className="maputnik-ol-zoom"> <div>
{t("Zoom:")} {this.state.zoom} <label>{t("cursor:")} </label>
</div> <span>{renderCoords(cursor)}</span>
{this.props.debugToolbox && </div>
<div className="maputnik-ol-debug"> <div>
<div> <label>{t("center:")} </label>
<label>{t("cursor:")} </label> <span>{renderCoords(center)}</span>
<span>{renderCoords(this.state.cursor)}</span> </div>
</div> <div>
<div> <label>{t("rotation:")} </label>
<label>{t("center:")} </label> <span>{rotation}</span>
<span>{renderCoords(this.state.center)}</span>
</div>
<div>
<label>{t("rotation:")} </label>
<span>{this.state.rotation}</span>
</div>
</div> </div>
}
<div
className="maputnik-ol"
ref={x => {this.container = x;}}
role="region"
aria-label={t("Map view")}
style={{
...this.props.style,
}}>
</div> </div>
</div>; }
} <div
} className="maputnik-ol"
ref={container}
role="region"
aria-label={t("Map view")}
style={{
...style,
}}>
</div>
</div>;
};
const MapOpenLayers = withTranslation()(MapOpenLayersInternal); export const MapOpenLayers = withTranslation()(MapOpenLayersInternal);
export default MapOpenLayers;
+28 -30
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import FieldFunction from "./FieldFunction"; import { FieldFunction } from "./FieldFunction";
import type {LayerSpecification} from "maplibre-gl"; import type {LayerSpecification} from "maplibre-gl";
import { type MappedLayerErrors } from "../libs/definitions"; import { type MappedLayerErrors } from "../libs/definitions";
@@ -40,37 +40,35 @@ type PropertyGroupProps = {
errors?: MappedLayerErrors errors?: MappedLayerErrors
}; };
export default class PropertyGroup extends React.Component<PropertyGroupProps> { export const PropertyGroup: React.FC<PropertyGroupProps> = (props) => {
onPropertyChange = (property: string, newValue: any) => { const onPropertyChange = (property: string, newValue: any) => {
const group = getGroupName(this.props.spec, this.props.layer.type, property); const group = getGroupName(props.spec, props.layer.type, property);
this.props.onChange(group ,property, newValue); props.onChange(group ,property, newValue);
}; };
render() { const {errors} = props;
const {errors} = this.props; const fields = props.groupFields.map(fieldName => {
const fields = this.props.groupFields.map(fieldName => { const fieldSpec = getFieldSpec(props.spec, props.layer.type, fieldName);
const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName);
const paint = this.props.layer.paint || {}; const paint = props.layer.paint || {};
const layout = this.props.layer.layout || {}; const layout = props.layer.layout || {};
const fieldValue = fieldName in paint const fieldValue = fieldName in paint
? paint[fieldName as keyof typeof paint] ? paint[fieldName as keyof typeof paint]
: layout[fieldName as keyof typeof layout]; : layout[fieldName as keyof typeof layout];
const fieldType = fieldName in paint ? "paint" : "layout"; const fieldType = fieldName in paint ? "paint" : "layout";
return <FieldFunction return <FieldFunction
errors={errors} errors={errors}
onChange={this.onPropertyChange} onChange={onPropertyChange}
key={fieldName} key={fieldName}
fieldName={fieldName} fieldName={fieldName}
value={fieldValue} value={fieldValue}
fieldType={fieldType} fieldType={fieldType}
fieldSpec={fieldSpec} fieldSpec={fieldSpec}
/>; />;
}); });
return <div className="maputnik-property-group"> return <div className="maputnik-property-group">
{fields} {fields}
</div>; </div>;
} };
}
+5 -7
View File
@@ -4,10 +4,8 @@ type ScrollContainerProps = {
children?: React.ReactNode children?: React.ReactNode
}; };
export default class ScrollContainer extends React.Component<ScrollContainerProps> { export const ScrollContainer: React.FC<ScrollContainerProps> = (props) => {
render() { return <div className="maputnik-scroll-container">
return <div className="maputnik-scroll-container"> {props.children}
{this.props.children} </div>;
</div>; };
}
}
+41 -44
View File
@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import {otherFilterOps} from "../libs/filterops"; import {otherFilterOps} from "../libs/filterops";
import InputString from "./InputString"; import { InputString } from "./InputString";
import InputAutocomplete from "./InputAutocomplete"; import { InputAutocomplete } from "./InputAutocomplete";
import InputSelect from "./InputSelect"; import { InputSelect } from "./InputSelect";
function tryParseInt(v: string | number) { function tryParseInt(v: string | number) {
if (v === "") return v; if (v === "") return v;
@@ -40,53 +40,50 @@ type SingleFilterEditorProps = {
properties?: {[key: string]: string} properties?: {[key: string]: string}
}; };
export default class SingleFilterEditor extends React.Component<SingleFilterEditorProps> { export const SingleFilterEditor: React.FC<SingleFilterEditorProps> = ({
static defaultProps = { properties = {},
properties: {}, ...props
}; }) => {
function onFilterPartChanged(filterOp: string, propertyName: string, filterArgs: string[]) {
onFilterPartChanged(filterOp: string, propertyName: string, filterArgs: string[]) {
let newFilter = [filterOp, propertyName, ...filterArgs.map(parseFilter)]; let newFilter = [filterOp, propertyName, ...filterArgs.map(parseFilter)];
if(filterOp === "has" || filterOp === "!has") { if(filterOp === "has" || filterOp === "!has") {
newFilter = [filterOp, propertyName]; newFilter = [filterOp, propertyName];
} else if(filterArgs.length === 0) { } else if(filterArgs.length === 0) {
newFilter = [filterOp, propertyName, ""]; newFilter = [filterOp, propertyName, ""];
} }
this.props.onChange(newFilter); props.onChange(newFilter);
} }
render() { const f = props.filter;
const f = this.props.filter; const filterOp = f[0];
const filterOp = f[0]; const propertyName = f[1];
const propertyName = f[1]; const filterArgs = f.slice(2);
const filterArgs = f.slice(2);
return <div className="maputnik-filter-editor-single"> return <div className="maputnik-filter-editor-single">
<div className="maputnik-filter-editor-property"> <div className="maputnik-filter-editor-property">
<InputAutocomplete <InputAutocomplete
aria-label="key" aria-label="key"
value={propertyName} value={propertyName}
options={Object.keys(this.props.properties!).map(propName => [propName, propName])} options={Object.keys(properties).map(propName => [propName, propName])}
onChange={(newPropertyName: string) => this.onFilterPartChanged(filterOp, newPropertyName, filterArgs)} onChange={(newPropertyName: string) => onFilterPartChanged(filterOp, newPropertyName, filterArgs)}
/> />
</div> </div>
<div className="maputnik-filter-editor-operator"> <div className="maputnik-filter-editor-operator">
<InputSelect <InputSelect
aria-label="function" aria-label="function"
value={filterOp} value={filterOp}
onChange={(newFilterOp: string) => this.onFilterPartChanged(newFilterOp, propertyName, filterArgs)} onChange={(newFilterOp: string) => onFilterPartChanged(newFilterOp, propertyName, filterArgs)}
options={otherFilterOps} options={otherFilterOps}
/> />
</div> </div>
{filterArgs.length > 0 && {filterArgs.length > 0 &&
<div className="maputnik-filter-editor-args"> <div className="maputnik-filter-editor-args">
<InputString <InputString
aria-label="value" aria-label="value"
value={filterArgs.join(",")} value={filterArgs.join(",")}
onChange={(v: string) => this.onFilterPartChanged(filterOp, propertyName, v.split(","))} onChange={(v: string) => onFilterPartChanged(filterOp, propertyName, v.split(","))}
/> />
</div> </div>
} }
</div>; </div>;
} };
}
+9 -12
View File
@@ -7,16 +7,13 @@ type SmallErrorInternalProps = {
children?: React.ReactNode children?: React.ReactNode
} & WithTranslation; } & WithTranslation;
class SmallErrorInternal extends React.Component<SmallErrorInternalProps> { const SmallErrorInternal: React.FC<SmallErrorInternalProps> = (props) => {
render () { const t = props.t;
const t = this.props.t; return (
return ( <div className="SmallError">
<div className="SmallError"> {t("Error:")} {props.children}
{t("Error:")} {this.props.children} </div>
</div> );
); };
}
}
const SmallError = withTranslation()(SmallErrorInternal); export const SmallError = withTranslation()(SmallErrorInternal);
export default SmallError;
+44
View File
@@ -0,0 +1,44 @@
import React from "react";
import { FieldSpec, type FieldSpecProps } from "./FieldSpec";
import { FunctionInputButtons as FunctionButtons } from "./FunctionButtons";
import { labelFromFieldName } from "../libs/label-from-field-name";
type SpecPropertyProps = FieldSpecProps & {
fieldName?: string
fieldType?: string
fieldSpec?: any
value?: any
errors?: {[key: string]: {message: string}}
onZoomClick(): void
onDataClick(): void
onExpressionClick?(): void
onElevationClick?(): void
};
export const SpecProperty: React.FC<SpecPropertyProps> = (props) => {
const {errors = {}, fieldName, fieldType} = props;
const functionBtn = <FunctionButtons
fieldSpec={props.fieldSpec}
onZoomClick={props.onZoomClick}
onDataClick={props.onDataClick}
onExpressionClick={props.onExpressionClick}
onElevationClick={props.onElevationClick}
/>;
const error = errors[fieldType+"."+fieldName as any] as any;
const propsWithDefaults = {...props, errors};
return <FieldSpec
{...propsWithDefaults}
error={error}
fieldSpec={props.fieldSpec}
label={labelFromFieldName(props.fieldName || "")}
action={functionBtn}
/>;
};
+246
View File
@@ -0,0 +1,246 @@
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 { type WithTranslation, withTranslation } from "react-i18next";
import { InputButton } from "./InputButton";
import { InputSpec } from "./InputSpec";
import { InputNumber } from "./InputNumber";
import { InputSelect } from "./InputSelect";
import { Block } from "./Block";
import { DeleteStopButton } from "./DeleteStopButton";
import { labelFromFieldName } from "../libs/label-from-field-name";
import { generateUniqueId as docUid } from "../libs/document-uid";
import { sortNumerically } from "../libs/sort-numerically";
import { type MappedLayerErrors } from "../libs/definitions";
/**
* We cache a reference for each stop by its index.
*
* When the stops are reordered the references are also updated (see this.orderStops) this allows React to use the same key for the element and keep keyboard focus.
*/
function setStopRefs(props: ZoomPropertyInternalProps, state: ZoomPropertyState) {
// This is initialised below only if required to improved performance.
let newRefs: {[key: number]: string} = {};
if(props.value && (props.value as ZoomWithStops).stops) {
(props.value as ZoomWithStops).stops.forEach((_val, idx: number) => {
if(Object.prototype.hasOwnProperty.call(!state.refs, idx)) {
if(!newRefs) {
newRefs = {...state};
}
newRefs[idx] = docUid("stop-");
} else {
newRefs[idx] = state.refs[idx];
}
});
}
return newRefs;
}
type ZoomWithStops = {
stops: [number | undefined, number][]
base?: number
};
type ZoomPropertyInternalProps = {
onChange?(...args: unknown[]): unknown
onChangeToDataFunction?(...args: unknown[]): unknown
onDeleteStop?(...args: unknown[]): unknown
onAddStop?(...args: unknown[]): unknown
onExpressionClick?(...args: unknown[]): unknown
fieldType?: string
fieldName: string
fieldSpec?: {
"property-type"?: string
"function-type"?: string
}
errors?: MappedLayerErrors
value?: ZoomWithStops
} & WithTranslation;
type ZoomPropertyState = {
refs: {[key: number]: string}
};
const ZoomPropertyInternal: React.FC<ZoomPropertyInternalProps> = ({ errors = {}, ...rest }) => {
const props = { errors, ...rest } as ZoomPropertyInternalProps;
// The stop refs never reach the rendered output (the row key is derived from
// the stop itself), so they live in a ref rather than state: keeping them in
// state would mean setting state during render on every pass.
const refs = useRef<{[key: number]: string}>({});
refs.current = setStopRefs(props, { refs: refs.current });
// Order the stops altering the refs to reflect their new position.
function orderStopsByZoom(stops: ZoomWithStops["stops"]) {
const mappedWithRef = stops
.map((stop, idx) => {
return {
ref: refs.current[idx],
data: stop
};
})
// Sort by zoom
.sort((a, b) => sortNumerically(a.data[0]!, b.data[0]!));
// Fetch the new position of the stops
const newRefs: {[key:number]: string} = {};
mappedWithRef
.forEach((stop, idx) =>{
newRefs[idx] = stop.ref;
});
refs.current = newRefs;
return mappedWithRef.map((item) => item.data);
}
function changeZoomStop(changeIdx: number, stopData: number | undefined, value: number) {
const stops = (props.value as ZoomWithStops).stops.slice(0);
stops[changeIdx] = [stopData, value];
const orderedStops = orderStopsByZoom(stops);
const changedValue = {
...props.value as ZoomWithStops,
stops: orderedStops
};
props.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);
}
const changeDataType = (type: string) => {
if (type !== "interpolate" && props.onChangeToDataFunction) {
props.onChangeToDataFunction(type);
}
};
function getDataFunctionTypes(fieldSpec: {
"property-type"?: string
"function-type"?: string
}) {
if (fieldSpec["property-type"] === "data-driven") {
return ["interpolate", "categorical", "interval", "exponential", "identity"];
}
else {
return ["interpolate"];
}
}
const t = props.t;
const zoomFields = props.value?.stops.map((stop, idx) => {
const zoomLevel = stop[0];
const value = stop[1];
const deleteStopBtn = <DeleteStopButton onClick={props.onDeleteStop?.bind(null, idx)} />;
return <tr
key={`${stop[0]}-${stop[1]}`}
>
<td>
<InputNumber
aria-label={t("Zoom")}
value={zoomLevel}
onChange={changedStop => changeZoomStop(idx, changedStop, value)}
min={0}
max={22}
/>
</td>
<td>
<InputSpec
aria-label={t("Output value")}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec as any}
value={value}
onChange={(_, newValue) => changeZoomStop(idx, zoomLevel, newValue as number)}
/>
</td>
<td>
{deleteStopBtn}
</td>
</tr>;
});
// return <div className="maputnik-zoom-spec-property">
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")}
data-wd-key="function-type"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={"interpolate"}
onChange={(propVal: string) => changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={getDataFunctionTypes(props.fieldSpec!)}
/>
</div>
</Block>
<Block
label={t("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 | undefined)}
/>
</div>
</Block>
<div className="maputnik-function-stop">
<table className="maputnik-function-stop-table maputnik-function-stop-table--zoom">
<caption>{t("Stops")}</caption>
<thead>
<tr>
<th>{t("Zoom")}</th>
<th rowSpan={2}>{t("Output value")}</th>
</tr>
</thead>
<tbody>
{zoomFields}
</tbody>
</table>
</div>
<div className="maputnik-toolbox">
<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 ZoomProperty = withTranslation()(ZoomPropertyInternal);
-384
View File
@@ -1,384 +0,0 @@
import React 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 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];
class DataPropertyInternal extends React.Component<DataPropertyInternalProps, DataPropertyState> {
state = {
refs: {} as {[key: number]: string}
};
componentDidMount() {
const newRefs = setStopRefs(this.props, this.state);
if(newRefs) {
this.setState({
refs: newRefs
});
}
}
static getDerivedStateFromProps(props: Readonly<DataPropertyInternalProps>, state: DataPropertyState) {
const newRefs = setStopRefs(props, state);
if(newRefs) {
return {
refs: newRefs
};
}
return null;
}
getFieldFunctionType(fieldSpec: any) {
if (fieldSpec.expression.interpolated) {
return "exponential";
}
if (fieldSpec.type === "number") {
return "interval";
}
return "categorical";
}
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.
orderStopsByZoom(stops: Stop[]) {
const mappedWithRef = stops
.map((stop, idx) => {
return {
ref: this.state.refs[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;
});
this.setState({
refs: newRefs
});
return mappedWithRef.map((item) => item.data);
}
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(this.props.fieldSpec as any)],
[{zoom: 10, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec as any)]
],
...value,
};
}
this.props.onChange!(fieldName, value);
};
changeStop(changeIdx: number, stopData: { zoom: number | undefined, value: number }, value: number) {
const stops = this.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 = this.orderStopsByZoom(stops);
const changedValue = {
...this.props.value,
stops: orderedStops,
};
this.onChange(this.props.fieldName, changedValue);
}
changeBase(newValue: number | undefined) {
const changedValue = {
...this.props.value,
base: newValue
};
if (changedValue.base === undefined) {
delete changedValue["base"];
}
this.props.onChange!(this.props.fieldName, changedValue);
}
changeDataType(propVal: string) {
if (propVal === "interpolate" && this.props.onChangeToZoomFunction) {
this.props.onChangeToZoomFunction();
}
else {
this.onChange(this.props.fieldName, {
...this.props.value,
type: propVal,
});
}
}
changeDataProperty(propName: "property" | "default", propVal: any) {
if (propVal) {
this.props.value![propName] = propVal;
}
else {
delete this.props.value![propName];
}
this.onChange(this.props.fieldName, this.props.value);
}
render() {
const t = this.props.t;
if (typeof this.props.value?.type === "undefined") {
this.props.value!.type = this.getFieldFunctionType(this.props.fieldSpec);
}
let dataFields;
if (this.props.value?.stops) {
dataFields = this.props.value.stops.map((stop, idx) => {
const zoomLevel = typeof stop[0] === "object" ? stop[0].zoom : undefined;
const key = this.state.refs[idx];
const dataLevel = typeof stop[0] === "object" ? stop[0].value : stop[0];
const value = stop[1];
const deleteStopBtn = <DeleteStopButton onClick={this.props.onDeleteStop?.bind(this, idx)} />;
const dataProps = {
"aria-label": t("Input value"),
label: t("Data value"),
value: dataLevel as any,
onChange: (newData: string | number | undefined) => this.changeStop(idx, { zoom: zoomLevel, value: newData as number }, value)
};
let dataInput;
if(this.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 => this.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={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={value}
onChange={(_, newValue) => this.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(this.props.fieldName)}</legend>
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
key="function"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={this.props.value!.type}
onChange={(propVal: string) => this.changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={this.getDataFunctionTypes(this.props.fieldSpec)}
/>
</div>
</Block>
{this.props.value?.type !== "identity" &&
<Block
label={t("Base")}
key="base"
>
<div className="maputnik-data-spec-property-input">
<InputSpec
fieldName={"base"}
fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }}
value={this.props.value?.base}
onChange={(_, newValue) => this.changeBase(newValue as number)}
/>
</div>
</Block>
}
<Block
label={"Property"}
key="property"
>
<div className="maputnik-data-spec-property-input">
<InputString
value={this.props.value?.property}
title={t("Input a data property to base styles off of.")}
onChange={propVal => this.changeDataProperty("property", propVal)}
/>
</div>
</Block>
{dataFields &&
<Block
label={t("Default")}
key="default"
>
<InputSpec
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={this.props.value?.default}
onChange={(_, propVal) => this.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={this.props.onAddStop?.bind(this)}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add stop")}
</InputButton>
}
<InputButton
className="maputnik-add-stop"
onClick={this.props.onExpressionClick?.bind(this)}
>
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
{t("Convert to expression")}
</InputButton>
</div>
</div>
</fieldset>
</div>;
}
}
const DataProperty = withTranslation()(DataPropertyInternal);
export default DataProperty;
-27
View File
@@ -1,27 +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;
class DeleteStopButtonInternal extends React.Component<DeleteStopButtonInternalProps> {
render() {
const t = this.props.t;
return <InputButton
className="maputnik-delete-stop"
onClick={this.props.onClick}
title={t("Remove zoom level from stop")}
>
<MdDelete />
</InputButton>;
}
}
const DeleteStopButton = withTranslation()(DeleteStopButtonInternal);
export default DeleteStopButton;
-94
View File
@@ -1,94 +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;
class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInternalProps> {
static defaultProps = {
errors: {},
onFocus: () => {},
onBlur: () => {},
};
constructor(props: ExpressionPropertyInternalProps) {
super(props);
this.state = {
jsonError: false,
};
}
render() {
const {t, value, canUndo} = this.props;
const undoDisabled = canUndo ? !canUndo() : true;
const deleteStopBtn = (
<>
{this.props.onUndo &&
<InputButton
key="undo_action"
onClick={this.props.onUndo}
disabled={undoDisabled}
className="maputnik-delete-stop"
title={t("Revert from expression")}
>
<MdUndo />
</InputButton>
}
<InputButton
key="delete_action"
onClick={this.props.onDelete}
className="maputnik-delete-stop"
title={t("Delete expression")}
>
<MdDelete />
</InputButton>
</>
);
let error = undefined;
if (this.props.errors) {
const fieldKey = this.props.fieldType ? this.props.fieldType + "." + this.props.fieldName : this.props.fieldName;
error = this.props.errors[fieldKey];
}
return <Block
fieldSpec={this.props.fieldSpec}
label={t(labelFromFieldName(this.props.fieldName))}
action={deleteStopBtn}
wideMode={true}
error={error}
>
<FieldJson
lintType="expression"
spec={this.props.fieldSpec}
className="maputnik-expression-editor"
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
value={value}
onChange={this.props.onChange}
/>
</Block>;
}
}
const ExpressionProperty = withTranslation()(ExpressionPropertyInternal);
export default ExpressionProperty;
-71
View File
@@ -1,71 +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;
class FunctionInputButtonsInternal extends React.Component<FunctionInputButtonsInternalProps> {
render() {
const t = this.props.t;
if (this.props.fieldSpec.expression?.parameters.includes("zoom")) {
const expressionInputButton = (
<InputButton
className="maputnik-make-zoom-function"
onClick={this.props.onExpressionClick}
title={t("Convert to expression")}
>
<TbMathFunction />
</InputButton>
);
const makeZoomInputButton = <InputButton
className="maputnik-make-zoom-function"
onClick={this.props.onZoomClick}
title={t("Convert property into a zoom function")}
>
<MdFunctions />
</InputButton>;
let makeDataInputButton;
if (this.props.fieldSpec["property-type"] === "data-driven") {
makeDataInputButton = <InputButton
className="maputnik-make-data-function"
onClick={this.props.onDataClick}
title={t("Convert property to data function")}
>
<MdInsertChart />
</InputButton>;
}
return <div>
{expressionInputButton}
{makeDataInputButton}
{makeZoomInputButton}
</div>;
} else if (this.props.fieldSpec.expression?.parameters.includes("elevation")) {
const inputElevationButton = <InputButton
className="maputnik-make-elevation-function"
onClick={this.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>;
}
}
}
const FunctionInputButtons = withTranslation()(FunctionInputButtonsInternal);
export default FunctionInputButtons;
-48
View File
@@ -1,48 +0,0 @@
import React from "react";
import FieldSpec, {type FieldSpecProps} from "./FieldSpec";
import FunctionButtons from "./_FunctionButtons";
import labelFromFieldName from "../libs/label-from-field-name";
type SpecPropertyProps = FieldSpecProps & {
fieldName?: string
fieldType?: string
fieldSpec?: any
value?: any
errors?: {[key: string]: {message: string}}
onZoomClick(): void
onDataClick(): void
onExpressionClick?(): void
onElevationClick?(): void
};
export default class SpecProperty extends React.Component<SpecPropertyProps> {
static defaultProps = {
errors: {},
};
render() {
const {errors, fieldName, fieldType} = this.props;
const functionBtn = <FunctionButtons
fieldSpec={this.props.fieldSpec}
onZoomClick={this.props.onZoomClick}
onDataClick={this.props.onDataClick}
onExpressionClick={this.props.onExpressionClick}
onElevationClick={this.props.onElevationClick}
/>;
const error = errors![fieldType+"."+fieldName as any] as any;
return <FieldSpec
{...this.props}
error={error}
fieldSpec={this.props.fieldSpec}
label={labelFromFieldName(this.props.fieldName || "")}
action={functionBtn}
/>;
}
}

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