Compare commits

...

31 commits

Author SHA1 Message Date
Yohan Boniface
3b6ff0c57c wip: allow to sync version restore
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:46:59 +01:00
Yohan Boniface
2a460f03b2 wip: add test to make sure saving also save remote dirty datalayers 2025-03-26 18:05:16 +01:00
Yohan Boniface
bbde111fdf wip: do not call document during JS unittests 2025-03-26 18:05:16 +01:00
Yohan Boniface
9c4287ac1e wip: allow to sync/undo filter added/removed from table editor 2025-03-26 18:05:16 +01:00
Yohan Boniface
e9f2ff9a6c wip: permissions does not inherit from ServerStored anymore 2025-03-26 18:05:16 +01:00
Yohan Boniface
12e456d24e wip: allow to undo/sync rules
When editing Rule(s), we are not editing the map data itself, but a
sort of proxy objects. This was done mainly because map.properties.rules
is an array of object, and at this time Leaflet.FormBuilder did not know
how to edit an array (something like properties.rules.0.condition).
Now that we integrated FormBuilder, it still does not know how to do this
but we could teach it, or find another way (real Proxy or use reference
to the original object in the Rule).
2025-03-26 18:05:16 +01:00
Yohan Boniface
e004cd461d wip: uncreated map should always appear as dirty 2025-03-26 18:05:16 +01:00
Yohan Boniface
6bea9339b6 wip: DataLayer does not inherit anymore from ServerStored 2025-03-26 18:05:16 +01:00
Yohan Boniface
90ea3737f2 wip: allow DataLayer.clear to be sync and undone 2025-03-26 18:05:16 +01:00
Yohan Boniface
a7b750740c wip: uMap does not inherit anymore from ServerStored
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
101b036a66 wip: remove not effective code
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
983f7f8cb1 wip: add permissions related fields in schema
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
9718f11faf wip: allow to mark an operation as not undoable
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
88382ab00b wip: tests pass
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
0b84084c6b fixup: make sure to toggle remote client state at save too
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
8b2454936b wip: derive the dirty status from the undoManager
This should pave the way for removing the SaveManager.

Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
98f2f8df65 Update the tests and remove cancel edits
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
2025-03-26 18:05:16 +01:00
757cb375d1 Add integration test for batch undo/redo 2025-03-26 18:05:16 +01:00
Yohan Boniface
4ef1411102 Batch operations
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
01b2053030 wip: undo redo
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
2025-03-26 18:05:16 +01:00
Yohan Boniface
64c7fe1ec9 fix: fix icon and button for version restore
Some checks failed
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
Test & Docs / lint (push) Has been cancelled
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:01 +01:00
Yohan Boniface
be83eddbd0
chore: bump ruff from 0.11.0 to 0.11.2 (#2587)
Some checks failed
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
Test & Docs / lint (push) Has been cancelled
2025-03-25 13:04:07 +01:00
dependabot[bot]
4df201107e
chore: bump ruff from 0.11.0 to 0.11.2
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.11.0 to 0.11.2.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.11.0...0.11.2)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-24 17:42:02 +00:00
Yohan Boniface
60f16cbc76
chore: remove old JS tests (#2585)
Some checks failed
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
Test & Docs / lint (push) Has been cancelled
Those are not run since more than one year, so let's trash.

fix #2260
2025-03-20 11:50:48 +01:00
Yohan Boniface
2fa88c36f8
chore: bump ruff from 0.9.10 to 0.11.0 (#2582)
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
2025-03-19 08:22:32 +01:00
Yohan Boniface
47c5c0a2f0 chore: remove old JS tests
Those are not ran since more than one year, so let's trash.

fix #2260
2025-03-19 08:16:38 +01:00
dependabot[bot]
e548ec60f1
chore: bump ruff from 0.9.10 to 0.11.0
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.10 to 0.11.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.9.10...0.11.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-18 05:58:20 +00:00
Yohan Boniface
190acbfaf0
chore: bump mkdocs-material from 9.6.7 to 9.6.9 (#2580)
Some checks failed
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
Test & Docs / lint (push) Has been cancelled
2025-03-18 06:57:13 +01:00
Yohan Boniface
1370b1a0e8
chore: bump psycopg from 3.2.5 to 3.2.6 (#2581)
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
2025-03-17 21:27:56 +01:00
dependabot[bot]
aa75b323c8
chore: bump psycopg from 3.2.5 to 3.2.6
Bumps [psycopg](https://github.com/psycopg/psycopg) from 3.2.5 to 3.2.6.
- [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news.rst)
- [Commits](https://github.com/psycopg/psycopg/compare/3.2.5...3.2.6)

---
updated-dependencies:
- dependency-name: psycopg
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-17 18:07:46 +00:00
dependabot[bot]
1c00545095
chore: bump mkdocs-material from 9.6.7 to 9.6.9
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.6.7 to 9.6.9.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.6.7...9.6.9)

---
updated-dependencies:
- dependency-name: mkdocs-material
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-17 18:07:38 +00:00
53 changed files with 983 additions and 2216 deletions

View file

@ -1,5 +1,5 @@
# Force rtfd to use a recent version of mkdocs # Force rtfd to use a recent version of mkdocs
mkdocs==1.6.1 mkdocs==1.6.1
pymdown-extensions==10.14.3 pymdown-extensions==10.14.3
mkdocs-material==9.6.7 mkdocs-material==9.6.9
mkdocs-static-i18n==1.3.0 mkdocs-static-i18n==1.3.0

View file

@ -1,5 +1,5 @@
# Force rtfd to use a recent version of mkdocs # Force rtfd to use a recent version of mkdocs
mkdocs==1.6.1 mkdocs==1.6.1
pymdown-extensions==10.14.3 pymdown-extensions==10.14.3
mkdocs-material==9.6.7 mkdocs-material==9.6.9
mkdocs-static-i18n==1.3.0 mkdocs-static-i18n==1.3.0

View file

@ -33,7 +33,7 @@ dependencies = [
"django-environ==0.12.0", "django-environ==0.12.0",
"django-probes==1.7.0", "django-probes==1.7.0",
"Pillow==11.1.0", "Pillow==11.1.0",
"psycopg==3.2.5", "psycopg==3.2.6",
"requests==2.32.3", "requests==2.32.3",
"rcssmin==1.2.1", "rcssmin==1.2.1",
"rjsmin==1.2.4", "rjsmin==1.2.4",
@ -44,10 +44,10 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"hatch==1.14.0", "hatch==1.14.0",
"ruff==0.9.10", "ruff==0.11.2",
"djlint==1.36.4", "djlint==1.36.4",
"mkdocs==1.6.1", "mkdocs==1.6.1",
"mkdocs-material==9.6.7", "mkdocs-material==9.6.9",
"mkdocs-static-i18n==1.3.0", "mkdocs-static-i18n==1.3.0",
"vermin==1.6.0", "vermin==1.6.0",
"pymdown-extensions==10.14.3", "pymdown-extensions==10.14.3",

View file

@ -14,11 +14,12 @@
background-color: inherit; background-color: inherit;
} }
.leaflet-container .edit-save, .leaflet-container .edit-save,
.leaflet-container .edit-cancel, .leaflet-container .edit-undo,
.leaflet-container .edit-redo,
.leaflet-container .edit-disable, .leaflet-container .edit-disable,
.leaflet-container .connected-peers .leaflet-container .connected-peers
{ {
display: block; display: inline-block;
height: 32px; height: 32px;
line-height: 30px; line-height: 30px;
padding: 0 20px; padding: 0 20px;
@ -39,7 +40,8 @@
color: var(--color-darkGray); color: var(--color-darkGray);
} }
.leaflet-container .edit-cancel:hover, .leaflet-container .edit-undo:hover,
.leaflet-container .edit-redo:hover,
.leaflet-container .edit-disable:hover { .leaflet-container .edit-disable:hover {
border: 0.5px solid rgba(153, 153, 153, 0.80); border: 0.5px solid rgba(153, 153, 153, 0.80);
text-decoration: none; text-decoration: none;
@ -76,19 +78,13 @@
background: rgba(66, 236, 230, 0.10); background: rgba(66, 236, 230, 0.10);
} }
.leaflet-container .edit-save, .leaflet-container .edit-save,
.leaflet-container .edit-cancel,
.leaflet-container .edit-disable,
.umap-edit-enabled .edit-enable { .umap-edit-enabled .edit-enable {
display: none; display: none;
} }
.umap-edit-enabled .edit-save, .umap-edit-enabled .edit-save,
.umap-edit-enabled .edit-disable, .umap-edit-enabled .edit-disable {
.umap-edit-enabled.umap-is-dirty .edit-cancel {
display: inline-block; display: inline-block;
} }
.umap-is-dirty .edit-disable {
display: none;
}
.umap-caption-bar { .umap-caption-bar {
display: none; display: none;
} }

View file

@ -167,11 +167,15 @@ html[dir="rtl"] .icon {
.icon-profile { .icon-profile {
background-position: 0 calc(var(--tile) * 4); background-position: 0 calc(var(--tile) * 4);
} }
.icon-redo {
background-position: calc(var(--tile) * 3) calc(var(--tile) * 7);
}
.icon-resize { .icon-resize {
background-position: calc(var(--tile) * 3) calc(var(--tile) * 6); background-position: calc(var(--tile) * 3) calc(var(--tile) * 6);
} }
.icon-undo,
.icon-restore { .icon-restore {
background-position: calc(var(--tile) * 5) calc(var(--tile) * 3); background-position: calc(var(--tile) * 2) calc(var(--tile) * 7);
} }
.expanded .icon-resize { .expanded .icon-resize {
background-position: calc(var(--tile) * 2) calc(var(--tile) * 6); background-position: calc(var(--tile) * 2) calc(var(--tile) * 6);

View file

@ -18,6 +18,9 @@
<clipPath id="clip0_3071_861"> <clipPath id="clip0_3071_861">
<rect id="rect3" width="18" height="20" fill="#fff"/> <rect id="rect3" width="18" height="20" fill="#fff"/>
</clipPath> </clipPath>
<clipPath id="clip0_241_10857-3">
<rect id="rect586-6" width="18.05" height="19.01" fill="#fff"/>
</clipPath>
</defs> </defs>
<metadata id="metadata7"> <metadata id="metadata7">
<rdf:RDF> <rdf:RDF>
@ -67,9 +70,12 @@
</g> </g>
</g> </g>
<path id="path4873" d="m108.15 816.36v3.8267h3.8544v-3.8267zm0.51755 4.3517-1.2459 2.3132 1.1669 0.61848 1.244-2.3151zm-1.8689 3.4717-0.27666 0.51571h-2.426v2.2441l-4.0916 4.064 1.3626 1.3528 3.862-3.8342h2.7214v-3.6959l0.015-0.028-0.015-8e-3v-0.0953h-0.17879l-0.97303-0.51571z" color="#000000" color-rendering="auto" fill="#f2f2f2" fill-rule="evenodd" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/> <path id="path4873" d="m108.15 816.36v3.8267h3.8544v-3.8267zm0.51755 4.3517-1.2459 2.3132 1.1669 0.61848 1.244-2.3151zm-1.8689 3.4717-0.27666 0.51571h-2.426v2.2441l-4.0916 4.064 1.3626 1.3528 3.862-3.8342h2.7214v-3.6959l0.015-0.028-0.015-8e-3v-0.0953h-0.17879l-0.97303-0.51571z" color="#000000" color-rendering="auto" fill="#f2f2f2" fill-rule="evenodd" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/>
<g id="g4244" transform="matrix(.51357 -.54309 .54309 .51357 -518.02 506.23)"> <g id="g4244" transform="matrix(.51357 -.54309 .54309 .51357 -590.02 601.73)">
<path id="path4240" transform="matrix(.91922 .97205 -.97205 .91922 152.14 647.93)" d="m220.49 133.52c-0.33017 0.01-0.66239 0.0456-0.99414 0.10742-2.2249 0.41425-4.0267 1.9575-4.832 4.0098l-0.87696-1.8164a0.50005 0.50005 0 0 0-0.83398-0.11328 0.50005 0.50005 0 0 0-0.0664 0.54883l1.459 3.0195a0.50005 0.50005 0 0 0 0.60742 0.25586l2.8438-0.94532a0.50028 0.50028 0 1 0-0.31641-0.94922l-2.002 0.66797c0.61312-1.8902 2.2073-3.3241 4.2012-3.6953 2.2474-0.41845 4.5146 0.59912 5.6953 2.5566 1.1807 1.9575 1.022 4.4377-0.39648 6.2305-1.4185 1.7928-3.7961 2.5154-5.9727 1.8164a0.50005 0.50005 0 1 0-0.30469 0.95117c2.5704 0.82539 5.3874-0.0294 7.0625-2.1465 0.4188-0.52924 0.74532-1.1114 0.97657-1.7207 0.69373-1.828 0.53599-3.9147-0.50977-5.6484-1.22-2.0227-3.4291-3.1977-5.7402-3.1289z" color="#000000" color-rendering="auto" fill="#f2f2f2" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-linecap="round" stroke-linejoin="round" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/> <path id="path4240" transform="matrix(.91922 .97205 -.97205 .91922 152.14 647.93)" d="m220.49 133.52c-0.33017 0.01-0.66239 0.0456-0.99414 0.10742-2.2249 0.41425-4.0267 1.9575-4.832 4.0098l-0.87696-1.8164a0.50005 0.50005 0 0 0-0.83398-0.11328 0.50005 0.50005 0 0 0-0.0664 0.54883l1.459 3.0195a0.50005 0.50005 0 0 0 0.60742 0.25586l2.8438-0.94532a0.50028 0.50028 0 1 0-0.31641-0.94922l-2.002 0.66797c0.61312-1.8902 2.2073-3.3241 4.2012-3.6953 2.2474-0.41845 4.5146 0.59912 5.6953 2.5566 1.1807 1.9575 1.022 4.4377-0.39648 6.2305-1.4185 1.7928-3.7961 2.5154-5.9727 1.8164a0.50005 0.50005 0 1 0-0.30469 0.95117c2.5704 0.82539 5.3874-0.0294 7.0625-2.1465 0.4188-0.52924 0.74532-1.1114 0.97657-1.7207 0.69373-1.828 0.53599-3.9147-0.50977-5.6484-1.22-2.0227-3.4291-3.1977-5.7402-3.1289z" color="#000000" color-rendering="auto" fill="#f2f2f2" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-linecap="round" stroke-linejoin="round" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/>
</g> </g>
<g id="g4244-6" transform="matrix(-.51357 -.54309 -.54309 .51357 734.02 601.73)">
<path id="path4240-7" transform="matrix(.91922 .97205 -.97205 .91922 152.14 647.93)" d="m220.49 133.52c-0.33017 0.01-0.66239 0.0456-0.99414 0.10742-2.2249 0.41425-4.0267 1.9575-4.832 4.0098l-0.87696-1.8164a0.50005 0.50005 0 0 0-0.83398-0.11328 0.50005 0.50005 0 0 0-0.0664 0.54883l1.459 3.0195a0.50005 0.50005 0 0 0 0.60742 0.25586l2.8438-0.94532a0.50028 0.50028 0 1 0-0.31641-0.94922l-2.002 0.66797c0.61312-1.8902 2.2073-3.3241 4.2012-3.6953 2.2474-0.41845 4.5146 0.59912 5.6953 2.5566 1.1807 1.9575 1.022 4.4377-0.39648 6.2305-1.4185 1.7928-3.7961 2.5154-5.9727 1.8164a0.50005 0.50005 0 1 0-0.30469 0.95117c2.5704 0.82539 5.3874-0.0294 7.0625-2.1465 0.4188-0.52924 0.74532-1.1114 0.97657-1.7207 0.69373-1.828 0.53599-3.9147-0.50977-5.6484-1.22-2.0227-3.4291-3.1977-5.7402-3.1289z" color="#000000" color-rendering="auto" fill="#f2f2f2" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-linecap="round" stroke-linejoin="round" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/>
</g>
<g id="delete-vertex" transform="translate(-90,-64)"> <g id="delete-vertex" transform="translate(-90,-64)">
<path id="path4349-2-2-9-3-8-3-5" transform="translate(0 852.36)" d="m227.55 53.105-6.0547 3.0273h-3.4414v3.5176l-2.9512 5.9023 1.7891 0.89453 3.1562-6.3145h2.0059v-2.043l6.3906-3.1953z" color="#000000" color-rendering="auto" fill="#f2f2f2" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/> <path id="path4349-2-2-9-3-8-3-5" transform="translate(0 852.36)" d="m227.55 53.105-6.0547 3.0273h-3.4414v3.5176l-2.9512 5.9023 1.7891 0.89453 3.1562-6.3145h2.0059v-2.043l6.3906-3.1953z" color="#000000" color-rendering="auto" fill="#f2f2f2" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/>
<path id="path4353-1-6-1-3-5-1-9" d="m217 907.36 6 6" fill="none" stroke="#fff" stroke-width="2.482"/> <path id="path4353-1-6-1-3-5-1-9" d="m217 907.36 6 6" fill="none" stroke="#fff" stroke-width="2.482"/>
@ -143,6 +149,10 @@
<path id="path580" d="m1.07 4.41h9.42c0.9234-0.0066 1.8391 0.16957 2.6941 0.5184 0.8551 0.34883 1.6327 0.8634 2.288 1.5141s1.1754 1.4246 1.5303 2.2771c0.3549 0.85256 0.5376 1.767 0.5376 2.6904 0.0067 0.9277-0.1712 1.8474-0.5231 2.7058-0.3519 0.8583-0.871 1.6382-1.527 2.2941-0.656 0.656-1.4358 1.1751-2.2941 1.527-0.8584 0.352-1.7781 0.5298-2.7058 0.5231h-9.42"/> <path id="path580" d="m1.07 4.41h9.42c0.9234-0.0066 1.8391 0.16957 2.6941 0.5184 0.8551 0.34883 1.6327 0.8634 2.288 1.5141s1.1754 1.4246 1.5303 2.2771c0.3549 0.85256 0.5376 1.767 0.5376 2.6904 0.0067 0.9277-0.1712 1.8474-0.5231 2.7058-0.3519 0.8583-0.871 1.6382-1.527 2.2941-0.656 0.656-1.4358 1.1751-2.2941 1.527-0.8584 0.352-1.7781 0.5298-2.7058 0.5231h-9.42"/>
<path id="path582" d="m4.75 8.45-4.04-4.05 4.04-4.05"/> <path id="path582" d="m4.75 8.45-4.04-4.05 4.04-4.05"/>
</g> </g>
<g id="undo-5" transform="matrix(-.71301 0 0 .66261 90.012 938.13)" clip-path="url(#clip0_241_10857-3)" fill="none" stroke="#f2f2f2" stroke-miterlimit="10" stroke-width="1.4549">
<path id="path580-3" d="m1.07 4.41h9.42c0.9234-0.0066 1.8391 0.16957 2.6941 0.5184 0.8551 0.34883 1.6327 0.8634 2.288 1.5141s1.1754 1.4246 1.5303 2.2771c0.3549 0.85256 0.5376 1.767 0.5376 2.6904 0.0067 0.9277-0.1712 1.8474-0.5231 2.7058-0.3519 0.8583-0.871 1.6382-1.527 2.2941-0.656 0.656-1.4358 1.1751-2.2941 1.527-0.8584 0.352-1.7781 0.5298-2.7058 0.5231h-9.42"/>
<path id="path582-5" d="m4.75 8.45-4.04-4.05 4.04-4.05"/>
</g>
<g id="g1" transform="translate(144 -24)" fill="none" stroke="#999"> <g id="g1" transform="translate(144 -24)" fill="none" stroke="#999">
<path id="path438" d="m9 849.94v4.05c0 0.20708 0.1679 0.375 0.375 0.375h5.25c0.20708 0 0.375-0.16792 0.375-0.375v-4.05c0-0.20708-0.16792-0.375-0.375-0.375h-5.25c-0.2071 0-0.375 0.16792-0.375 0.375z"/> <path id="path438" d="m9 849.94v4.05c0 0.20708 0.1679 0.375 0.375 0.375h5.25c0.20708 0 0.375-0.16792 0.375-0.375v-4.05c0-0.20708-0.16792-0.375-0.375-0.375h-5.25c-0.2071 0-0.375 0.16792-0.375 0.375z"/>
<path id="save" d="m15.213 842.36h-8.8376c-0.2071 0-0.375 0.1679-0.375 0.37499v11.25c0 0.20708 0.1679 0.375 0.375 0.375h11.25c0.20708 0 0.375-0.16792 0.375-0.375v-8.6766c0-0.0953-0.0363-0.18697-0.1014-0.25648l-2.4124-2.5733c-0.07095-0.0756-0.16995-0.11853-0.2736-0.11853z"/> <path id="save" d="m15.213 842.36h-8.8376c-0.2071 0-0.375 0.1679-0.375 0.37499v11.25c0 0.20708 0.1679 0.375 0.375 0.375h11.25c0.20708 0 0.375-0.16792 0.375-0.375v-8.6766c0-0.0953-0.0363-0.18697-0.1014-0.25648l-2.4124-2.5733c-0.07095-0.0756-0.16995-0.11853-0.2736-0.11853z"/>

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -21,8 +21,11 @@
<clipPath id="clip0_3071_861"> <clipPath id="clip0_3071_861">
<rect width="18" height="20" fill="#ffffff" id="rect3" x="0" y="0" /> <rect width="18" height="20" fill="#ffffff" id="rect3" x="0" y="0" />
</clipPath> </clipPath>
<clipPath id="clip0_241_10857-3">
<rect width="18.049999" height="19.01" fill="#ffffff" id="rect586-6" x="0" y="0" />
</clipPath>
</defs> </defs>
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="22.311152" inkscape:cx="196.69536" inkscape:cy="36.932203" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="true" inkscape:window-width="1920" inkscape:window-height="1011" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" showguides="true" inkscape:guide-bbox="true" inkscape:snap-grids="true" inkscape:snap-to-guides="true" inkscape:showpageshadow="2" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1"> <sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="4.4320814" inkscape:cx="116.08541" inkscape:cy="109.65503" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="true" inkscape:window-width="1920" inkscape:window-height="1011" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" showguides="true" inkscape:guide-bbox="true" inkscape:snap-grids="true" inkscape:snap-to-guides="true" inkscape:showpageshadow="2" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1">
<inkscape:grid type="xygrid" id="grid3004" empspacing="4" visible="true" enabled="true" snapvisiblegridlinesonly="true" originx="0" originy="0" spacingy="1" spacingx="1" units="px" /> <inkscape:grid type="xygrid" id="grid3004" empspacing="4" visible="true" enabled="true" snapvisiblegridlinesonly="true" originx="0" originy="0" spacingy="1" spacingx="1" units="px" />
<inkscape:grid id="grid1" units="px" originx="0" originy="0" spacingx="24" spacingy="24" empcolor="#203fff" empopacity="0.85490196" color="#3f3fff" opacity="0.1254902" empspacing="1" enabled="true" visible="true" /> <inkscape:grid id="grid1" units="px" originx="0" originy="0" spacingx="24" spacingy="24" empcolor="#203fff" empopacity="0.85490196" color="#3f3fff" opacity="0.1254902" empspacing="1" enabled="true" visible="true" />
</sodipodi:namedview> </sodipodi:namedview>
@ -78,9 +81,12 @@
</g> </g>
</g> </g>
<path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:evenodd;stroke:#999999;stroke-width:0.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 108.14555,816.36218 v 3.8267 h 3.85445 v -3.8267 z m 0.51755,4.35174 -1.24591,2.31321 1.16687,0.61848 1.24404,-2.31507 z m -1.86888,3.47168 -0.27666,0.51571 h -2.42597 v 2.24408 l -4.09159,4.06399 1.36261,1.3528 3.86198,-3.83417 h 2.72145 v -3.6959 l 0.015,-0.028 -0.015,-0.008 v -0.0953 h -0.17879 l -0.97303,-0.51571 z" id="path4873" inkscape:connector-curvature="0" /> <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:evenodd;stroke:#999999;stroke-width:0.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 108.14555,816.36218 v 3.8267 h 3.85445 v -3.8267 z m 0.51755,4.35174 -1.24591,2.31321 1.16687,0.61848 1.24404,-2.31507 z m -1.86888,3.47168 -0.27666,0.51571 h -2.42597 v 2.24408 l -4.09159,4.06399 1.36261,1.3528 3.86198,-3.83417 h 2.72145 v -3.6959 l 0.015,-0.028 -0.015,-0.008 v -0.0953 h -0.17879 l -0.97303,-0.51571 z" id="path4873" inkscape:connector-curvature="0" />
<g id="g4244" transform="matrix(0.51357238,-0.54309229,0.54309229,0.51357238,-518.0199,506.22551)"> <g id="g4244" transform="matrix(0.51357238,-0.54309229,0.54309229,0.51357238,-590.0195,601.72586)">
<path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:#999999;stroke-width:0.25;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 220.49219,133.52344 c -0.33017,0.01 -0.66239,0.0456 -0.99414,0.10742 -2.22487,0.41425 -4.02666,1.95747 -4.83203,4.00976 l -0.87696,-1.8164 a 0.50004998,0.50004998 0 0 0 -0.83398,-0.11328 0.50004998,0.50004998 0 0 0 -0.0664,0.54883 l 1.45899,3.01953 a 0.50004998,0.50004998 0 0 0 0.60742,0.25586 l 2.84375,-0.94532 a 0.50028339,0.50028339 0 1 0 -0.31641,-0.94922 l -2.00195,0.66797 c 0.61312,-1.89015 2.20733,-3.32407 4.20117,-3.69531 2.24744,-0.41845 4.51458,0.59912 5.69531,2.55664 1.18073,1.95754 1.02202,4.43774 -0.39648,6.23047 -1.41851,1.79275 -3.79606,2.51535 -5.97266,1.81641 a 0.50004998,0.50004998 0 1 0 -0.30469,0.95117 c 2.57038,0.82539 5.38736,-0.0294 7.0625,-2.14649 0.4188,-0.52924 0.74532,-1.11137 0.97657,-1.7207 0.69373,-1.828 0.53599,-3.91467 -0.50977,-5.64844 -1.22005,-2.02271 -3.42908,-3.19767 -5.74023,-3.1289 z" transform="matrix(0.91921787,0.9720541,-0.9720541,0.91921787,152.1356,647.93271)" id="path4240" inkscape:connector-curvature="0" /> <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:#999999;stroke-width:0.25;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 220.49219,133.52344 c -0.33017,0.01 -0.66239,0.0456 -0.99414,0.10742 -2.22487,0.41425 -4.02666,1.95747 -4.83203,4.00976 l -0.87696,-1.8164 a 0.50004998,0.50004998 0 0 0 -0.83398,-0.11328 0.50004998,0.50004998 0 0 0 -0.0664,0.54883 l 1.45899,3.01953 a 0.50004998,0.50004998 0 0 0 0.60742,0.25586 l 2.84375,-0.94532 a 0.50028339,0.50028339 0 1 0 -0.31641,-0.94922 l -2.00195,0.66797 c 0.61312,-1.89015 2.20733,-3.32407 4.20117,-3.69531 2.24744,-0.41845 4.51458,0.59912 5.69531,2.55664 1.18073,1.95754 1.02202,4.43774 -0.39648,6.23047 -1.41851,1.79275 -3.79606,2.51535 -5.97266,1.81641 a 0.50004998,0.50004998 0 1 0 -0.30469,0.95117 c 2.57038,0.82539 5.38736,-0.0294 7.0625,-2.14649 0.4188,-0.52924 0.74532,-1.11137 0.97657,-1.7207 0.69373,-1.828 0.53599,-3.91467 -0.50977,-5.64844 -1.22005,-2.02271 -3.42908,-3.19767 -5.74023,-3.1289 z" transform="matrix(0.91921787,0.9720541,-0.9720541,0.91921787,152.1356,647.93271)" id="path4240" inkscape:connector-curvature="0" />
</g> </g>
<g id="g4244-6" transform="matrix(-0.51357238,-0.54309229,-0.54309229,0.51357238,734.0195,601.72586)">
<path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:#999999;stroke-width:0.25;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 220.49219,133.52344 c -0.33017,0.01 -0.66239,0.0456 -0.99414,0.10742 -2.22487,0.41425 -4.02666,1.95747 -4.83203,4.00976 l -0.87696,-1.8164 a 0.50004998,0.50004998 0 0 0 -0.83398,-0.11328 0.50004998,0.50004998 0 0 0 -0.0664,0.54883 l 1.45899,3.01953 a 0.50004998,0.50004998 0 0 0 0.60742,0.25586 l 2.84375,-0.94532 a 0.50028339,0.50028339 0 1 0 -0.31641,-0.94922 l -2.00195,0.66797 c 0.61312,-1.89015 2.20733,-3.32407 4.20117,-3.69531 2.24744,-0.41845 4.51458,0.59912 5.69531,2.55664 1.18073,1.95754 1.02202,4.43774 -0.39648,6.23047 -1.41851,1.79275 -3.79606,2.51535 -5.97266,1.81641 a 0.50004998,0.50004998 0 1 0 -0.30469,0.95117 c 2.57038,0.82539 5.38736,-0.0294 7.0625,-2.14649 0.4188,-0.52924 0.74532,-1.11137 0.97657,-1.7207 0.69373,-1.828 0.53599,-3.91467 -0.50977,-5.64844 -1.22005,-2.02271 -3.42908,-3.19767 -5.74023,-3.1289 z" transform="matrix(0.91921787,0.9720541,-0.9720541,0.91921787,152.1356,647.93271)" id="path4240-7" inkscape:connector-curvature="0" />
</g>
<g id="delete-vertex" transform="translate(-90,-64)"> <g id="delete-vertex" transform="translate(-90,-64)">
<path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:#999999;stroke-width:0.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 227.55273,53.105469 -6.05468,3.027343 h -3.44141 v 3.517579 l -2.95117,5.902343 1.78906,0.894532 3.15625,-6.314454 h 2.00586 v -2.042968 l 6.39063,-3.195313 z" transform="translate(0,852.36218)" id="path4349-2-2-9-3-8-3-5" inkscape:connector-curvature="0" /> <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:#999999;stroke-width:0.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 227.55273,53.105469 -6.05468,3.027343 h -3.44141 v 3.517579 l -2.95117,5.902343 1.78906,0.894532 3.15625,-6.314454 h 2.00586 v -2.042968 l 6.39063,-3.195313 z" transform="translate(0,852.36218)" id="path4349-2-2-9-3-8-3-5" inkscape:connector-curvature="0" />
<path sodipodi:nodetypes="cc" style="fill:none;stroke:#ffffff;stroke-width:2.482;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m 217,907.36218 6,6" id="path4353-1-6-1-3-5-1-9" inkscape:connector-curvature="0" /> <path sodipodi:nodetypes="cc" style="fill:none;stroke:#ffffff;stroke-width:2.482;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m 217,907.36218 6,6" id="path4353-1-6-1-3-5-1-9" inkscape:connector-curvature="0" />
@ -154,6 +160,10 @@
<path d="m 1.07001,4.41003 h 9.41999 c 0.9234,-0.0066 1.8391,0.16957 2.6941,0.5184 0.8551,0.34883 1.6327,0.8634 2.288,1.51407 0.6553,0.65067 1.1754,1.42458 1.5303,2.27713 0.3549,0.85256 0.5376,1.76697 0.5376,2.69037 0.0067,0.9277 -0.1712,1.8474 -0.5231,2.7058 -0.3519,0.8583 -0.871,1.6382 -1.527,2.2941 -0.656,0.656 -1.4358,1.1751 -2.2941,1.527 -0.8584,0.352 -1.7781,0.5298 -2.7058,0.5231 h -9.41999" stroke="#f2f2f2" stroke-miterlimit="10" id="path580" style="fill:none;stroke:#f2f2f2;stroke-width:1.45486;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1" /> <path d="m 1.07001,4.41003 h 9.41999 c 0.9234,-0.0066 1.8391,0.16957 2.6941,0.5184 0.8551,0.34883 1.6327,0.8634 2.288,1.51407 0.6553,0.65067 1.1754,1.42458 1.5303,2.27713 0.3549,0.85256 0.5376,1.76697 0.5376,2.69037 0.0067,0.9277 -0.1712,1.8474 -0.5231,2.7058 -0.3519,0.8583 -0.871,1.6382 -1.527,2.2941 -0.656,0.656 -1.4358,1.1751 -2.2941,1.527 -0.8584,0.352 -1.7781,0.5298 -2.7058,0.5231 h -9.41999" stroke="#f2f2f2" stroke-miterlimit="10" id="path580" style="fill:none;stroke:#f2f2f2;stroke-width:1.45486;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1" />
<path d="m 4.75002,8.44998 -4.039998,-4.05 4.039998,-4.050004" stroke="#f2f2f2" stroke-miterlimit="10" id="path582" style="fill:none;stroke:#f2f2f2;stroke-width:1.45486;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1" /> <path d="m 4.75002,8.44998 -4.039998,-4.05 4.039998,-4.050004" stroke="#f2f2f2" stroke-miterlimit="10" id="path582" style="fill:none;stroke:#f2f2f2;stroke-width:1.45486;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1" />
</g> </g>
<g clip-path="url(#clip0_241_10857-3)" id="undo-5" transform="matrix(-0.71300568,0,0,0.66260978,90.012499,938.13028)" style="fill:none;stroke:#f2f2f2;stroke-width:1.45488;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1">
<path d="m 1.07001,4.41003 h 9.41999 c 0.9234,-0.0066 1.8391,0.16957 2.6941,0.5184 0.8551,0.34883 1.6327,0.8634 2.288,1.51407 0.6553,0.65067 1.1754,1.42458 1.5303,2.27713 0.3549,0.85256 0.5376,1.76697 0.5376,2.69037 0.0067,0.9277 -0.1712,1.8474 -0.5231,2.7058 -0.3519,0.8583 -0.871,1.6382 -1.527,2.2941 -0.656,0.656 -1.4358,1.1751 -2.2941,1.527 -0.8584,0.352 -1.7781,0.5298 -2.7058,0.5231 h -9.41999" stroke="#f2f2f2" stroke-miterlimit="10" id="path580-3" style="fill:none;stroke:#f2f2f2;stroke-width:1.45486;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1" />
<path d="m 4.75002,8.44998 -4.039998,-4.05 4.039998,-4.050004" stroke="#f2f2f2" stroke-miterlimit="10" id="path582-5" style="fill:none;stroke:#f2f2f2;stroke-width:1.45486;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1" />
</g>
<g id="g1" transform="translate(144,-24.00004)"> <g id="g1" transform="translate(144,-24.00004)">
<path d="m 9,849.93721 v 4.04997 c 0,0.20708 0.167895,0.375 0.375,0.375 h 5.25 c 0.207075,0 0.375,-0.16792 0.375,-0.375 v -4.04997 c 0,-0.20708 -0.167925,-0.375 -0.375,-0.375 h -5.25 c -0.207105,0 -0.375,0.16792 -0.375,0.375 z" stroke="#f2f2f2" id="path438" style="fill:none;stroke:#999999;stroke-width:0.999997;stroke-opacity:1" /> <path d="m 9,849.93721 v 4.04997 c 0,0.20708 0.167895,0.375 0.375,0.375 h 5.25 c 0.207075,0 0.375,-0.16792 0.375,-0.375 v -4.04997 c 0,-0.20708 -0.167925,-0.375 -0.375,-0.375 h -5.25 c -0.207105,0 -0.375,0.16792 -0.375,0.375 z" stroke="#f2f2f2" id="path438" style="fill:none;stroke:#999999;stroke-width:0.999997;stroke-opacity:1" />
<path d="m 15.21255,842.36218 h -8.83755 c -0.207105,0 -0.375,0.1679 -0.375,0.37499 v 11.24993 c 0,0.20708 0.167895,0.375 0.375,0.375 h 11.25 c 0.207075,0 0.375,-0.16792 0.375,-0.375 v -8.67664 c 0,-0.0953 -0.0363,-0.18697 -0.1014,-0.25648 l -2.41245,-2.57327 c -0.07095,-0.0756 -0.16995,-0.11853 -0.2736,-0.11853 z" stroke="#f2f2f2" id="save" style="fill:none;stroke:#999999;stroke-width:0.999997;stroke-opacity:1" /> <path d="m 15.21255,842.36218 h -8.83755 c -0.207105,0 -0.375,0.1679 -0.375,0.37499 v 11.24993 c 0,0.20708 0.167895,0.375 0.375,0.375 h 11.25 c 0.207075,0 0.375,-0.16792 0.375,-0.375 v -8.67664 c 0,-0.0953 -0.0363,-0.18697 -0.1014,-0.25648 l -2.41245,-2.57327 c -0.07095,-0.0756 -0.16995,-0.11853 -0.2736,-0.11853 z" stroke="#f2f2f2" id="save" style="fill:none;stroke:#999999;stroke-width:0.999997;stroke-opacity:1" />

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 79 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -91,6 +91,7 @@ class Feature {
} }
set geometry(value) { set geometry(value) {
this._geometry_bk = Utils.CopyJSON(this._geometry)
this._geometry = value this._geometry = value
this.pushGeometry() this.pushGeometry()
} }
@ -104,13 +105,15 @@ class Feature {
} }
pullGeometry(sync = true) { pullGeometry(sync = true) {
const oldGeometry = Utils.CopyJSON(this._geometry)
this.fromLatLngs(this._getLatLngs()) this.fromLatLngs(this._getLatLngs())
if (sync) { if (sync) {
this.sync.update('geometry', this.geometry) this.sync.update('geometry', this.geometry, oldGeometry)
} }
} }
fromLatLngs(latlngs) { fromLatLngs(latlngs) {
this._geometry_bk = Utils.CopyJSON(this._geometry)
this._geometry = this.convertLatLngs(latlngs) this._geometry = this.convertLatLngs(latlngs)
} }
@ -145,8 +148,15 @@ class Feature {
onCommit() { onCommit() {
// When the layer is a remote layer, we don't want to sync the creation of the // When the layer is a remote layer, we don't want to sync the creation of the
// points via the websocket, as the other peers will get them themselves. // points via the websocket, as the other peers will get them themselves.
const oldGeoJSON = this._just_married ? null : Utils.CopyJSON(this.toGeoJSON())
this.pullGeometry(false)
if (this.datalayer?.isRemoteLayer()) return if (this.datalayer?.isRemoteLayer()) return
this.sync.upsert(this.toGeoJSON()) if (this._just_married) {
this.sync.upsert(this.toGeoJSON(), null)
this._just_married = false
} else {
this.sync.update('geometry', this.geometry, this._geometry_bk)
}
} }
isReadOnly() { isReadOnly() {

View file

@ -18,7 +18,6 @@ import { translate } from '../i18n.js'
import { DataLayerPermissions } from '../permissions.js' import { DataLayerPermissions } from '../permissions.js'
import { Point, LineString, Polygon } from './features.js' import { Point, LineString, Polygon } from './features.js'
import TableEditor from '../tableeditor.js' import TableEditor from '../tableeditor.js'
import { ServerStored } from '../saving.js'
import * as Schema from '../schema.js' import * as Schema from '../schema.js'
import { MutatingForm } from '../form/builder.js' import { MutatingForm } from '../form/builder.js'
@ -36,9 +35,8 @@ const LAYER_MAP = LAYER_TYPES.reduce((acc, klass) => {
return acc return acc
}, {}) }, {})
export class DataLayer extends ServerStored { export class DataLayer {
constructor(umap, leafletMap, data = {}) { constructor(umap, leafletMap, data = {}) {
super()
this._umap = umap this._umap = umap
this.sync = umap.syncEngine.proxy(this) this.sync = umap.syncEngine.proxy(this)
this._index = Array() this._index = Array()
@ -49,7 +47,6 @@ export class DataLayer extends ServerStored {
this._leafletMap = leafletMap this._leafletMap = leafletMap
this.parentPane = this._leafletMap.getPane('overlayPane') this.parentPane = this._leafletMap.getPane('overlayPane')
this.pane = this._leafletMap.createPane(`datalayer${stamp(this)}`, this.parentPane) this.pane = this._leafletMap.createPane(`datalayer${stamp(this)}`, this.parentPane)
this.pane.dataset.id = stamp(this)
// FIXME: should be on layer // FIXME: should be on layer
this.renderer = L.svg({ pane: this.pane }) this.renderer = L.svg({ pane: this.pane })
this.defaultOptions = { this.defaultOptions = {
@ -66,6 +63,7 @@ export class DataLayer extends ServerStored {
data.id = data.id || crypto.randomUUID() data.id = data.id || crypto.randomUUID()
this.setOptions(data) this.setOptions(data)
this.pane.dataset.id = this.id
if (!Utils.isObject(this.options.remoteData)) { if (!Utils.isObject(this.options.remoteData)) {
this.options.remoteData = {} this.options.remoteData = {}
@ -114,7 +112,6 @@ export class DataLayer extends ServerStored {
set isDeleted(status) { set isDeleted(status) {
this._isDeleted = status this._isDeleted = status
if (status) this.isDirty = status
} }
get isDeleted() { get isDeleted() {
@ -269,13 +266,11 @@ export class DataLayer extends ServerStored {
} }
clear() { clear() {
this.layer.clearLayers() this.sync.startBatch()
this._features = {} for (const feature of Object.values(this._features)) {
this._index = Array() feature.del()
if (this._geojson) {
this.backupData()
this._geojson = null
} }
this.sync.commitBatch()
this.dataChanged() this.dataChanged()
} }
@ -366,9 +361,8 @@ export class DataLayer extends ServerStored {
} }
connectToMap() { connectToMap() {
const id = stamp(this) if (!this._umap.datalayers[this.id]) {
if (!this._umap.datalayers[id]) { this._umap.datalayers[this.id] = this
this._umap.datalayers[id] = this
} }
if (!this._umap.datalayersIndex.includes(this)) { if (!this._umap.datalayersIndex.includes(this)) {
this._umap.datalayersIndex.push(this) this._umap.datalayersIndex.push(this)
@ -417,7 +411,10 @@ export class DataLayer extends ServerStored {
removeFeature(feature, sync) { removeFeature(feature, sync) {
const id = stamp(feature) const id = stamp(feature)
if (sync !== false) feature.sync.delete() if (sync !== false) {
const oldValue = feature.toGeoJSON()
feature.sync.delete(oldValue)
}
this.hideFeature(feature) this.hideFeature(feature)
delete this._umap.featuresIndex[feature.getSlug()] delete this._umap.featuresIndex[feature.getSlug()]
feature.disconnectFromDataLayer(this) feature.disconnectFromDataLayer(this)
@ -460,7 +457,10 @@ export class DataLayer extends ServerStored {
try { try {
// Do not fail if remote data is somehow invalid, // Do not fail if remote data is somehow invalid,
// otherwise the layer becomes uneditable. // otherwise the layer becomes uneditable.
return this.makeFeatures(geojson, sync) this.sync.startBatch()
const features = this.makeFeatures(geojson, sync)
this.sync.commitBatch()
return features
} catch (err) { } catch (err) {
console.debug('Error with DataLayer', this.id) console.debug('Error with DataLayer', this.id)
console.error(err) console.error(err)
@ -518,7 +518,7 @@ export class DataLayer extends ServerStored {
} }
if (feature && !feature.isEmpty()) { if (feature && !feature.isEmpty()) {
this.addFeature(feature) this.addFeature(feature)
if (sync) feature.onCommit() if (sync) feature.sync.upsert(feature.toGeoJSON(), null)
return feature return feature
} }
} }
@ -527,10 +527,6 @@ export class DataLayer extends ServerStored {
return this._umap.formatter return this._umap.formatter
.parse(raw, format) .parse(raw, format)
.then((geojson) => this.addData(geojson)) .then((geojson) => this.addData(geojson))
.then((data) => {
if (data?.length) this.isDirty = true
return data
})
.catch((error) => { .catch((error) => {
console.debug(error) console.debug(error)
Alert.error(translate('Import failed: invalid data')) Alert.error(translate('Import failed: invalid data'))
@ -596,17 +592,17 @@ export class DataLayer extends ServerStored {
} }
del(sync = true) { del(sync = true) {
const oldValue = Utils.CopyJSON(this.umapGeoJSON())
this.erase() this.erase()
if (sync) { if (sync) {
this.isDeleted = true this.isDeleted = true
this.sync.delete() this.sync.delete(oldValue)
} }
} }
empty() { empty() {
if (this.isRemoteLayer()) return if (this.isRemoteLayer()) return
this.clear() this.clear()
this.isDirty = true
} }
clone() { clone() {
@ -630,25 +626,6 @@ export class DataLayer extends ServerStored {
this.clear() this.clear()
} }
reset() {
if (!this.createdOnServer) {
this.erase()
return
}
this.resetOptions()
this.parentPane.appendChild(this.pane)
if (this._leaflet_events_bk && !this._leaflet_events) {
this._leaflet_events = this._leaflet_events_bk
}
this.clear()
this.hide()
if (this.isRemoteLayer()) this.fetchRemoteData()
else if (this._geojson_bk) this.fromGeoJSON(this._geojson_bk)
this.show()
this.isDirty = false
}
redraw() { redraw() {
if (!this.isVisible()) return if (!this.isVisible()) return
this.eachFeature((feature) => feature.redraw()) this.eachFeature((feature) => feature.redraw())
@ -831,8 +808,9 @@ export class DataLayer extends ServerStored {
this this
) )
if (this._umap.properties.urls.datalayer_versions) if (this._umap.properties.urls.datalayer_versions) {
this.buildVersionsFieldset(container) this.buildVersionsFieldset(container)
}
const advancedActions = DomUtil.createFieldset( const advancedActions = DomUtil.createFieldset(
container, container,
@ -907,10 +885,15 @@ export class DataLayer extends ServerStored {
const appendVersion = (data) => { const appendVersion = (data) => {
const date = new Date(Number.parseInt(data.at, 10)) const date = new Date(Number.parseInt(data.at, 10))
const content = `${date.toLocaleString(U.lang)} (${Number.parseInt(data.size) / 1000}Kb)` const content = `${date.toLocaleString(U.lang)} (${Number.parseInt(data.size) / 1000}Kb)`
const el = DomUtil.create('div', 'umap-datalayer-version', versionsContainer) const [el, { button }] = Utils.loadTemplateWithRefs(
const button = DomUtil.createButton('', el, '', () => this.restore(data.ref)) `<div class="umap-datalayer-version">
button.title = translate('Restore this version') <button type="button" title="${translate('Restore this version')}" data-ref=button>
DomUtil.add('span', '', el, content) <i class="icon icon-16 icon-restore"></i> ${content}
</button>
</div>`
)
versionsContainer.appendChild(el)
button.addEventListener('click', () => this.restore(data.ref))
} }
const versionsContainer = DomUtil.createFieldset(container, translate('Versions'), { const versionsContainer = DomUtil.createFieldset(container, translate('Versions'), {
@ -934,11 +917,14 @@ export class DataLayer extends ServerStored {
) )
if (!error) { if (!error) {
if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat. if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat.
if (geojson._umap_options) this.setOptions(geojson._umap_options) if (geojson._umap_options) {
const oldOptions = Utils.CopyJSON(this.options)
this.setOptions(geojson._umap_options)
this.sync.update('options', this.options, oldOptions)
}
this.empty() this.empty()
if (this.isRemoteLayer()) this.fetchRemoteData() if (this.isRemoteLayer()) this.fetchRemoteData()
else this.addData(geojson) else this.addData(geojson)
this.isDirty = true
} }
}) })
} }
@ -1086,7 +1072,11 @@ export class DataLayer extends ServerStored {
setReferenceVersion({ response, sync }) { setReferenceVersion({ response, sync }) {
this._referenceVersion = response.headers.get('X-Datalayer-Version') this._referenceVersion = response.headers.get('X-Datalayer-Version')
if (sync) this.sync.update('_referenceVersion', this._referenceVersion) if (sync) {
this.sync.update('_referenceVersion', this._referenceVersion, null, {
undo: false,
})
}
} }
async save() { async save() {
@ -1115,6 +1105,10 @@ export class DataLayer extends ServerStored {
} }
async _trySave(url, headers, formData) { async _trySave(url, headers, formData) {
if (this._forceSave) {
headers = {}
this._forceSave = false
}
const [data, response, error] = await this._umap.server.post(url, headers, formData) const [data, response, error] = await this._umap.server.post(url, headers, formData)
if (error) { if (error) {
if (response && response.status === 412) { if (response && response.status === 412) {
@ -1124,15 +1118,8 @@ export class DataLayer extends ServerStored {
'This situation is tricky, you have to choose carefully which version is pertinent.' 'This situation is tricky, you have to choose carefully which version is pertinent.'
), ),
async () => { async () => {
// Save again this layer this._forceSave = true
const status = await this._trySave(url, {}, formData) await this._umap.saveAll()
if (status) {
this.isDirty = false
// Call the main save, in case something else needs to be saved
// as the conflict stopped the saving flow
await this._umap.saveAll()
}
} }
) )
} }
@ -1167,7 +1154,7 @@ export class DataLayer extends ServerStored {
} }
commitDelete() { commitDelete() {
delete this._umap.datalayers[stamp(this)] delete this._umap.datalayers[this.id]
} }
getName() { getName() {

View file

@ -135,7 +135,13 @@ export default class Facets {
for (const [property, { label, type }] of parsed) { for (const [property, { label, type }] of parsed) {
dumped.push([property, label, type].filter(Boolean).join('|')) dumped.push([property, label, type].filter(Boolean).join('|'))
} }
return dumped.join(',') const oldValue = this._umap.properties.facetKey
this._umap.properties.facetKey = dumped.join(',')
this._umap.sync.update(
'properties.facetKey',
this._umap.properties.facetKey,
oldValue
)
} }
has(property) { has(property) {
@ -146,15 +152,13 @@ export default class Facets {
const defined = this.getDefined() const defined = this.getDefined()
if (!defined.has(property)) { if (!defined.has(property)) {
defined.set(property, { label, type }) defined.set(property, { label, type })
this._umap.properties.facetKey = this.dumps(defined) this.dumps(defined)
this._umap.isDirty = true
} }
} }
remove(property) { remove(property) {
const defined = this.getDefined() const defined = this.getDefined()
defined.delete(property) defined.delete(property)
this._umap.properties.facetKey = this.dumps(defined) this.dumps(defined)
this._umap.isDirty = true
} }
} }

View file

@ -70,21 +70,7 @@ export class Form extends Utils.WithEvents {
} }
setter(field, value) { setter(field, value) {
const path = field.split('.') Utils.setObjectValue(this.obj, field, value)
let obj = this.obj
let what
for (let i = 0, l = path.length; i < l; i++) {
what = path[i]
if (what === path[l - 1]) {
if (typeof value === 'undefined') {
delete obj[what]
} else {
obj[what] = value
}
} else {
obj = obj[what]
}
}
} }
restoreField(field) { restoreField(field) {
@ -190,13 +176,17 @@ export class MutatingForm extends Form {
} }
setter(field, value) { setter(field, value) {
super.setter(field, value) const oldValue = this.getter(field)
this.obj.isDirty = true if ('setter' in this.obj) {
this.obj.setter(field, value)
} else {
super.setter(field, value)
}
if ('render' in this.obj) { if ('render' in this.obj) {
this.obj.render([field], this) this.obj.render([field], this)
} }
if ('sync' in this.obj) { if ('sync' in this.obj) {
this.obj.sync.update(field, value) this.obj.sync.update(field, value, oldValue)
} }
} }

View file

@ -541,14 +541,14 @@ Fields.DataLayerSwitcher = class extends Fields.Select {
!datalayer.isDataReadOnly() && !datalayer.isDataReadOnly() &&
datalayer.isBrowsable() datalayer.isBrowsable()
) { ) {
options.push([L.stamp(datalayer), datalayer.getName()]) options.push([datalayer.id, datalayer.getName()])
} }
}) })
return options return options
} }
toHTML() { toHTML() {
return L.stamp(this.obj.datalayer) return this.obj.datalayer.id
} }
toJS() { toJS() {

View file

@ -249,7 +249,7 @@ export default class Importer extends Utils.WithTemplate {
tagName: 'option', tagName: 'option',
parent: layerSelect, parent: layerSelect,
textContent: datalayer.options.name, textContent: datalayer.options.name,
value: L.stamp(datalayer), value: datalayer.id,
}) })
} }
}) })

View file

@ -1,18 +1,16 @@
import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from './i18n.js' import { translate } from './i18n.js'
import { uMapAlert as Alert } from '../components/alerts/alert.js' import { uMapAlert as Alert } from '../components/alerts/alert.js'
import { ServerStored } from './saving.js'
import * as Utils from './utils.js' import * as Utils from './utils.js'
import { MutatingForm } from './form/builder.js' import { MutatingForm } from './form/builder.js'
// Dedicated object so we can deal with a separate dirty status, and thus // Dedicated object so we can deal with a separate dirty status, and thus
// call the endpoint only when needed, saving one call at each save. // call the endpoint only when needed, saving one call at each save.
export class MapPermissions extends ServerStored { export class MapPermissions {
constructor(umap) { constructor(umap) {
super()
this.setProperties(umap.properties.permissions) this.setProperties(umap.properties.permissions)
this._umap = umap this._umap = umap
this._isDirty = false this.sync = umap.syncEngine.proxy(this)
} }
setProperties(properties) { setProperties(properties) {
@ -28,6 +26,13 @@ export class MapPermissions extends ServerStored {
) )
} }
getSyncMetadata() {
return {
subject: 'mappermissions',
metadata: {},
}
}
render() { render() {
this._umap.render(['properties.permissions']) this._umap.render(['properties.permissions'])
} }
@ -188,7 +193,6 @@ export class MapPermissions extends ServerStored {
} }
async save() { async save() {
if (!this.isDirty) return
const formData = new FormData() const formData = new FormData()
if (!this.isAnonymousMap() && this.properties.editors) { if (!this.isAnonymousMap() && this.properties.editors) {
const editors = this.properties.editors.map((u) => u.id) const editors = this.properties.editors.map((u) => u.id)
@ -247,9 +251,8 @@ export class MapPermissions extends ServerStored {
} }
} }
export class DataLayerPermissions extends ServerStored { export class DataLayerPermissions {
constructor(umap, datalayer) { constructor(umap, datalayer) {
super()
this._umap = umap this._umap = umap
this.properties = Object.assign( this.properties = Object.assign(
{ {
@ -259,6 +262,14 @@ export class DataLayerPermissions extends ServerStored {
) )
this.datalayer = datalayer this.datalayer = datalayer
this.sync = umap.syncEngine.proxy(this)
}
getSyncMetadata() {
return {
subject: 'datalayerpermissions',
metadata: { id: this.datalayer.id },
}
} }
edit(container) { edit(container) {
@ -289,7 +300,6 @@ export class DataLayerPermissions extends ServerStored {
} }
async save() { async save() {
if (!this.isDirty) return
const formData = new FormData() const formData = new FormData()
formData.append('edit_status', this.properties.edit_status) formData.append('edit_status', this.properties.edit_status)
const [data, response, error] = await this._umap.server.post( const [data, response, error] = await this._umap.server.post(

View file

@ -117,7 +117,7 @@ export const Choropleth = FeatureGroup.extend({
}, },
_getValue: function (feature) { _getValue: function (feature) {
const key = this.datalayer.options.choropleth.property || 'value' const key = this.datalayer.options.choropleth?.property || 'value'
const value = +feature.properties[key] const value = +feature.properties[key]
if (!Number.isNaN(value)) return value if (!Number.isNaN(value)) return value
}, },
@ -130,12 +130,12 @@ export const Choropleth = FeatureGroup.extend({
this.options.colors = [] this.options.colors = []
return return
} }
const mode = this.datalayer.options.choropleth.mode const mode = this.datalayer.options.choropleth?.mode
let classes = +this.datalayer.options.choropleth.classes || 5 let classes = +this.datalayer.options.choropleth?.classes || 5
let breaks let breaks
classes = Math.min(classes, values.length) classes = Math.min(classes, values.length)
if (mode === 'manual') { if (mode === 'manual') {
const manualBreaks = this.datalayer.options.choropleth.breaks const manualBreaks = this.datalayer.options.choropleth?.breaks
if (manualBreaks) { if (manualBreaks) {
breaks = manualBreaks breaks = manualBreaks
.split(',') .split(',')

View file

@ -97,7 +97,6 @@ const FeatureMixin = {
}, },
onCommit: function () { onCommit: function () {
this.feature.pullGeometry(false)
this.feature.onCommit() this.feature.onCommit()
}, },
} }
@ -112,7 +111,7 @@ const PointMixin = {
this.on('dragend', (event) => { this.on('dragend', (event) => {
this.isDirty = true this.isDirty = true
this.feature.edit(event) this.feature.edit(event)
this.feature.pullGeometry(false) // this.feature.pullGeometry(false)
}) })
if (!this.feature.isReadOnly()) this.on('mouseover', this._enableDragging) if (!this.feature.isReadOnly()) this.on('mouseover', this._enableDragging)
this.on('mouseout', this._onMouseOut) this.on('mouseout', this._onMouseOut)
@ -303,13 +302,13 @@ const PathMixin = {
this._container = null this._container = null
FeatureMixin.onAdd.call(this, map) FeatureMixin.onAdd.call(this, map)
this.setStyle() this.setStyle()
if (this.editing?.enabled()) this.editing.addHooks() if (this.editor?.enabled()) this.editor.addHooks()
this.resetTooltip() this.resetTooltip()
this._path.dataset.feature = this.feature.id this._path.dataset.feature = this.feature.id
}, },
onRemove: function (map) { onRemove: function (map) {
if (this.editing?.enabled()) this.editing.removeHooks() if (this.editor?.enabled()) this.editor.removeHooks()
FeatureMixin.onRemove.call(this, map) FeatureMixin.onRemove.call(this, map)
}, },
@ -362,6 +361,13 @@ const PathMixin = {
isOnScreen: function (bounds) { isOnScreen: function (bounds) {
return bounds.overlaps(this.getBounds()) return bounds.overlaps(this.getBounds())
}, },
_setLatLngs: function (latlngs) {
this.parentClass.prototype._setLatLngs.call(this, latlngs)
if (this.editor?.enabled()) {
this.editor.reset()
}
},
} }
export const LeafletPolyline = Polyline.extend({ export const LeafletPolyline = Polyline.extend({

View file

@ -17,20 +17,10 @@ class Rule {
this.parse() this.parse()
} }
get isDirty() {
return this._isDirty
}
set isDirty(status) {
this._isDirty = status
if (status) this._umap.isDirty = status
}
constructor(umap, condition = '', options = {}) { constructor(umap, condition = '', options = {}) {
// TODO make this public properties when browser coverage is ok // TODO make this public properties when browser coverage is ok
// cf https://caniuse.com/?search=public%20class%20field // cf https://caniuse.com/?search=public%20class%20field
this._condition = null this._condition = null
this._isDirty = false
this.OPERATORS = [ this.OPERATORS = [
['>', this.gt], ['>', this.gt],
['<', this.lt], ['<', this.lt],
@ -190,17 +180,25 @@ class Rule {
_delete() { _delete() {
this._umap.rules.rules = this._umap.rules.rules.filter((rule) => rule !== this) this._umap.rules.rules = this._umap.rules.rules.filter((rule) => rule !== this)
this._umap.rules.commit()
}
setter(key, value) {
const oldRules = Utils.CopyJSON(this._umap.properties.rules || {})
Utils.setObjectValue(this, key, value)
this._umap.rules.commit()
this._umap.sync.update('properties.rules', this._umap.properties.rules, oldRules)
} }
} }
export default class Rules { export default class Rules {
constructor(umap) { constructor(umap) {
this._umap = umap this._umap = umap
this.rules = []
this.load() this.load()
} }
load() { load() {
this.rules = []
if (!this._umap.properties.rules?.length) return if (!this._umap.properties.rules?.length) return
for (const { condition, options } of this._umap.properties.rules) { for (const { condition, options } of this._umap.properties.rules) {
if (!condition) continue if (!condition) continue
@ -222,8 +220,8 @@ export default class Rules {
else if (finalIndex > initialIndex) newIdx = referenceIdx else if (finalIndex > initialIndex) newIdx = referenceIdx
else newIdx = referenceIdx + 1 else newIdx = referenceIdx + 1
this.rules.splice(newIdx, 0, moved) this.rules.splice(newIdx, 0, moved)
moved.isDirty = true
this._umap.render(['rules']) this._umap.render(['rules'])
this.commit()
} }
edit(container) { edit(container) {
@ -242,7 +240,6 @@ export default class Rules {
addRule() { addRule() {
const rule = new Rule(this._umap) const rule = new Rule(this._umap)
rule.isDirty = true
this.rules.push(rule) this.rules.push(rule)
rule.edit(map) rule.edit(map)
} }

View file

@ -1,52 +0,0 @@
const _queue = new Set()
export let isDirty = false
export async function save() {
for (const obj of _queue) {
const ok = await obj.save()
if (!ok) break
remove(obj)
}
}
export function clear() {
_queue.clear()
onUpdate()
}
function add(obj) {
_queue.add(obj)
onUpdate()
}
function remove(obj) {
_queue.delete(obj)
onUpdate()
}
function has(obj) {
return _queue.has(obj)
}
function onUpdate() {
isDirty = Boolean(_queue.size)
document.body.classList.toggle('umap-is-dirty', isDirty)
}
export class ServerStored {
set isDirty(status) {
if (status) {
add(this)
} else {
remove(this)
}
this.onDirty(status)
}
get isDirty() {
return has(this)
}
onDirty(status) {}
}

View file

@ -44,6 +44,10 @@ export const SCHEMA = {
type: Object, type: Object,
impacts: ['data'], impacts: ['data'],
}, },
center: {
type: Object,
impacts: [], // default center, doesn't need any update of the map
},
color: { color: {
type: String, type: String,
impacts: ['data'], impacts: ['data'],
@ -118,6 +122,9 @@ export const SCHEMA = {
default: false, default: false,
label: translate('Animated transitions'), label: translate('Animated transitions'),
}, },
edit_status: {
type: Number,
},
editinosmControl: { editinosmControl: {
type: Boolean, type: Boolean,
impacts: ['ui'], impacts: ['ui'],
@ -125,6 +132,9 @@ export const SCHEMA = {
label: translate('Display the control to open OpenStreetMap editor'), label: translate('Display the control to open OpenStreetMap editor'),
default: null, default: null,
}, },
editors: {
type: Array,
},
embedControl: { embedControl: {
type: Boolean, type: Boolean,
impacts: ['ui'], impacts: ['ui'],
@ -362,6 +372,9 @@ export const SCHEMA = {
type: Object, type: Object,
impacts: ['background'], impacts: ['background'],
}, },
owner: {
type: Object,
},
permanentCredit: { permanentCredit: {
type: 'Text', type: 'Text',
impacts: ['ui'], impacts: ['ui'],
@ -436,6 +449,9 @@ export const SCHEMA = {
label: translate('Display the search control'), label: translate('Display the search control'),
default: true, default: true,
}, },
share_status: {
type: Number,
},
shortCredit: { shortCredit: {
type: String, type: String,
impacts: ['ui'], impacts: ['ui'],
@ -500,6 +516,9 @@ export const SCHEMA = {
helpEntries: ['sync'], helpEntries: ['sync'],
default: false, default: false,
}, },
team: {
type: Object,
},
tilelayer: { tilelayer: {
type: Object, type: Object,
impacts: ['background'], impacts: ['background'],
@ -566,7 +585,6 @@ export const SCHEMA = {
type: Object, type: Object,
impacts: ['data'], impacts: ['data'],
}, },
_referenceVersion: { _referenceVersion: {
type: Number, type: Number,
impacts: ['data'], impacts: ['data'],

View file

@ -1,8 +1,14 @@
import * as Utils from '../utils.js' import * as Utils from '../utils.js'
import { HybridLogicalClock } from './hlc.js' import { HybridLogicalClock } from './hlc.js'
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' import {
DataLayerUpdater,
FeatureUpdater,
MapUpdater,
MapPermissionsUpdater,
DataLayerPermissionsUpdater,
} from './updaters.js'
import { WebSocketTransport } from './websocket.js' import { WebSocketTransport } from './websocket.js'
import * as SaveManager from '../saving.js' import { UndoManager } from './undo.js'
// Start reconnecting after 2 seconds, then double the delay each time // Start reconnecting after 2 seconds, then double the delay each time
// maxing out at 32 seconds. // maxing out at 32 seconds.
@ -55,6 +61,8 @@ export class SyncEngine {
map: new MapUpdater(umap), map: new MapUpdater(umap),
feature: new FeatureUpdater(umap), feature: new FeatureUpdater(umap),
datalayer: new DataLayerUpdater(umap), datalayer: new DataLayerUpdater(umap),
mappermissions: new MapPermissionsUpdater(umap),
datalayerpermissions: new DataLayerPermissionsUpdater(umap),
} }
this.transport = undefined this.transport = undefined
this._operations = new Operations() this._operations = new Operations()
@ -64,6 +72,7 @@ export class SyncEngine {
this.websocketConnected = false this.websocketConnected = false
this.closeRequested = false this.closeRequested = false
this.peerId = Utils.generateId() this.peerId = Utils.generateId()
this._undoManager = new UndoManager(umap, this.updaters, this)
} }
get isOpen() { get isOpen() {
@ -122,16 +131,107 @@ export class SyncEngine {
await this.authenticate() await this.authenticate()
}, this._reconnectDelay) }, this._reconnectDelay)
} }
upsert(subject, metadata, value) {
this._send({ verb: 'upsert', subject, metadata, value }) startBatch() {
this._batch = []
} }
update(subject, metadata, key, value) { commitBatch(subject, metadata) {
this._send({ verb: 'update', subject, metadata, key, value }) if (!this._batch.length) {
this._batch = null
return
}
const operations = this._batch.map((stage) => stage.operation)
const operation = { verb: 'batch', operations, subject, metadata }
this._undoManager.add({ operation, stages: this._batch })
this._send(operation)
this._batch = null
} }
delete(subject, metadata, key) { upsert(subject, metadata, value, oldValue) {
this._send({ verb: 'delete', subject, metadata, key }) const operation = {
verb: 'upsert',
subject,
metadata,
value,
}
const stage = {
operation,
newValue: value,
oldValue: oldValue,
}
if (this._batch) {
this._batch.push(stage)
return
}
this._undoManager.add(stage)
this._send(operation)
}
update(subject, metadata, key, value, oldValue, { undo } = { undo: true }) {
const operation = {
verb: 'update',
subject,
metadata,
key,
value,
}
const stage = {
operation,
oldValue: oldValue,
newValue: value,
}
if (this._batch) {
this._batch.push(stage)
return
}
if (undo) this._undoManager.add(stage)
this._send(operation)
}
delete(subject, metadata, oldValue) {
const operation = {
verb: 'delete',
subject,
metadata,
}
const stage = {
operation,
oldValue: oldValue,
}
if (this._batch) {
this._batch.push(stage)
return
}
this._undoManager.add(stage)
this._send(operation)
}
async save() {
const needSave = new Map()
if (!this._umap.id) {
// There is no operation for first map save
needSave.set(this._umap, [])
}
for (const operation of this._operations.sorted()) {
if (operation.dirty) {
const updater = this._getUpdater(operation.subject)
const obj = updater.getStoredObject(operation.metadata)
if (!needSave.has(obj)) {
needSave.set(obj, [])
}
needSave.get(obj).push(operation)
}
}
for (const [obj, operations] of needSave.entries()) {
const ok = await obj.save()
if (!ok) break
for (const operation of operations) {
operation.dirty = false
}
}
this.saved()
this._undoManager.toggleState()
} }
saved() { saved() {
@ -144,8 +244,8 @@ export class SyncEngine {
} }
} }
_send(inputMessage) { _send(operation) {
const message = this._operations.addLocal(inputMessage) const message = this._operations.addLocal(operation)
if (this.offline) return if (this.offline) return
if (this.transport) { if (this.transport) {
@ -153,7 +253,11 @@ export class SyncEngine {
} }
} }
_getUpdater(subject, metadata) { _getUpdater(subject, metadata, sync) {
// For now, prevent permissions to be synced, for security reasons
if (sync && (subject === 'mappermissions' || subject === 'datalayerpermissions')) {
return
}
if (Object.keys(this.updaters).includes(subject)) { if (Object.keys(this.updaters).includes(subject)) {
return this.updaters[subject] return this.updaters[subject]
} }
@ -161,7 +265,15 @@ export class SyncEngine {
} }
_applyOperation(operation) { _applyOperation(operation) {
if (operation.verb === 'batch') {
operation.operations.map((op) => this._applyOperation(op))
return
}
const updater = this._getUpdater(operation.subject, operation.metadata) const updater = this._getUpdater(operation.subject, operation.metadata)
if (!updater) {
debug('No updater for', operation)
return
}
updater.applyMessage(operation) updater.applyMessage(operation)
} }
@ -304,9 +416,8 @@ export class SyncEngine {
onSavedMessage({ sender, lastKnownHLC }) { onSavedMessage({ sender, lastKnownHLC }) {
debug(`received saved message from peer ${sender}`, lastKnownHLC) debug(`received saved message from peer ${sender}`, lastKnownHLC)
if (lastKnownHLC === this._operations.getLastKnownHLC() && SaveManager.isDirty) { this._operations.saved(lastKnownHLC)
SaveManager.clear() this._undoManager.toggleState()
}
} }
/** /**
@ -356,7 +467,7 @@ export class SyncEngine {
const handler = { const handler = {
get(target, prop) { get(target, prop) {
// Only proxy these methods // Only proxy these methods
if (['upsert', 'update', 'delete'].includes(prop)) { if (['upsert', 'update', 'delete', 'commitBatch'].includes(prop)) {
const { subject, metadata } = object.getSyncMetadata() const { subject, metadata } = object.getSyncMetadata()
// Reflect.get is calling the original method. // Reflect.get is calling the original method.
// .bind is adding the parameters automatically // .bind is adding the parameters automatically
@ -378,16 +489,22 @@ export class Operations {
this._operations = new Array() this._operations = new Array()
} }
saved(hlc) {
for (const operation of this.getOperationsBefore(hlc)) {
operation.dirty = false
}
}
/** /**
* Tick the clock and store the passed message in the operations list. * Tick the clock and store the passed message in the operations list.
* *
* @param {*} inputMessage * @param {*} inputMessage
* @returns {*} clock-aware message * @returns {*} clock-aware message
*/ */
addLocal(inputMessage) { addLocal(operation) {
const message = { ...inputMessage, hlc: this._hlc.tick() } operation.hlc = this._hlc.tick()
this._operations.push(message) this._operations.push(operation)
return message return operation
} }
/** /**
@ -445,6 +562,11 @@ export class Operations {
return this._operations.filter((op) => op.hlc > hlc) return this._operations.filter((op) => op.hlc > hlc)
} }
getOperationsBefore(hlc) {
if (!hlc) return this._operations
return this._operations.filter((op) => op.hlc <= hlc)
}
/** /**
* Returns the last known HLC value. * Returns the last known HLC value.
*/ */

View file

@ -0,0 +1,101 @@
import * as Utils from '../utils.js'
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
export class UndoManager {
constructor(umap, updaters, syncEngine) {
this._umap = umap
this._syncEngine = syncEngine
this.updaters = updaters
this._undoStack = []
this._redoStack = []
}
toggleState() {
// document is undefined during unittests
if (typeof document === 'undefined') return
const undoButton = document.querySelector('.edit-undo')
const redoButton = document.querySelector('.edit-redo')
if (undoButton) undoButton.disabled = !this._undoStack.length
if (redoButton) redoButton.disabled = !this._redoStack.length
const dirty = this.isDirty()
document.body.classList.toggle('umap-is-dirty', dirty)
for (const button of document.querySelectorAll('.disabled-on-dirty')) {
button.disabled = dirty
}
for (const button of document.querySelectorAll('.enabled-on-dirty')) {
button.disabled = !dirty
}
}
isDirty() {
if (!this._umap.id) return true
for (const stage of this._undoStack) {
if (stage.operation.dirty) return true
}
for (const stage of this._redoStack) {
if (stage.operation.dirty) return true
}
return false
}
add(stage) {
stage.operation.dirty = true
this._redoStack = []
this._undoStack.push(stage)
this.toggleState()
}
copyOperation(stage, redo) {
const operation = Utils.CopyJSON(stage.operation)
const value = redo ? stage.newValue : stage.oldValue
operation.value = value
if (['delete', 'upsert'].includes(operation.verb)) {
operation.verb = value === null || value === undefined ? 'delete' : 'upsert'
}
return operation
}
undo(redo = false) {
const fromStack = redo ? this._redoStack : this._undoStack
const toStack = redo ? this._undoStack : this._redoStack
const stage = fromStack.pop()
if (!stage) return
stage.operation.dirty = !stage.operation.dirty
if (stage.operation.verb === 'batch') {
for (const st of stage.stages) {
this.applyOperation(this.copyOperation(st, redo))
}
} else {
this.applyOperation(this.copyOperation(stage, redo))
}
toStack.push(stage)
this.toggleState()
}
redo() {
this.undo(true)
}
applyOperation(operation) {
const updater = this._getUpdater(operation.subject, operation.metadata)
switch (operation.verb) {
case 'update':
updater.update(operation)
break
case 'delete':
updater.delete(operation)
break
case 'upsert':
updater.upsert(operation)
break
}
this._syncEngine._send(operation)
}
_getUpdater(subject, metadata) {
if (Object.keys(this.updaters).includes(subject)) {
return this.updaters[subject]
}
throw new Error(`Unknown updater ${subject}, ${metadata}`)
}
}

View file

@ -1,4 +1,4 @@
import { fieldInSchema } from '../utils.js' import * as Utils from '../utils.js'
/** /**
* Updaters are classes able to convert messages * Updaters are classes able to convert messages
@ -10,27 +10,6 @@ class BaseUpdater {
this._umap = umap this._umap = umap
} }
updateObjectValue(obj, key, value) {
const parts = key.split('.')
const lastKey = parts.pop()
// Reduce the current list of attributes,
// to find the object to set the property onto
const objectToSet = parts.reduce((currentObj, part) => {
if (currentObj !== undefined && part in currentObj) return currentObj[part]
}, obj)
// In case the given path doesn't exist, stop here
if (objectToSet === undefined) return
// Set the value (or delete it)
if (typeof value === 'undefined') {
delete objectToSet[lastKey]
} else {
objectToSet[lastKey] = value
}
}
getDataLayerFromID(layerId) { getDataLayerFromID(layerId) {
return this._umap.getDataLayerByUmapId(layerId) return this._umap.getDataLayerByUmapId(layerId)
} }
@ -43,13 +22,17 @@ class BaseUpdater {
export class MapUpdater extends BaseUpdater { export class MapUpdater extends BaseUpdater {
update({ key, value }) { update({ key, value }) {
if (fieldInSchema(key)) { if (Utils.fieldInSchema(key)) {
this.updateObjectValue(this._umap, key, value) Utils.setObjectValue(this._umap, key, value)
} }
this._umap.onPropertiesUpdated([key]) this._umap.onPropertiesUpdated([key])
this._umap.render([key]) this._umap.render([key])
} }
getStoredObject() {
return this._umap
}
} }
export class DataLayerUpdater extends BaseUpdater { export class DataLayerUpdater extends BaseUpdater {
@ -58,14 +41,21 @@ export class DataLayerUpdater extends BaseUpdater {
try { try {
this.getDataLayerFromID(value.id) this.getDataLayerFromID(value.id)
} catch { } catch {
this._umap.createDataLayer(value, false) const datalayer = this._umap.createDataLayer(value._umap_options || value, false)
if (value.features) {
// FIXME: this will create new stages in the undoStack, thus this will empty
// the redoStack
datalayer.addData(value)
}
} }
} }
update({ key, metadata, value }) { update({ key, metadata, value }) {
const datalayer = this.getDataLayerFromID(metadata.id) const datalayer = this.getDataLayerFromID(metadata.id)
if (fieldInSchema(key)) { if (key === 'options') {
this.updateObjectValue(datalayer, key, value) datalayer.setOptions(value)
} else if (Utils.fieldInSchema(key)) {
Utils.setObjectValue(datalayer, key, value)
} else { } else {
console.debug( console.debug(
'Not applying update for datalayer because key is not in the schema', 'Not applying update for datalayer because key is not in the schema',
@ -82,6 +72,10 @@ export class DataLayerUpdater extends BaseUpdater {
datalayer.commitDelete() datalayer.commitDelete()
} }
} }
getStoredObject(metadata) {
return this.getDataLayerFromID(metadata.id)
}
} }
export class FeatureUpdater extends BaseUpdater { export class FeatureUpdater extends BaseUpdater {
@ -114,7 +108,7 @@ export class FeatureUpdater extends BaseUpdater {
const feature = this.getFeatureFromMetadata(metadata) const feature = this.getFeatureFromMetadata(metadata)
feature.geometry = value feature.geometry = value
} else { } else {
this.updateObjectValue(feature, key, value) Utils.setObjectValue(feature, key, value)
feature.datalayer.indexProperties(feature) feature.datalayer.indexProperties(feature)
} }
@ -127,4 +121,32 @@ export class FeatureUpdater extends BaseUpdater {
const feature = this.getFeatureFromMetadata(metadata) const feature = this.getFeatureFromMetadata(metadata)
if (feature) feature.del(false) if (feature) feature.del(false)
} }
getStoredObject(metadata) {
return this.getDataLayerFromID(metadata.layerId)
}
}
export class MapPermissionsUpdater extends BaseUpdater {
update({ key, value }) {
if (Utils.fieldInSchema(key)) {
Utils.setObjectValue(this._umap.permissions, key, value)
}
}
getStoredObject(metadata) {
return this._umap.permissions
}
}
export class DataLayerPermissionsUpdater extends BaseUpdater {
update({ key, value, metadata }) {
if (Utils.fieldInSchema(key)) {
Utils.setObjectValue(this.getDataLayerFromID(metadata.id), key, value)
}
}
getStoredObject(metadata) {
return this.getDataLayerFromID(metadata.id).permissions
}
} }

View file

@ -22,15 +22,19 @@ const TOP_BAR_TEMPLATE = `
<span class="username" data-ref="username"></span> <span class="username" data-ref="username"></span>
</button> </button>
<button class="umap-help-link flat" type="button" title="${translate('Help')}" data-ref="help">${translate('Help')}</button> <button class="umap-help-link flat" type="button" title="${translate('Help')}" data-ref="help">${translate('Help')}</button>
<button class="edit-cancel round" type="button" data-ref="cancel"> <button class="edit-undo round" type="button" data-ref="undo" disabled>
<i class="icon icon-16 icon-restore"></i> <i class="icon icon-16 icon-undo"></i>
<span class="">${translate('Cancel edits')}</span> <span class="">${translate('Undo')}</span>
</button> </button>
<button class="edit-disable round" type="button" data-ref="view"> <button class="edit-redo round" type="button" data-ref="redo" disabled>
<i class="icon icon-16 icon-redo"></i>
<span class="">${translate('Redo')}</span>
</button>
<button class="edit-disable round disabled-on-dirty" type="button" data-ref="view">
<i class="icon icon-16 icon-eye"></i> <i class="icon icon-16 icon-eye"></i>
<span class="">${translate('View')}</span> <span class="">${translate('View')}</span>
</button> </button>
<button class="edit-save button round" type="button" data-ref="save"> <button class="edit-save button round enabled-on-dirty" type="button" data-ref="save">
<i class="icon icon-16 icon-save"></i> <i class="icon icon-16 icon-save"></i>
<i class="icon icon-16 icon-save-disabled"></i> <i class="icon icon-16 icon-save-disabled"></i>
<span hidden data-ref="saveLabel">${translate('Save')}</span> <span hidden data-ref="saveLabel">${translate('Save')}</span>
@ -118,11 +122,12 @@ export class TopBar extends WithTemplate {
}) })
this.elements.help.addEventListener('click', () => this._umap.help.showGetStarted()) this.elements.help.addEventListener('click', () => this._umap.help.showGetStarted())
this.elements.cancel.addEventListener('click', () => this._umap.askForReset()) this.elements.redo.addEventListener('click', () => this._umap.redo())
this.elements.cancel.addEventListener('mouseover', () => { this.elements.undo.addEventListener('click', () => this._umap.undo())
this.elements.undo.addEventListener('mouseover', () => {
this._umap.tooltip.open({ this._umap.tooltip.open({
content: this._umap.help.displayLabel('CANCEL'), content: this._umap.help.displayLabel('CANCEL'),
anchor: this.elements.cancel, anchor: this.elements.undo,
position: 'bottom', position: 'bottom',
delay: 500, delay: 500,
duration: 5000, duration: 5000,
@ -154,9 +159,10 @@ export class TopBar extends WithTemplate {
redraw() { redraw() {
const syncEnabled = this._umap.getProperty('syncEnabled') const syncEnabled = this._umap.getProperty('syncEnabled')
this.elements.peers.hidden = !syncEnabled this.elements.peers.hidden = !syncEnabled
this.elements.cancel.hidden = syncEnabled this.elements.view.disabled = this._umap.sync._undoManager.isDirty()
this.elements.saveLabel.hidden = this._umap.permissions.isDraft() this.elements.saveLabel.hidden = this._umap.permissions.isDraft()
this.elements.saveDraftLabel.hidden = !this._umap.permissions.isDraft() this.elements.saveDraftLabel.hidden = !this._umap.permissions.isDraft()
this._umap.sync._undoManager.toggleState()
} }
} }
@ -240,10 +246,7 @@ export class EditBar extends WithTemplate {
DomEvent.disableClickPropagation(this.element) DomEvent.disableClickPropagation(this.element)
this._onClick('marker', () => this._leafletMap.editTools.startMarker()) this._onClick('marker', () => this._leafletMap.editTools.startMarker())
this._onClick('polyline', () => this._leafletMap.editTools.startPolyline()) this._onClick('polyline', () => this._leafletMap.editTools.startPolyline())
this._onClick('multiline', () => { this._onClick('multiline', () => this._umap.editedFeature.ui.editor.newShape())
console.log('click click')
this._umap.editedFeature.ui.editor.newShape()
})
this._onClick('polygon', () => this._leafletMap.editTools.startPolygon()) this._onClick('polygon', () => this._leafletMap.editTools.startPolygon())
this._onClick('multipolygon', () => this._umap.editedFeature.ui.editor.newShape()) this._onClick('multipolygon', () => this._umap.editedFeature.ui.editor.newShape())
this._onClick('caption', () => this._umap.editCaption()) this._onClick('caption', () => this._umap.editCaption())

View file

@ -1,13 +1,10 @@
import { import {
DomUtil, DomUtil,
Util as LeafletUtil, Util as LeafletUtil,
stamp,
latLngBounds, latLngBounds,
} from '../../vendors/leaflet/leaflet-src.esm.js' } from '../../vendors/leaflet/leaflet-src.esm.js'
import { translate, setLocale, getLocale } from './i18n.js' import { translate, setLocale, getLocale } from './i18n.js'
import * as Utils from './utils.js' import * as Utils from './utils.js'
import { ServerStored } from './saving.js'
import * as SAVEMANAGER from './saving.js'
import { SyncEngine } from './sync/engine.js' import { SyncEngine } from './sync/engine.js'
import { LeafletMap } from './rendering/map.js' import { LeafletMap } from './rendering/map.js'
import URLs from './urls.js' import URLs from './urls.js'
@ -36,9 +33,8 @@ import {
import Orderable from './orderable.js' import Orderable from './orderable.js'
import { MutatingForm } from './form/builder.js' import { MutatingForm } from './form/builder.js'
export default class Umap extends ServerStored { export default class Umap {
constructor(element, geojson) { constructor(element, geojson) {
super()
// We need to call async function in the init process, // We need to call async function in the init process,
// the init itself does not need to be awaited, but some calls // the init itself does not need to be awaited, but some calls
// in the process must be blocker // in the process must be blocker
@ -96,6 +92,10 @@ export default class Umap extends ServerStored {
this._leafletMap.latLng(center) this._leafletMap.latLng(center)
} }
// Needed for permissions
this.syncEngine = new SyncEngine(this)
this.sync = this.syncEngine.proxy(this)
// Needed to render controls // Needed to render controls
this.permissions = new MapPermissions(this) this.permissions = new MapPermissions(this)
this.urls = new URLs(this.properties.urls) this.urls = new URLs(this.properties.urls)
@ -130,9 +130,6 @@ export default class Umap extends ServerStored {
this.share = new Share(this) this.share = new Share(this)
this.rules = new Rules(this) this.rules = new Rules(this)
this.syncEngine = new SyncEngine(this)
this.sync = this.syncEngine.proxy(this)
if (this.hasEditMode()) { if (this.hasEditMode()) {
this.editPanel = new EditPanel(this, this._leafletMap) this.editPanel = new EditPanel(this, this._leafletMap)
this.fullPanel = new FullPanel(this, this._leafletMap) this.fullPanel = new FullPanel(this, this._leafletMap)
@ -196,7 +193,6 @@ export default class Umap extends ServerStored {
// Creation mode // Creation mode
if (!this.id) { if (!this.id) {
if (!this.properties.preview) { if (!this.properties.preview) {
this.isDirty = true
this.enableEdit() this.enableEdit()
} }
this._defaultExtent = true this._defaultExtent = true
@ -212,10 +208,14 @@ export default class Umap extends ServerStored {
this.propagate() this.propagate()
} }
window.onbeforeunload = () => (this.editEnabled && SAVEMANAGER.isDirty) || null window.onbeforeunload = () => (this.editEnabled && this.isDirty) || null
this.backup() this.backup()
} }
get isDirty() {
return this.sync._undoManager.isDirty()
}
get editedFeature() { get editedFeature() {
return this._editedFeature return this._editedFeature
} }
@ -349,7 +349,7 @@ export default class Umap extends ServerStored {
const items = [] const items = []
if (this.hasEditMode()) { if (this.hasEditMode()) {
if (this.editEnabled) { if (this.editEnabled) {
if (!SAVEMANAGER.isDirty) { if (!this.isDirty) {
items.push({ items.push({
label: this.help.displayLabel('STOP_EDIT'), label: this.help.displayLabel('STOP_EDIT'),
action: () => this.disableEdit(), action: () => this.disableEdit(),
@ -543,19 +543,17 @@ export default class Umap extends ServerStored {
let used = true let used = true
switch (event.key) { switch (event.key) {
case 'e': case 'e':
if (!SAVEMANAGER.isDirty) this.disableEdit() if (!this.isDirty) this.disableEdit()
break break
case 's': case 's':
if (SAVEMANAGER.isDirty) this.saveAll() if (this.isDirty) this.saveAll()
break break
case 'z': case 'z':
if (Utils.isWritable(event.target)) { if (Utils.isWritable(event.target)) {
used = false used = false
break break
} }
if (SAVEMANAGER.isDirty) { this.sync._undoManager.undo()
this.askForReset()
}
break break
case 'm': case 'm':
this._leafletMap.editTools.startMarker() this._leafletMap.editTools.startMarker()
@ -671,10 +669,10 @@ export default class Umap extends ServerStored {
} }
async saveAll() { async saveAll() {
if (!SAVEMANAGER.isDirty) return if (!this.isDirty) return
if (this._defaultExtent) this._setCenterAndZoom() if (this._defaultExtent) this._setCenterAndZoom()
this.backup() this.backup()
await SAVEMANAGER.save() await this.sync.save()
// Do a blind render for now, as we are not sure what could // Do a blind render for now, as we are not sure what could
// have changed, we'll be more subtil when we'll remove the // have changed, we'll be more subtil when we'll remove the
// save action // save action
@ -685,7 +683,6 @@ export default class Umap extends ServerStored {
Alert.success(translate('Map has been saved!')) Alert.success(translate('Map has been saved!'))
}) })
} }
this.sync.saved()
this.fire('saved') this.fire('saved')
} }
@ -1019,35 +1016,36 @@ export default class Umap extends ServerStored {
'button', 'button',
boundsButtons, boundsButtons,
translate('Use current bounds'), translate('Use current bounds'),
function () { () => {
const bounds = this._leafletMap.getBounds() const bounds = this._leafletMap.getBounds()
const oldLimitBounds = { ...this.properties.limitBounds }
this.properties.limitBounds.south = LeafletUtil.formatNum(bounds.getSouth()) this.properties.limitBounds.south = LeafletUtil.formatNum(bounds.getSouth())
this.properties.limitBounds.west = LeafletUtil.formatNum(bounds.getWest()) this.properties.limitBounds.west = LeafletUtil.formatNum(bounds.getWest())
this.properties.limitBounds.north = LeafletUtil.formatNum(bounds.getNorth()) this.properties.limitBounds.north = LeafletUtil.formatNum(bounds.getNorth())
this.properties.limitBounds.east = LeafletUtil.formatNum(bounds.getEast()) this.properties.limitBounds.east = LeafletUtil.formatNum(bounds.getEast())
boundsBuilder.fetchAll() boundsBuilder.fetchAll()
this.sync.update(
this.sync.update(this, 'properties.limitBounds', this.properties.limitBounds) 'properties.limitBounds',
this.isDirty = true this.properties.limitBounds,
oldLimitBounds
)
this._leafletMap.handleLimitBounds() this._leafletMap.handleLimitBounds()
}, }
this
)
DomUtil.createButton(
'button',
boundsButtons,
translate('Empty'),
function () {
this.properties.limitBounds.south = null
this.properties.limitBounds.west = null
this.properties.limitBounds.north = null
this.properties.limitBounds.east = null
boundsBuilder.fetchAll()
this.isDirty = true
this._leafletMap.handleLimitBounds()
},
this
) )
DomUtil.createButton('button', boundsButtons, translate('Empty'), () => {
const oldLimitBounds = { ...this.properties.limitBounds }
this.properties.limitBounds.south = null
this.properties.limitBounds.west = null
this.properties.limitBounds.north = null
this.properties.limitBounds.east = null
boundsBuilder.fetchAll()
this._leafletMap.handleLimitBounds()
this.sync.update(
'properties.limitBounds',
this.properties.limitBounds,
oldLimitBounds
)
})
} }
_editSlideshow(container) { _editSlideshow(container) {
@ -1160,23 +1158,7 @@ export default class Umap extends ServerStored {
}) })
} }
reset() {
if (this._leafletMap.editTools) this._leafletMap.editTools.stopDrawing()
this.resetProperties()
this.datalayersIndex = [].concat(this._datalayersIndex_bk)
// Iter over all datalayers, including deleted if any.
for (const datalayer of Object.values(this.datalayers)) {
if (datalayer.isDeleted) datalayer.connectToMap()
if (datalayer.isDirty) datalayer.reset()
}
this.ensurePanesOrder()
this._leafletMap.initTileLayers()
this.onDataLayersChanged()
this.isDirty = !this.id
}
async save() { async save() {
this.rules.commit()
const geojson = { const geojson = {
type: 'Feature', type: 'Feature',
geometry: this.geometry(), geometry: this.geometry(),
@ -1301,16 +1283,6 @@ export default class Umap extends ServerStored {
this._leafletMap.fire(name) this._leafletMap.fire(name)
} }
askForReset(e) {
if (this.getProperty('syncEnabled')) return
this.dialog
.confirm(translate('Are you sure you want to cancel your changes?'))
.then(() => {
this.reset()
this.disableEdit()
})
}
async initSyncEngine() { async initSyncEngine() {
// this.properties.websocketEnabled is set by the server admin // this.properties.websocketEnabled is set by the server admin
if (this.properties.websocketEnabled === false) return if (this.properties.websocketEnabled === false) return
@ -1324,7 +1296,6 @@ export default class Umap extends ServerStored {
getSyncMetadata() { getSyncMetadata() {
return { return {
engine: this.sync,
subject: 'map', subject: 'map',
} }
} }
@ -1348,6 +1319,9 @@ export default class Umap extends ServerStored {
this.bottomBar.redraw() this.bottomBar.redraw()
break break
case 'data': case 'data':
if (fields.includes('properties.rules')) {
this.rules.load()
}
this.eachVisibleDataLayer((datalayer) => { this.eachVisibleDataLayer((datalayer) => {
datalayer.redraw() datalayer.redraw()
}) })
@ -1522,7 +1496,7 @@ export default class Umap extends ServerStored {
const form = builder.build() const form = builder.build()
row.appendChild(form) row.appendChild(form)
row.classList.toggle('off', !datalayer.isVisible()) row.classList.toggle('off', !datalayer.isVisible())
row.dataset.id = stamp(datalayer) row.dataset.id = datalayer.id
}) })
const onReorder = (src, dst, initialIndex, finalIndex) => { const onReorder = (src, dst, initialIndex, finalIndex) => {
const movedLayer = this.datalayers[src.dataset.id] const movedLayer = this.datalayers[src.dataset.id]
@ -1553,7 +1527,7 @@ export default class Umap extends ServerStored {
} }
getDataLayerByUmapId(id) { getDataLayerByUmapId(id) {
const datalayer = this.findDataLayer((d) => d.id === id) const datalayer = this.datalayers[id]
if (!datalayer) throw new Error(`Can't find datalayer with id ${id}`) if (!datalayer) throw new Error(`Can't find datalayer with id ${id}`)
return datalayer return datalayer
} }
@ -1669,7 +1643,6 @@ export default class Umap extends ServerStored {
) )
this.render(fields) this.render(fields)
this._leafletMap._setDefaultCenter() this._leafletMap._setDefaultCenter()
this.isDirty = true
} }
importUmapFile(file) { importUmapFile(file) {
@ -1768,13 +1741,26 @@ export default class Umap extends ServerStored {
} }
_setCenterAndZoom() { _setCenterAndZoom() {
const oldCenter = { ...this.properties.center }
const oldZoom = this.properties.zoom
this.properties.center = this._leafletMap.getCenter() this.properties.center = this._leafletMap.getCenter()
this.properties.zoom = this._leafletMap.getZoom() this.properties.zoom = this._leafletMap.getZoom()
this.isDirty = true
this._defaultExtent = false this._defaultExtent = false
this.sync.startBatch()
this.sync.update('properties.center', this.properties.center, oldCenter)
this.sync.update('properties.zoom', this.properties.zoom, oldZoom)
this.sync.commitBatch()
} }
getStaticPathFor(name) { getStaticPathFor(name) {
return SCHEMA.iconUrl.default.replace('marker.svg', name) return SCHEMA.iconUrl.default.replace('marker.svg', name)
} }
undo() {
this.sync._undoManager.undo()
}
redo() {
this.sync._undoManager.redo()
}
} }

View file

@ -481,6 +481,27 @@ export const debounce = (callback, wait) => {
} }
} }
export function setObjectValue(obj, key, value) {
const parts = key.split('.')
const lastKey = parts.pop()
// Reduce the current list of attributes,
// to find the object to set the property onto
const objectToSet = parts.reduce((currentObj, part) => {
if (currentObj !== undefined && part in currentObj) return currentObj[part]
}, obj)
// In case the given path doesn't exist, stop here
if (objectToSet === undefined) return
// Set the value (or delete it)
if (typeof value === 'undefined') {
delete objectToSet[lastKey]
} else {
objectToSet[lastKey] = value
}
}
export const COLORS = [ export const COLORS = [
'Black', 'Black',
'Navy', 'Navy',

View file

@ -297,6 +297,7 @@ U.TileLayerChooser = L.Control.extend({
el, el,
'click', 'click',
() => { () => {
const oldTileLayer = this.map._umap.properties.tilelayer
this.map.selectTileLayer(tilelayer) this.map.selectTileLayer(tilelayer)
this.map._controls.tilelayers.setLayers() this.map._controls.tilelayers.setLayers()
if (options?.edit) { if (options?.edit) {
@ -304,7 +305,8 @@ U.TileLayerChooser = L.Control.extend({
this.map._umap.isDirty = true this.map._umap.isDirty = true
this.map._umap.sync.update( this.map._umap.sync.update(
'properties.tilelayer', 'properties.tilelayer',
this.map._umap.properties.tilelayer this.map._umap.properties.tilelayer,
oldTileLayer
) )
} }
}, },
@ -606,7 +608,7 @@ U.Editable = L.Editable.extend({
this.on('editable:editing', (event) => { this.on('editable:editing', (event) => {
const feature = event.layer.feature const feature = event.layer.feature
feature.isDirty = true feature.isDirty = true
feature.pullGeometry(false) // feature.pullGeometry(false)
}) })
this.on('editable:vertex:ctrlclick', (event) => { this.on('editable:vertex:ctrlclick', (event) => {
const index = event.vertex.getIndex() const index = event.vertex.getIndex()
@ -624,18 +626,20 @@ U.Editable = L.Editable.extend({
createPolyline: function (latlngs) { createPolyline: function (latlngs) {
const datalayer = this._umap.defaultEditDataLayer() const datalayer = this._umap.defaultEditDataLayer()
const point = new U.LineString(this._umap, datalayer, { const line = new U.LineString(this._umap, datalayer, {
geometry: { type: 'LineString', coordinates: [] }, geometry: { type: 'LineString', coordinates: [] },
}) })
return point.ui line._just_married = true
return line.ui
}, },
createPolygon: function (latlngs) { createPolygon: function (latlngs) {
const datalayer = this._umap.defaultEditDataLayer() const datalayer = this._umap.defaultEditDataLayer()
const point = new U.Polygon(this._umap, datalayer, { const poly = new U.Polygon(this._umap, datalayer, {
geometry: { type: 'Polygon', coordinates: [] }, geometry: { type: 'Polygon', coordinates: [] },
}) })
return point.ui poly._just_married = true
return poly.ui
}, },
createMarker: function (latlng) { createMarker: function (latlng) {
@ -643,6 +647,7 @@ U.Editable = L.Editable.extend({
const point = new U.Point(this._umap, datalayer, { const point = new U.Point(this._umap, datalayer, {
geometry: { type: 'Point', coordinates: [latlng.lng, latlng.lat] }, geometry: { type: 'Point', coordinates: [latlng.lng, latlng.lat] },
}) })
point._just_married = true
return point.ui return point.ui
}, },
@ -734,6 +739,7 @@ U.Editable = L.Editable.extend({
// Leaflet.Editable will delete the drawn shape if invalid // Leaflet.Editable will delete the drawn shape if invalid
// (eg. line has only one drawn point) // (eg. line has only one drawn point)
// So let's check if the layer has no more shape // So let's check if the layer has no more shape
event.layer.feature.pullGeometry(false)
if (!event.layer.feature.hasGeom()) { if (!event.layer.feature.hasGeom()) {
event.layer.feature.del() event.layer.feature.del()
} else { } else {

View file

@ -475,22 +475,6 @@ ul.photon-autocomplete {
font-style: italic; font-style: italic;
} }
.umap-datalayer-version {
padding: 5px 0;
border-bottom: 1px solid #202425;
}
.umap-datalayer-version button {
display: inline-block;
width: 24px;
min-height: 24px;
background-position: -122px -73px;
background-repeat: no-repeat;
background-image: url('./img/16-white.svg');
margin-inline-end: 10px;
}
.leaflet-toolbar-tip { .leaflet-toolbar-tip {
background-color: var(--color-darkGray); background-color: var(--color-darkGray);
} }
@ -1006,7 +990,7 @@ a.umap-control-caption,
.umap-main-edit-toolbox .umap-user span, .umap-main-edit-toolbox .umap-user span,
.leaflet-container .leaflet-control-edit-save span, .leaflet-container .leaflet-control-edit-save span,
.leaflet-container .leaflet-control-edit-disable span, .leaflet-container .leaflet-control-edit-disable span,
.leaflet-container .leaflet-control-edit-cancel span { .leaflet-container .edit-cancel span {
display: none; display: none;
} }
.umap-main-edit-toolbox .umap-help-button { .umap-main-edit-toolbox .umap-help-button {

View file

@ -1,21 +0,0 @@
{
"globals": {
"describe": true,
"happen": true,
"assert": true,
"before": true,
"after": true,
"it": true,
"sinon": true,
"enableEdit": true,
"disableEdit": true,
"changeInputValue": true,
"resetMap": true,
"initMap": true,
"clickCancel": true,
"map": true,
"qs": true,
"qsa": true,
"qst": true
}
}

View file

@ -1,463 +0,0 @@
describe('U.DataLayer', () => {
let path = '/map/99/datalayer/update/62/',
map,
datalayer
before(async () => {
fetchMock.mock(/\/datalayer\/62\/\?.*/, JSON.stringify(RESPONSES.datalayer62_GET))
fetchMock.sticky('/map/99/update/settings/', { id: 99 })
this.options = {
umap_id: 99,
}
MAP = map = initMap({ umap_id: 99 })
const datalayer_options = defaultDatalayerData()
await map.initDataLayers([datalayer_options])
datalayer = map.getDataLayerByUmapId(62)
enableEdit()
})
after(() => {
fetchMock.restore()
resetMap()
})
describe('#init()', () => {
it('should be added in datalayers index', () => {
assert.notEqual(map.datalayers_index.indexOf(datalayer), -1)
})
})
describe('#edit()', () => {
var editButton, form, input, forceButton
it('row in control should be active', () => {
assert.notOk(
qs('.leaflet-control-browse #browse_data_toggle_' + L.stamp(datalayer) + '.off')
)
})
it('should have edit button', () => {
editButton = qs('#browse_data_toggle_' + L.stamp(datalayer) + ' .layer-edit')
assert.ok(editButton)
})
it('should have toggle visibility element', () => {
assert.ok(qs('.leaflet-control-browse i.layer-toggle'))
})
it('should exist only one datalayer', () => {
assert.equal(qsa('.leaflet-control-browse i.layer-toggle').length, 1)
})
it('should build a form on edit button click', () => {
happen.click(editButton)
form = qs('form.umap-form')
input = qs('form.umap-form input[name="name"]')
assert.ok(form)
assert.ok(input)
})
it('should update name on input change', () => {
var new_name = 'This is a new name'
input.value = new_name
happen.once(input, { type: 'input' })
assert.equal(datalayer.options.name, new_name)
})
it('should have made datalayer dirty', () => {
assert.ok(datalayer.isDirty)
assert.notEqual(map.dirty_datalayers.indexOf(datalayer), -1)
})
it('should have made Map dirty', () => {
assert.ok(map.isDirty)
})
it('should call datalayer.save on save button click', (done) => {
const postDatalayer = fetchMock.post(path, () => {
return defaultDatalayerData()
})
clickSave()
window.setTimeout(() => {
assert(fetchMock.called(path))
done()
}, 500)
})
it('should show alert if server respond 412', (done) => {
cleanAlert()
fetchMock.restore()
fetchMock.post(path, 412)
happen.click(editButton)
input = qs('form.umap-form input[name="name"]')
input.value = 'a new name'
happen.once(input, { type: 'input' })
clickSave()
window.setTimeout(() => {
assert(L.DomUtil.hasClass(map._container, 'umap-alert'))
assert.notEqual(map.dirty_datalayers.indexOf(datalayer), -1)
const forceButton = qs('#umap-alert-container .umap-action')
assert.ok(forceButton)
done()
}, 500)
})
it('should save anyway on force save button click', (done) => {
const forceButton = qs('#umap-alert-container .umap-action')
fetchMock.restore()
fetchMock.post(path, defaultDatalayerData)
happen.click(forceButton)
window.setTimeout(() => {
assert.notOk(qs('#umap-alert-container .umap-action'))
assert(fetchMock.called(path))
assert.equal(map.dirty_datalayers.indexOf(datalayer), -1)
done()
}, 500)
})
})
describe('#save() new', () => {
let newLayerButton, form, input, newDatalayer, editButton, manageButton
it('should have a manage datalayers action', () => {
enableEdit()
manageButton = qs('.manage-datalayers')
assert.ok(manageButton)
happen.click(manageButton)
})
it('should have a new layer button', () => {
newLayerButton = qs('.panel.right.on .add-datalayer')
assert.ok(newLayerButton)
})
it('should build a form on new layer button click', () => {
happen.click(newLayerButton)
form = qs('form.umap-form')
input = qs('form.umap-form input[name="name"]')
assert.ok(form)
assert.ok(input)
})
it('should have an empty name', () => {
assert.notOk(input.value)
})
it('should have created a new datalayer', () => {
assert.equal(map.datalayers_index.length, 2)
newDatalayer = map.datalayers_index[1]
})
it('should have made Map dirty', () => {
assert.ok(map.isDirty)
})
it('should update name on input change', () => {
var new_name = 'This is a new name'
input.value = new_name
happen.once(input, { type: 'input' })
assert.equal(newDatalayer.options.name, new_name)
})
it('should set umap_id on save callback', async () => {
assert.notOk(newDatalayer.umap_id)
fetchMock.post('/map/99/datalayer/create/', defaultDatalayerData({ id: 63 }))
clickSave()
return new Promise((resolve) => {
window.setTimeout(() => {
assert.equal(newDatalayer.umap_id, 63)
resolve()
}, 1000)
})
})
it('should have unset map dirty', () => {
assert.notOk(map.isDirty)
})
it('should have edit button', () => {
editButton = qs('#browse_data_toggle_' + L.stamp(newDatalayer) + ' .layer-edit')
assert.ok(editButton)
})
it('should call update if we edit again', async () => {
happen.click(editButton)
assert.notOk(map.isDirty)
input = qs('form.umap-form input[name="name"]')
input.value = "a new name again but we don't care which"
happen.once(input, { type: 'input' })
assert.ok(map.isDirty)
var response = () => {
return defaultDatalayerData({ pk: 63 })
}
var spy = sinon.spy(response)
fetchMock.post('/map/99/datalayer/update/63/', spy)
return new Promise((resolve) => {
clickSave()
window.setTimeout(() => {
assert.ok(spy.calledOnce)
resolve()
}, 1000)
})
})
})
describe('#iconClassChange()', () => {
it('should change icon class', () => {
happen.click(qs('[data-id="' + datalayer._leaflet_id + '"] .layer-edit'))
changeSelectValue(
qs('form#datalayer-advanced-properties select[name=iconClass]'),
'Circle'
)
assert.notOk(qs('div.umap-div-icon'))
assert.ok(qs('div.umap-circle-icon'))
happen.click(
qs('form#datalayer-advanced-properties .umap-field-iconClass .undefine')
)
assert.notOk(qs('div.umap-circle-icon'))
assert.ok(qs('div.umap-div-icon'))
clickCancel()
})
})
describe('#show/hide', () => {
it('should hide features on hide', () => {
assert.ok(qs('div.umap-div-icon'))
assert.ok(qs('path[fill="none"]'))
datalayer.hide()
assert.notOk(qs('div.umap-div-icon'))
assert.notOk(qs('path[fill="none"]'))
})
it('should show features on show', () => {
assert.notOk(qs('div.umap-div-icon'))
assert.notOk(qs('path[fill="none"]'))
datalayer.show()
assert.ok(qs('div.umap-div-icon'))
assert.ok(qs('path[fill="none"]'))
})
})
describe('#clone()', () => {
it('should clone everything but the id and the name', () => {
enableEdit()
var clone = datalayer.clone()
assert.notOk(clone.umap_id)
assert.notEqual(clone.options.name, datalayer.name)
assert.ok(clone.options.name)
assert.equal(clone.options.color, datalayer.options.color)
assert.equal(clone.options.stroke, datalayer.options.stroke)
clone._delete()
clickSave()
})
})
describe('#restore()', () => {
var oldConfirm,
newConfirm = () => {
return true
}
before(() => {
oldConfirm = window.confirm
window.confirm = newConfirm
})
after(() => {
window.confirm = oldConfirm
})
it('should restore everything', (done) => {
enableEdit()
var geojson = L.Util.CopyJSON(RESPONSES.datalayer62_GET)
geojson.features.push({
geometry: {
type: 'Point',
coordinates: [-1.274658203125, 50.57634993749885],
},
type: 'Feature',
id: 1807,
properties: { _umap_options: {}, name: 'new point from restore' },
})
geojson._umap_options.color = 'Chocolate'
fetchMock.get('/datalayer/62/olderversion.geojson', geojson)
sinon.spy(window, 'confirm')
datalayer.restore('olderversion.geojson')
window.setTimeout(() => {
assert(window.confirm.calledOnce)
window.confirm.restore()
assert.equal(datalayer.umap_id, 62)
assert.ok(datalayer.isDirty)
assert.equal(datalayer._index.length, 4)
assert.ok(qs('path[fill="Chocolate"]'))
done()
}, 1000)
})
it('should revert anything on cancel click', () => {
clickCancel()
assert.equal(datalayer._index.length, 3)
assert.notOk(qs('path[fill="Chocolate"]'))
})
})
describe('#smart-options()', () => {
let poly, marker
before(() => {
datalayer.eachLayer(function (layer) {
if (!poly && layer instanceof L.Polygon) {
poly = layer
}
if (!marker && layer instanceof L.Marker) {
marker = layer
}
})
})
it('should parse color variable', () => {
let icon = qs('div.umap-div-icon .icon_container')
poly.properties.mycolor = 'DarkGoldenRod'
marker.properties.mycolor = 'DarkRed'
marker.properties._umap_options.color = undefined
assert.notOk(qs('path[fill="DarkGoldenRod"]'))
assert.equal(icon.style.backgroundColor, 'olivedrab')
datalayer.options.color = '{mycolor}'
datalayer.options.fillColor = '{mycolor}'
datalayer.indexProperties(poly)
datalayer.indexProperties(marker)
datalayer.redraw()
icon = qs('div.umap-div-icon .icon_container')
assert.equal(icon.style.backgroundColor, 'darkred')
assert.ok(qs('path[fill="DarkGoldenRod"]'))
})
})
describe('#facet-search()', () => {
before(async () => {
fetchMock.get(/\/datalayer\/63\/\?.*/, RESPONSES.datalayer63_GET)
map.options.facetKey = 'name'
await map.initDataLayers([RESPONSES.datalayer63_GET._umap_options])
})
it('should not impact non browsable layer', () => {
assert.ok(qs('path[fill="SteelBlue"]'))
})
it('should allow advanced filter', () => {
map.openFacet()
assert.ok(qs('div.umap-facet-search'))
// This one if from the normal datalayer
// it's name is "test", so it should be hidden
// by the filter
assert.ok(qs('path[fill="none"]'))
happen.click(qs('input[data-value="name poly"]'))
assert.notOk(qs('path[fill="none"]'))
// This one comes from a non browsable layer
// so it should not be affected by the filter
assert.ok(qs('path[fill="SteelBlue"]'))
happen.click(qs('input[data-value="name poly"]')) // Undo
})
it('should allow to control facet label', () => {
map.options.facetKey = 'name|Nom'
map.openFacet()
assert.ok(qs('div.umap-facet-search h5'))
assert.equal(qs('div.umap-facet-search h5').textContent, 'Nom')
})
})
describe('#zoomEnd', () => {
it('should honour the fromZoom option', () => {
map.setZoom(6, { animate: false })
assert.ok(qs('path[fill="none"]'))
datalayer.options.fromZoom = 6
map.setZoom(5, { animate: false })
assert.notOk(qs('path[fill="none"]'))
map.setZoom(6, { animate: false })
assert.ok(qs('path[fill="none"]'))
})
it('should honour the toZoom option', () => {
map.setZoom(6, { animate: false })
assert.ok(qs('path[fill="none"]'))
datalayer.options.toZoom = 6
map.setZoom(7, { animate: false })
assert.notOk(qs('path[fill="none"]'))
map.setZoom(6, { animate: false })
assert.ok(qs('path[fill="none"]'))
})
})
describe('#displayOnLoad', () => {
before(() => {
fetchMock.get(/\/datalayer\/64\/\?.*/, RESPONSES.datalayer64_GET)
})
beforeEach(async () => {
await map.initDataLayers([RESPONSES.datalayer64_GET._umap_options])
datalayer = map.getDataLayerByUmapId(64)
map.setZoom(10, { animate: false })
})
afterEach(() => {
datalayer._delete()
})
it('should not display layer at load', () => {
assert.notOk(qs('path[fill="AliceBlue"]'))
})
it('should display on click', (done) => {
happen.click(qs(`[data-id='${L.stamp(datalayer)}'] .layer-toggle`))
window.setTimeout(() => {
assert.ok(qs('path[fill="AliceBlue"]'))
done()
}, 500)
})
it('should not display on zoom', (done) => {
map.setZoom(9, { animate: false })
window.setTimeout(() => {
assert.notOk(qs('path[fill="AliceBlue"]'))
done()
}, 500)
})
})
describe('#delete()', () => {
let deleteLink,
deletePath = '/map/99/datalayer/delete/62/'
before(() => {
datalayer = map.getDataLayerByUmapId(62)
})
it('should have a delete link in update form', () => {
enableEdit()
happen.click(qs('#browse_data_toggle_' + L.stamp(datalayer) + ' .layer-edit'))
deleteLink = qs('button.delete_datalayer_button')
assert.ok(deleteLink)
})
it('should delete features on datalayer delete', () => {
happen.click(deleteLink)
assert.notOk(qs('div.icon_container'))
})
it('should have set map dirty', () => {
assert.ok(map.isDirty)
})
it('should delete layer control row on delete', () => {
assert.notOk(
qs('.leaflet-control-browse #browse_data_toggle_' + L.stamp(datalayer))
)
})
it('should be removed from map.datalayers_index', () => {
assert.equal(map.datalayers_index.indexOf(datalayer), -1)
})
it('should be removed from map.datalayers', () => {
assert.notOk(map.datalayers[L.stamp(datalayer)])
})
it('should be visible again on edit cancel', () => {
clickCancel()
assert.ok(qs('div.icon_container'))
})
})
})

View file

@ -1,131 +0,0 @@
describe('U.FeatureMixin', function () {
let map, datalayer
before(async () => {
await fetchMock.mock(
/\/datalayer\/62\/\?.*/,
JSON.stringify(RESPONSES.datalayer62_GET)
)
this.options = {
umap_id: 99,
}
MAP = map = initMap({ umap_id: 99 })
const datalayer_options = defaultDatalayerData()
await map.initDataLayers([datalayer_options])
datalayer = map.getDataLayerByUmapId(62)
})
after(function () {
fetchMock.restore()
resetMap()
})
describe('#utils()', function () {
var poly, marker
function setFeatures(datalayer) {
datalayer.eachLayer(function (layer) {
if (!poly && layer instanceof L.Polygon) {
poly = layer
}
if (!marker && layer instanceof L.Marker) {
marker = layer
}
})
}
it('should generate a valid geojson', function () {
setFeatures(datalayer)
assert.ok(poly)
assert.deepEqual(poly.toGeoJSON().geometry, {
type: 'Polygon',
coordinates: [
[
[11.25, 53.585984],
[10.151367, 52.975108],
[12.689209, 52.167194],
[14.084473, 53.199452],
[12.634277, 53.618579],
[11.25, 53.585984],
[11.25, 53.585984],
],
],
})
// Ensure original latlngs has not been modified
assert.equal(poly.getLatLngs()[0].length, 6)
})
it('should remove empty _umap_options from exported geojson', function () {
setFeatures(datalayer)
assert.ok(poly)
assert.deepEqual(poly.toGeoJSON().properties, { name: 'name poly' })
assert.ok(marker)
assert.deepEqual(marker.toGeoJSON().properties, {
_umap_options: { color: 'OliveDrab' },
name: 'test',
})
})
})
describe('#properties()', function () {
it('should rename property', function () {
var poly = datalayer._lineToLayer({}, [
[0, 0],
[0, 1],
[0, 2],
])
poly.properties.prop1 = 'xxx'
poly.renameProperty('prop1', 'prop2')
assert.equal(poly.properties.prop2, 'xxx')
assert.ok(typeof poly.properties.prop1 === 'undefined')
})
it('should not create property when renaming', function () {
var poly = datalayer._lineToLayer({}, [
[0, 0],
[0, 1],
[0, 2],
])
delete poly.properties.prop2 // Make sure it doesn't exist
poly.renameProperty('prop1', 'prop2')
assert.ok(typeof poly.properties.prop2 === 'undefined')
})
it('should delete property', function () {
var poly = datalayer._lineToLayer({}, [
[0, 0],
[0, 1],
[0, 2],
])
poly.properties.prop = 'xxx'
assert.equal(poly.properties.prop, 'xxx')
poly.deleteProperty('prop')
assert.ok(typeof poly.properties.prop === 'undefined')
})
})
describe('#matchFilter()', function () {
var poly
it('should filter on properties', function () {
poly = datalayer._lineToLayer({}, [
[0, 0],
[0, 1],
[0, 2],
])
poly.properties.name = 'mooring'
assert.ok(poly.matchFilter('moo', ['name']))
assert.notOk(poly.matchFilter('foo', ['name']))
})
it('should be case unsensitive', function () {
assert.ok(poly.matchFilter('Moo', ['name']))
})
it('should match also in the middle of a string', function () {
assert.ok(poly.matchFilter('oor', ['name']))
})
it('should handle multiproperties', function () {
poly.properties.city = 'Teulada'
assert.ok(poly.matchFilter('eul', ['name', 'city', 'foo']))
})
})
})

View file

@ -1,37 +0,0 @@
describe('U.Map', () => {
let map, datalayer
before(async () => {
await fetchMock.mock(
/\/datalayer\/62\/\?.*/,
JSON.stringify(RESPONSES.datalayer62_GET)
)
this.options = {
umap_id: 99,
}
map = initMap({ umap_id: 99 })
const datalayer_options = defaultDatalayerData()
await map.initDataLayers([datalayer_options])
datalayer = map.getDataLayerByUmapId(62)
})
after(() => {
fetchMock.restore()
clickCancel()
resetMap()
})
describe('#localizeUrl()', function () {
it('should replace known variables', function () {
assert.equal(
map.localizeUrl('http://example.org/{zoom}'),
'http://example.org/' + map.getZoom()
)
})
it('should keep unknown variables', function () {
assert.equal(
map.localizeUrl('http://example.org/{unkown}'),
'http://example.org/{unkown}'
)
})
})
})

View file

@ -1,126 +0,0 @@
describe('U.Marker', () => {
let map, datalayer
before(async () => {
const datalayer_response = JSON.parse(JSON.stringify(RESPONSES.datalayer62_GET)) // Copy.
datalayer_response._umap_options.iconClass = 'Drop'
await fetchMock.mock(/\/datalayer\/62\/\?.*/, datalayer_response)
this.options = {
umap_id: 99,
}
MAP = map = initMap({ umap_id: 99 })
const datalayer_options = defaultDatalayerData()
await map.initDataLayers([datalayer_options])
datalayer = map.getDataLayerByUmapId(62)
})
after(() => {
fetchMock.restore()
resetMap()
})
describe('#iconClassChange()', () => {
it('should change icon class', () => {
enableEdit()
happen.click(qs('div.umap-drop-icon'))
happen.click(qs('ul.leaflet-inplace-toolbar a.umap-toggle-edit'))
changeSelectValue(
qs(
'form#umap-feature-shape-properties .umap-field-iconClass select[name=iconClass]'
),
'Circle'
)
assert.notOk(qs('div.umap-drop-icon'))
assert.ok(qs('div.umap-circle-icon'))
happen.click(
qs('form#umap-feature-shape-properties .umap-field-iconClass .undefine')
)
assert.notOk(qs('div.umap-circle-icon'))
assert.ok(qs('div.umap-drop-icon'))
clickCancel()
})
})
describe('#iconSymbolChange()', () => {
it('should change icon symbol', () => {
enableEdit()
happen.click(qs('div.umap-drop-icon'))
happen.click(qs('ul.leaflet-inplace-toolbar a.umap-toggle-edit'))
changeInputValue(
qs(
'form#umap-feature-shape-properties .umap-field-iconUrl input[name=iconUrl]'
),
'1'
)
assert.equal(qs('div.umap-drop-icon span').textContent, '1')
changeInputValue(
qs(
'form#umap-feature-shape-properties .umap-field-iconUrl input[name=iconUrl]'
),
'{name}'
)
assert.equal(qs('div.umap-drop-icon span').textContent, 'test')
clickCancel()
})
})
describe('#iconClassChange()', () => {
it('should change icon class', () => {
enableEdit()
happen.click(qs('div.umap-drop-icon'))
happen.click(qs('ul.leaflet-inplace-toolbar a.umap-toggle-edit'))
changeSelectValue(
qs(
'form#umap-feature-shape-properties .umap-field-iconClass select[name=iconClass]'
),
'Circle'
)
assert.notOk(qs('div.umap-drop-icon'))
assert.ok(qs('div.umap-circle-icon'))
happen.click(
qs('form#umap-feature-shape-properties .umap-field-iconClass .undefine')
)
assert.notOk(qs('div.umap-circle-icon'))
assert.ok(qs('div.umap-drop-icon'))
clickCancel()
})
})
describe('#clone', () => {
it('should clone marker', () => {
var layer = new U.Marker(map, [10, 20], {
datalayer: datalayer,
}).addTo(datalayer)
assert.equal(datalayer._index.length, 4)
other = layer.clone()
assert.ok(map.hasLayer(other))
assert.equal(datalayer._index.length, 5)
// Must not be the same reference
assert.notEqual(layer._latlng, other._latlng)
assert.equal(L.Util.formatNum(layer._latlng.lat), other._latlng.lat)
assert.equal(L.Util.formatNum(layer._latlng.lng), other._latlng.lng)
})
})
describe('#edit()', function (done) {
it('should allow changing coordinates manually', () => {
var layer = new U.Marker(map, [10, 20], {
datalayer: datalayer,
}).addTo(datalayer)
enableEdit()
layer.edit()
changeInputValue(qs('form.umap-form input[name="lat"]'), '54.43')
assert.equal(layer._latlng.lat, 54.43)
})
it('should not allow invalid latitude nor longitude', () => {
var layer = new U.Marker(map, [10, 20], {
datalayer: datalayer,
}).addTo(datalayer)
enableEdit()
layer.edit()
changeInputValue(qs('form.umap-form input[name="lat"]'), '5443')
assert.equal(layer._latlng.lat, 10)
changeInputValue(qs('form.umap-form input[name="lng"]'), '5443')
assert.equal(layer._latlng.lng, 20)
})
})
})

View file

@ -1,111 +0,0 @@
describe('U.Polygon', function () {
var p2ll, map, datalayer
before(function () {
map = initMap({ umap_id: 99 })
enableEdit()
p2ll = function (x, y) {
return map.containerPointToLatLng([x, y])
}
datalayer = map.createDataLayer()
datalayer.connectToMap()
})
after(function () {
clickCancel()
resetMap()
})
afterEach(function () {
datalayer.empty()
})
describe('#isMulti()', function () {
it('should return false for basic Polygon', function () {
var layer = new U.Polygon(
map,
[
[1, 2],
[3, 4],
[5, 6],
],
{ datalayer: datalayer }
)
assert.notOk(layer.isMulti())
})
it('should return false for nested basic Polygon', function () {
var latlngs = [[[p2ll(100, 150), p2ll(150, 200), p2ll(200, 100)]]],
layer = new U.Polygon(map, latlngs, { datalayer: datalayer })
assert.notOk(layer.isMulti())
})
it('should return false for simple Polygon with hole', function () {
var layer = new U.Polygon(
map,
[
[
[1, 2],
[3, 4],
[5, 6],
],
[
[7, 8],
[9, 10],
[11, 12],
],
],
{ datalayer: datalayer }
)
assert.notOk(layer.isMulti())
})
it('should return true for multi Polygon', function () {
var latLngs = [
[
[
[1, 2],
[3, 4],
[5, 6],
],
],
[
[
[7, 8],
[9, 10],
[11, 12],
],
],
]
var layer = new U.Polygon(map, latLngs, { datalayer: datalayer })
assert.ok(layer.isMulti())
})
it('should return true for multi Polygon with hole', function () {
var latLngs = [
[
[
[10, 20],
[30, 40],
[50, 60],
],
],
[
[
[0, 10],
[10, 10],
[10, 0],
],
[
[2, 3],
[2, 4],
[3, 4],
],
],
]
var layer = new U.Polygon(map, latLngs, { datalayer: datalayer })
assert.ok(layer.isMulti())
})
})
})

View file

@ -1,286 +0,0 @@
describe('U.Polyline', function () {
var p2ll, map
before(function () {
this.map = map = initMap({ umap_id: 99 })
enableEdit()
p2ll = function (x, y) {
return map.containerPointToLatLng([x, y])
}
this.datalayer = this.map.createDataLayer()
this.datalayer.connectToMap()
})
after(function () {
clickCancel()
resetMap()
})
afterEach(function () {
this.datalayer.empty()
})
describe('#isMulti()', function () {
it('should return false for basic Polyline', function () {
var layer = new U.Polyline(
this.map,
[
[1, 2],
[3, 4],
[5, 6],
],
{ datalayer: this.datalayer }
)
assert.notOk(layer.isMulti())
})
it('should return false for nested basic Polyline', function () {
var layer = new U.Polyline(
this.map,
[
[
[1, 2],
[3, 4],
[5, 6],
],
],
{ datalayer: this.datalayer }
)
assert.notOk(layer.isMulti())
})
it('should return true for multi Polyline', function () {
var latLngs = [
[
[
[1, 2],
[3, 4],
[5, 6],
],
],
[
[
[7, 8],
[9, 10],
[11, 12],
],
],
]
var layer = new U.Polyline(this.map, latLngs, { datalayer: this.datalayer })
assert.ok(layer.isMulti())
})
})
describe('#contextmenu', function () {
afterEach(function () {
// Make sure contextmenu is hidden.
happen.once(document, { type: 'keydown', keyCode: 27 })
})
describe('#in edit mode', function () {
it('should allow to remove shape when multi', function () {
var latlngs = [
[p2ll(100, 100), p2ll(100, 200)],
[p2ll(300, 350), p2ll(350, 400), p2ll(400, 300)],
],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
happen.once(layer._path, { type: 'contextmenu' })
assert.equal(qst('Remove shape from the multi'), 1)
})
it('should not allow to remove shape when not multi', function () {
var latlngs = [[p2ll(100, 100), p2ll(100, 200)]],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
happen.once(layer._path, { type: 'contextmenu' })
assert.notOk(qst('Remove shape from the multi'))
})
it('should not allow to isolate shape when not multi', function () {
var latlngs = [[p2ll(100, 100), p2ll(100, 200)]],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
happen.once(layer._path, { type: 'contextmenu' })
assert.notOk(qst('Extract shape to separate feature'))
})
it('should allow to isolate shape when multi', function () {
var latlngs = [
[p2ll(100, 150), p2ll(100, 200)],
[p2ll(300, 350), p2ll(350, 400), p2ll(400, 300)],
],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
happen.once(layer._path, { type: 'contextmenu' })
assert.ok(qst('Extract shape to separate feature'))
})
it('should not allow to transform to polygon when multi', function () {
var latlngs = [
[p2ll(100, 150), p2ll(100, 200)],
[p2ll(300, 350), p2ll(350, 400), p2ll(400, 300)],
],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
happen.once(layer._path, { type: 'contextmenu' })
assert.notOk(qst('Transform to polygon'))
})
it('should allow to transform to polygon when not multi', function () {
var latlngs = [p2ll(100, 150), p2ll(100, 200), p2ll(200, 100)],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
happen.once(layer._path, { type: 'contextmenu' })
assert.equal(qst('Transform to polygon'), 1)
})
it('should not allow to transfer shape when not editedFeature', function () {
var layer = new U.Polyline(this.map, [p2ll(100, 150), p2ll(100, 200)], {
datalayer: this.datalayer,
}).addTo(this.datalayer)
happen.once(layer._path, { type: 'contextmenu' })
assert.notOk(qst('Transfer shape to edited feature'))
})
it('should not allow to transfer shape when editedFeature is not a line', function () {
var layer = new U.Polyline(this.map, [p2ll(100, 150), p2ll(100, 200)], {
datalayer: this.datalayer,
}).addTo(this.datalayer),
other = new U.Polygon(
this.map,
[p2ll(200, 300), p2ll(300, 200), p2ll(200, 100)],
{ datalayer: this.datalayer }
).addTo(this.datalayer)
other.edit()
happen.once(layer._path, { type: 'contextmenu' })
assert.notOk(qst('Transfer shape to edited feature'))
})
it('should allow to transfer shape when another line is edited', function () {
var layer = new U.Polyline(
this.map,
[p2ll(100, 150), p2ll(100, 200), p2ll(200, 100)],
{ datalayer: this.datalayer }
).addTo(this.datalayer),
other = new U.Polyline(this.map, [p2ll(200, 300), p2ll(300, 200)], {
datalayer: this.datalayer,
}).addTo(this.datalayer)
other.edit()
happen.once(layer._path, { type: 'contextmenu' })
assert.equal(qst('Transfer shape to edited feature'), 1)
other.remove()
layer.remove()
})
it('should allow to merge lines when multi', function () {
var latlngs = [
[p2ll(100, 100), p2ll(100, 200)],
[p2ll(300, 350), p2ll(350, 400), p2ll(400, 300)],
],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
happen.once(layer._path, { type: 'contextmenu' })
assert.equal(qst('Merge lines'), 1)
})
it('should not allow to merge lines when not multi', function () {
var latlngs = [[p2ll(100, 100), p2ll(100, 200)]],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
happen.once(layer._path, { type: 'contextmenu' })
assert.notOk(qst('Merge lines'))
})
it('should allow to split lines when clicking on vertex', function () {
var latlngs = [[p2ll(300, 350), p2ll(350, 400), p2ll(400, 300)]],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
layer.enableEdit()
happen.at('contextmenu', 350, 400)
assert.equal(qst('Split line'), 1)
})
it('should not allow to split lines when clicking on first vertex', function () {
var latlngs = [[p2ll(300, 350), p2ll(350, 400), p2ll(400, 300)]],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
layer.enableEdit()
happen.at('contextmenu', 300, 350)
assert.equal(qst('Delete this feature'), 1) // Make sure we have clicked on the vertex.
assert.notOk(qst('Split line'))
})
it('should not allow to split lines when clicking on last vertex', function () {
var latlngs = [[p2ll(300, 350), p2ll(350, 400), p2ll(400, 300)]],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
layer.enableEdit()
happen.at('contextmenu', 400, 300)
assert.equal(qst('Delete this feature'), 1) // Make sure we have clicked on the vertex.
assert.notOk(qst('Split line'))
})
})
})
describe('#mergeShapes', function () {
it('should remove duplicated join point when merging', function () {
var latlngs = [
[
[0, 0],
[0, 1],
],
[
[0, 1],
[0, 2],
],
],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
layer.mergeShapes()
layer.disableEdit() // Remove vertex from latlngs to compare them.
assert.deepEqual(layer.getLatLngs(), [
L.latLng([0, 0]),
L.latLng([0, 1]),
L.latLng([0, 2]),
])
assert(this.map.isDirty)
})
it('should revert candidate if first point is closer', function () {
var latlngs = [
[
[0, 0],
[0, 1],
],
[
[0, 2],
[0, 1],
],
],
layer = new U.Polyline(this.map, latlngs, {
datalayer: this.datalayer,
}).addTo(this.datalayer)
layer.mergeShapes()
layer.disableEdit()
assert.deepEqual(layer.getLatLngs(), [
L.latLng([0, 0]),
L.latLng([0, 1]),
L.latLng([0, 2]),
])
})
})
})

View file

@ -1,28 +0,0 @@
describe('L.Util', function () {
describe('#TextColorFromBackgroundColor', function () {
it('should output white for black', function () {
document.body.style.backgroundColor = 'black'
assert.equal(L.DomUtil.TextColorFromBackgroundColor(document.body), '#ffffff')
})
it('should output white for brown', function () {
document.body.style.backgroundColor = 'brown'
assert.equal(L.DomUtil.TextColorFromBackgroundColor(document.body), '#ffffff')
})
it('should output black for white', function () {
document.body.style.backgroundColor = 'white'
assert.equal(L.DomUtil.TextColorFromBackgroundColor(document.body), '#000000')
})
it('should output black for tan', function () {
document.body.style.backgroundColor = 'tan'
assert.equal(L.DomUtil.TextColorFromBackgroundColor(document.body), '#000000')
})
it('should output black by default', function () {
document.body.style.backgroundColor = 'transparent'
assert.equal(L.DomUtil.TextColorFromBackgroundColor(document.body), '#000000')
})
})
})

View file

@ -1,455 +0,0 @@
window.assert = chai.assert
window.expect = chai.expect
var qs = function (selector, element) {
return (element || document).querySelector(selector)
}
var qsa = function (selector) {
return document.querySelectorAll(selector)
}
var qst = function (text, parent) {
// find element by its text content
var r = document.evaluate(
"descendant::*[contains(text(),'" + text + "')]",
parent || qs('#map'),
null,
XPathResult.UNORDERED_NODE_ITERATOR_TYPE,
null
),
count = 0
while (r.iterateNext()) console.log(++count)
return count
}
happen.at = function (what, x, y, props) {
this.once(
document.elementFromPoint(x, y),
L.Util.extend(
{
type: what,
clientX: x,
clientY: y,
screenX: x,
screenY: y,
which: 1,
button: 0,
},
props || {}
)
)
}
var resetMap = function () {
var mapElement = qs('#map')
mapElement.innerHTML = 'Done'
delete mapElement._leaflet_id
document.body.className = ''
}
var enableEdit = function () {
happen.click(qs('div.leaflet-control-edit-enable button'))
}
var disableEdit = function () {
happen.click(qs('.leaflet-control-edit-disable'))
}
var clickSave = function () {
happen.click(qs('.leaflet-control-edit-save'))
}
var clickCancel = function () {
var _confirm = window.confirm
window.confirm = function (text) {
return true
}
happen.click(qs('button.leaflet-control-edit-cancel'))
happen.once(document.body, { type: 'keypress', keyCode: 13 })
window.confirm = _confirm
}
var changeInputValue = function (input, value) {
input.value = value
happen.once(input, { type: 'input' })
happen.once(input, { type: 'blur' })
}
var changeSelectValue = function (path_or_select, value) {
if (typeof path_or_select === 'string') path_or_select = qs(path_or_select)
var found = false
for (var i = 0; i < path_or_select.length; i++) {
if (path_or_select.options[i].value === value) {
path_or_select.options[i].selected = true
found = true
}
}
happen.once(path_or_select, { type: 'change' })
if (!found)
throw new Error('Value ' + value + 'not found in select ' + path_or_select)
return path_or_select
}
var cleanAlert = function () {
L.DomUtil.removeClass(qs('#map'), 'umap-alert')
L.DomUtil.get('umap-alert-container').innerHTML = ''
UI_ALERT_ID = null // Prevent setTimeout to be called
}
var defaultDatalayerData = function (custom) {
var _default = {
iconClass: 'Default',
name: 'Elephants',
displayOnLoad: true,
id: 62,
pictogram_url: null,
weight: null,
fillColor: '',
color: '',
stroke: true,
smoothFactor: null,
dashArray: '',
fill: true,
}
return L.extend({}, _default, custom)
}
function initMap(options) {
default_options = {
type: 'Feature',
properties: {
umap_id: 42,
datalayers: [],
urls: {
map: '/map/{slug}_{pk}',
datalayer_view: '/datalayer/{pk}/',
map_update: '/map/{map_id}/update/settings/',
map_old_url: '/map/{username}/{slug}/',
map_clone: '/map/{map_id}/update/clone/',
map_short_url: '/m/{pk}/',
map_anonymous_edit_url: '/map/anonymous-edit/{signature}',
map_new: '/map/new/',
datalayer_update: '/map/{map_id}/datalayer/update/{pk}/',
map_delete: '/map/{map_id}/update/delete/',
map_create: '/map/create/',
logout: '/logout/',
datalayer_create: '/map/{map_id}/datalayer/create/',
login_popup_end: '/login/popupd/',
login: '/login/',
datalayer_delete: '/map/{map_id}/datalayer/delete/{pk}/',
datalayer_versions: '/map/{map_id}/datalayer/{pk}/versions/',
datalayer_version: '/datalayer/{pk}/{name}',
pictogram_list_json: '/pictogram/json/',
map_update_permissions: '/map/{map_id}/update/permissions/',
map_download: '/map/{map_id}/download/',
},
default_iconUrl: '../src/img/marker.svg',
zoom: 6,
share_statuses: [
[1, 'Tout le monde (public)'],
[2, 'Quiconque a le lien'],
[3, 'Éditeurs uniquement'],
],
tilelayers: [
{
attribution: '\u00a9 OSM Contributors',
name: 'OpenStreetMap',
url_template: 'http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png',
minZoom: 0,
maxZoom: 18,
id: 1,
selected: true,
},
{
attribution: 'HOT and friends',
name: 'HOT OSM-fr server',
url_template: 'http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png',
rank: 99,
minZoom: 0,
maxZoom: 20,
id: 2,
},
],
tilelayer: {
attribution: 'HOT and friends',
name: 'HOT OSM-fr server',
url_template: 'http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png',
rank: 99,
minZoom: 0,
maxZoom: 20,
id: 2,
},
licences: {
'No licence set': {
url: '',
name: 'No licence set',
},
'Licence ouverte/Open Licence': {
url: 'http://www.data.gouv.fr/Licence-Ouverte-Open-Licence',
name: 'Licence ouverte/Open Licence',
},
'WTFPL': {
url: 'http://www.wtfpl.net/',
name: 'WTFPL',
},
'ODbl': {
url: 'http://opendatacommons.org/licenses/odbl/',
name: 'ODbl',
},
},
name: 'name of the map',
description: 'The description of the map',
locale: 'en',
editMode: 'advanced',
moreControl: true,
scaleControl: true,
miniMap: false,
datalayersControl: true,
displayCaptionOnLoad: false,
displayPopupFooter: false,
displayDataBrowserOnLoad: false,
permissions: {
share_status: 1,
owner: {
id: 1,
name: 'ybon',
url: '/en/user/ybon/',
},
editors: [],
},
user: {
id: 1,
name: 'foofoo',
url: '/en/me',
},
},
}
options = options || {}
options.properties = L.extend({}, default_options.properties, options)
options.geometry = {
type: 'Point',
coordinates: [5.0592041015625, 52.05924589011585],
}
return new U.Map('map', options)
}
var RESPONSES = {
datalayer62_GET: {
crs: null,
type: 'FeatureCollection',
_umap_options: defaultDatalayerData(),
features: [
{
geometry: {
type: 'Point',
coordinates: [-0.274658203125, 52.57634993749885],
},
type: 'Feature',
id: 1807,
properties: { _umap_options: { color: 'OliveDrab' }, name: 'test' },
},
{
geometry: {
type: 'LineString',
coordinates: [
[-0.5712890625, 54.47642158429295],
[0.439453125, 54.610254981579146],
[1.724853515625, 53.44880683542759],
[4.163818359375, 53.98839506479995],
[5.306396484375, 53.533778184257805],
[6.591796875, 53.70971358510174],
[7.042236328124999, 53.35055131839989],
],
},
type: 'Feature',
id: 20,
properties: { _umap_options: { fill: false, opacity: 0.6 }, name: 'test' },
},
{
geometry: {
type: 'Polygon',
coordinates: [
[
[11.25, 53.585983654559804],
[10.1513671875, 52.9751081817353],
[12.689208984375, 52.16719363541221],
[14.084472656249998, 53.199451902831555],
[12.63427734375, 53.61857936489517],
[11.25, 53.585983654559804],
[11.25, 53.585983654559804],
],
],
},
type: 'Feature',
id: 76,
properties: { name: 'name poly' },
},
],
},
// This one is non browsable
datalayer63_GET: {
crs: null,
type: 'FeatureCollection',
_umap_options: defaultDatalayerData({ id: 63, browsable: false }),
features: [
{
geometry: {
type: 'Polygon',
coordinates: [
[
[5.545478, 45.068383],
[5.545907, 45.067277],
[5.548439, 45.067565],
[5.552516, 45.06752],
[5.553288, 45.068217],
[5.549405, 45.069247],
[5.548224, 45.071005],
[5.545907, 45.071096],
[5.545478, 45.068383],
],
],
},
type: 'Feature',
id: 76,
properties: { name: 'non browsable 1' },
},
{
type: 'Feature',
properties: {
_umap_options: {
color: 'SteelBlue',
},
name: 'non browsable 2',
},
geometry: {
type: 'Polygon',
coordinates: [
[
[5.550542, 45.071717],
[5.548182, 45.071051],
[5.549426, 45.069232],
[5.553331, 45.068171],
[5.554812, 45.070869],
[5.553396, 45.072384],
[5.550542, 45.071717],
],
],
},
},
],
},
// This one is not shown at load
datalayer64_GET: {
crs: null,
type: 'FeatureCollection',
_umap_options: defaultDatalayerData({
name: 'hidden',
id: 64,
displayOnLoad: false,
}),
features: [
{
geometry: {
type: 'Polygon',
coordinates: [
[
[5.545478, 45.068383],
[5.545907, 45.067277],
[5.548439, 45.067565],
[5.552516, 45.06752],
[5.553288, 45.068217],
[5.549405, 45.069247],
[5.548224, 45.071005],
[5.545907, 45.071096],
[5.545478, 45.068383],
],
],
},
type: 'Feature',
id: 76,
properties: { name: 'not shown at load 1' },
},
{
type: 'Feature',
properties: {
_umap_options: {
color: 'AliceBlue',
},
name: 'not shown at load 2',
},
geometry: {
type: 'Polygon',
coordinates: [
[
[5.550542, 45.071717],
[5.548182, 45.071051],
[5.549426, 45.069232],
[5.553331, 45.068171],
[5.554812, 45.070869],
[5.553396, 45.072384],
[5.550542, 45.071717],
],
],
},
},
],
},
}
var kml_example =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<kml xmlns="http://www.opengis.net/kml/2.2">' +
'<Placemark>' +
'<name>Simple point</name>' +
'<description>Here is a simple description.</description>' +
'<Point>' +
'<coordinates>-122.0822035425683,37.42228990140251,0</coordinates>' +
'</Point>' +
'</Placemark>' +
'<Placemark>' +
'<name>Simple path</name>' +
'<description>Simple description</description>' +
'<LineString>' +
'<coordinates>-112.2550785337791,36.07954952145647,2357 -112.2549277039738,36.08117083492122,2357 -112.2552505069063,36.08260761307279,2357</coordinates>' +
'</LineString>' +
'</Placemark>' +
'<Placemark>' +
'<name>Simple polygon</name>' +
'<description>A description.</description>' +
'<Polygon>' +
'<outerBoundaryIs>' +
'<LinearRing>' +
'<coordinates>' +
' -77.05788457660967,38.87253259892824,100 ' +
' -77.05465973756702,38.87291016281703,100 ' +
' -77.05315536854791,38.87053267794386,100 ' +
' -77.05788457660967,38.87253259892824,100 ' +
'</coordinates>' +
'</LinearRing>' +
'</outerBoundaryIs>' +
'</Polygon>' +
'</Placemark>' +
'</kml>'
var gpx_example =
'<gpx' +
' version="1.1"' +
' creator="GPSBabel - http://www.gpsbabel.org"' +
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' +
' xmlns="http://www.topografix.com/GPX/1/1"' +
' xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">' +
' <wpt lat="45.44283" lon="-121.72904"><ele>1374</ele><name>Simple Point</name><desc>Simple description</desc></wpt>' +
' <trk>' +
' <name>Simple path</name>' +
' <desc>Simple description</desc>' +
' <trkseg>' +
' <trkpt lat="45.4431641" lon="-121.7295456"></trkpt>' +
' <trkpt lat="45.4428615" lon="-121.7290800"></trkpt>' +
' <trkpt lat="45.4425697" lon="-121.7279085"></trkpt>' +
' </trkseg>' +
' </trk>' +
'</gpx>'
var csv_example =
'Foo,Latitude,Longitude,title,description\n' +
'bar,41.34,122.86,a point somewhere,the description of this point'
// Make Sinon log readable
sinon.format = function (what) {
if (typeof what === 'object') {
return JSON.stringify(what, null, 4)
} else if (typeof what === 'undefined') {
return ''
} else {
return what.toString()
}
}

View file

@ -1,139 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Umap front Tests</title>
<meta charset="utf-8" />
<script type="module" src="../js/modules/leaflet-configure.js" defer></script>
<script type="module" src="../js/modules/global.js" defer></script>
<script src="../vendors/editable/Path.Drag.js" defer></script>
<script src="../vendors/editable/Leaflet.Editable.js" defer></script>
<script src="../vendors/hash/leaflet-hash.js" defer></script>
<script src="../vendors/editinosm/Leaflet.EditInOSM.js" defer></script>
<script src="../vendors/minimap/Control.MiniMap.min.js" defer></script>
<script src="../vendors/csv2geojson/csv2geojson.js" defer></script>
<script src="../vendors/togeojson/togeojson.umd.js" defer></script>
<script src="../vendors/osmtogeojson/osmtogeojson.js" defer></script>
<script src="../vendors/loading/Control.Loading.js" defer></script>
<script src="../vendors/markercluster/leaflet.markercluster.js" defer></script>
<script src="../vendors/contextmenu/leaflet.contextmenu.min.js" defer></script>
<script src="../vendors/photon/leaflet.photon.js" defer></script>
<script src="../vendors/georsstogeojson/GeoRSSToGeoJSON.js" defer></script>
<script src="../vendors/heat/leaflet-heat.js" defer></script>
<script src="../vendors/fullscreen/Leaflet.fullscreen.min.js" defer></script>
<script src="../vendors/toolbar/leaflet.toolbar.js" defer></script>
<script src="../vendors/formbuilder/Leaflet.FormBuilder.js" defer></script>
<script src="../vendors/measurable/Leaflet.Measurable.js" defer></script>
<script src="../vendors/togpx/togpx.js" defer></script>
<script src="../vendors/iconlayers/iconLayers.js" defer></script>
<script src="../vendors/tokml/tokml.js" defer></script>
<script src="../vendors/locatecontrol/L.Control.Locate.min.js" defer></script>
<script src="../vendors/colorbrewer/colorbrewer.js" defer></script>
<script src="../vendors/simple-statistics/simple-statistics.min.js" defer></script>
<script src="../js/umap.core.js" defer></script>
<script src="../js/umap.autocomplete.js" defer></script>
<script src="../js/umap.popup.js" defer></script>
<script src="../js/umap.forms.js" defer></script>
<script src="../js/umap.icon.js" defer></script>
<script src="../js/umap.features.js" defer></script>
<script src="../js/umap.permissions.js" defer></script>
<script src="../js/umap.datalayer.permissions.js" defer></script>
<script src="../js/umap.layer.js" defer></script>
<script src="../js/umap.controls.js" defer></script>
<script src="../js/umap.slideshow.js" defer></script>
<script src="../js/umap.tableeditor.js" defer></script>
<script src="../js/umap.importer.js" defer></script>
<script src="../js/umap.share.js" defer></script>
<script src="../js/umap.js" defer></script>
<script src="../js/umap.ui.js" defer></script>
<script src="../js/components/fragment.js" defer></script>
<link rel="stylesheet" href="../vendors/leaflet/leaflet.css" />
<link rel="stylesheet" href="../vendors/markercluster/MarkerCluster.css" />
<link rel="stylesheet" href="../vendors/markercluster/MarkerCluster.Default.css" />
<link rel="stylesheet" href="../vendors/editinosm/Leaflet.EditInOSM.css" />
<link rel="stylesheet" href="../vendors/minimap/Control.MiniMap.min.css" />
<link rel="stylesheet" href="../vendors/contextmenu/leaflet.contextmenu.min.css" />
<link rel="stylesheet" href="../vendors/toolbar/leaflet.toolbar.css" />
<link rel="stylesheet" href="../vendors/measurable/Leaflet.Measurable.css" />
<link rel="stylesheet" href="../vendors/fullscreen/leaflet.fullscreen.css" />
<link rel="stylesheet" href="../vendors/locatecontrol/L.Control.Locate.min.css" />
<link rel="stylesheet" href="../vendors/iconlayers/iconLayers.css" />
<link rel="stylesheet" href="../../umap/vars.css" />
<link rel="stylesheet" href="../../umap/font.css" />
<link rel="stylesheet" href="../../umap/base.css" />
<link rel="stylesheet" href="../../umap/content.css" />
<link rel="stylesheet" href="../../umap/nav.css" />
<link rel="stylesheet" href="../../umap/map.css" />
<link rel="stylesheet" href="../../umap/theme.css" />
<script src="../../../../node_modules/sinon/pkg/sinon.js"></script>
<script src="../../../../node_modules/mocha/mocha.js"></script>
<script src="../../../../node_modules/chai/chai.js"></script>
<script src="../../../../node_modules/happen/happen.js"></script>
<link rel="stylesheet" href="../../../../node_modules/mocha/mocha.css" />
<script type="module">
import fetchMock from '../../../../node_modules/fetch-mock/esm/client.js';
window.fetchMock = fetchMock
</script>
<script type="text/javascript">
mocha.setup({
ui: 'bdd',
bail: window.location.search.indexOf('failfast') !== -1,
ignoreLeaks: true,
})
chai.config.includeStack = true
</script>
<script src="./_pre.js" defer></script>
<script src="./Map.js" defer></script>
<script src="./Feature.js" defer></script>
<script src="./Marker.js" defer></script>
<script src="./Polyline.js" defer></script>
<script src="./Polygon.js" defer></script>
<script src="./Util.js" defer></script>
<script type="module" src="./URLs.js" defer></script>
<style type="text/css">
#mocha {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 10000;
background-color: white;
box-shadow: 0px 0px 8px 0px black;
overflow-y: auto;
display: none;
}
#mocha-stats {
position: absolute;
}
</style>
</head>
<body>
<div id="mocha"></div>
<div id="map"></div>
<script>
window.addEventListener('DOMContentLoaded', () => {
var runner = (window.mochaPhantomJS || window.mocha).run(function (failures) {
if (window.location.search.indexOf('debug') === -1)
qs('#mocha').style.display = 'block'
console.log(failures)
})
if (window.location.search.indexOf('debug') !== -1) {
runner.on('fail', function (test, err) {
console.log(test.title, test.err)
console.log(test.err.expected, test.err.actual)
console.log(test.err.stack)
})
sinon.log = function (message) {
console.log(message)
}
}
})
</script>
</body>
</html>

View file

@ -49,63 +49,6 @@ describe('#dispatch', () => {
}) })
}) })
describe('Updaters', () => {
describe('BaseUpdater', () => {
let updater
let map
let obj
beforeEach(() => {
map = {}
updater = new MapUpdater(map)
obj = {}
})
it('should be able to set object properties', () => {
let obj = {}
updater.updateObjectValue(obj, 'foo', 'foo')
expect(obj).deep.equal({ foo: 'foo' })
})
it('should be able to set object properties recursively on existing objects', () => {
let obj = { foo: {} }
updater.updateObjectValue(obj, 'foo.bar', 'foo')
expect(obj).deep.equal({ foo: { bar: 'foo' } })
})
it('should be able to set object properties recursively on deep objects', () => {
let obj = { foo: { bar: { baz: {} } } }
updater.updateObjectValue(obj, 'foo.bar.baz.test', 'value')
expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } })
})
it('should be able to replace object properties recursively on deep objects', () => {
let obj = { foo: { bar: { baz: { test: 'test' } } } }
updater.updateObjectValue(obj, 'foo.bar.baz.test', 'value')
expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } })
})
it('should not set object properties recursively on non-existing objects', () => {
let obj = { foo: {} }
updater.updateObjectValue(obj, 'bar.bar', 'value')
expect(obj).deep.equal({ foo: {} })
})
it('should delete keys for undefined values', () => {
let obj = { foo: 'foo' }
updater.updateObjectValue(obj, 'foo', undefined)
expect(obj).deep.equal({})
})
it('should delete keys for undefined values, recursively', () => {
let obj = { foo: { bar: 'bar' } }
updater.updateObjectValue(obj, 'foo.bar', undefined)
expect(obj).deep.equal({ foo: {} })
})
})
})
describe('Operations', () => { describe('Operations', () => {
describe('haveSameContext', () => { describe('haveSameContext', () => {

View file

@ -862,4 +862,51 @@ describe('Utils', () => {
assert.equal(Utils.isObject(''), false) assert.equal(Utils.isObject(''), false)
}) })
}) })
describe('setObjectValue', () => {
it('should be able to set object properties', () => {
let obj = {}
Utils.setObjectValue(obj, 'foo', 'foo')
expect(obj).deep.equal({ foo: 'foo' })
})
it('should be able to set object properties recursively on existing objects', () => {
let obj = { foo: {} }
Utils.setObjectValue(obj, 'foo.bar', 'foo')
expect(obj).deep.equal({ foo: { bar: 'foo' } })
})
it('should be able to set object properties recursively on deep objects', () => {
let obj = { foo: { bar: { baz: {} } } }
Utils.setObjectValue(obj, 'foo.bar.baz.test', 'value')
expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } })
})
it('should be able to replace object properties recursively on deep objects', () => {
let obj = { foo: { bar: { baz: { test: 'test' } } } }
Utils.setObjectValue(obj, 'foo.bar.baz.test', 'value')
expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } })
})
it('should not set object properties recursively on non-existing objects', () => {
let obj = { foo: {} }
Utils.setObjectValue(obj, 'bar.bar', 'value')
expect(obj).deep.equal({ foo: {} })
})
it('should delete keys for undefined values', () => {
let obj = { foo: 'foo' }
Utils.setObjectValue(obj, 'foo', undefined)
expect(obj).deep.equal({})
})
it('should delete keys for undefined values, recursively', () => {
let obj = { foo: { bar: 'bar' } }
Utils.setObjectValue(obj, 'foo.bar', undefined)
expect(obj).deep.equal({ foo: {} })
})
})
}) })

View file

@ -1,4 +1,4 @@
from typing import Literal, Optional, Union from typing import List, Literal, Optional, Union
from pydantic import BaseModel, Field, RootModel from pydantic import BaseModel, Field, RootModel
@ -14,10 +14,11 @@ class OperationMessage(BaseModel):
"""Message sent from one peer to all the others""" """Message sent from one peer to all the others"""
kind: Literal["OperationMessage"] = "OperationMessage" kind: Literal["OperationMessage"] = "OperationMessage"
verb: Literal["upsert", "update", "delete"] verb: Literal["upsert", "update", "delete", "batch"]
subject: Literal["map", "datalayer", "feature"] subject: Literal["map", "datalayer", "feature"]
metadata: Optional[dict] = None metadata: Optional[dict] = None
key: Optional[str] = None key: Optional[str] = None
operations: Optional[List] = None
class PeerMessage(BaseModel): class PeerMessage(BaseModel):

View file

@ -18,6 +18,7 @@ DATALAYER_DATA = {
"features": [ "features": [
{ {
"type": "Feature", "type": "Feature",
"id": "ExNTQ",
"geometry": { "geometry": {
"type": "Point", "type": "Point",
"coordinates": [14.68896484375, 48.55297816440071], "coordinates": [14.68896484375, 48.55297816440071],
@ -41,7 +42,7 @@ class LicenceFactory(factory.django.DjangoModelFactory):
class TileLayerFactory(factory.django.DjangoModelFactory): class TileLayerFactory(factory.django.DjangoModelFactory):
name = "Test zoom layer" name = "Test tilelayer"
url_template = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" url_template = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution = "Test layer attribution" attribution = "Test layer attribution"

View file

@ -350,8 +350,7 @@ def test_should_redraw_list_on_feature_delete(live_server, openmap, page, bootst
buttons.first.click() buttons.first.click()
page.locator("dialog").get_by_role("button", name="OK").click() page.locator("dialog").get_by_role("button", name="OK").click()
expect(buttons).to_have_count(2) expect(buttons).to_have_count(2)
page.get_by_role("button", name="Cancel edits").click() page.get_by_role("button", name="Undo").click()
page.locator("dialog").get_by_role("button", name="OK").click()
expect(buttons).to_have_count(3) expect(buttons).to_have_count(3)

View file

@ -261,6 +261,9 @@ def test_can_create_new_rule(live_server, page, openmap):
page.get_by_title("AliceBlue").first.click() page.get_by_title("AliceBlue").first.click()
colors = getColors(markers) colors = getColors(markers)
assert colors.count("rgb(240, 248, 255)") == 3 assert colors.count("rgb(240, 248, 255)") == 3
page.get_by_role("button", name="Undo").click()
colors = getColors(markers)
assert colors.count("rgb(240, 248, 255)") == 0
def test_can_deactive_rule_from_list(live_server, page, openmap): def test_can_deactive_rule_from_list(live_server, page, openmap):

View file

@ -64,8 +64,7 @@ def test_cancel_deleting_datalayer_should_restore(
page.get_by_role("button", name="OK").click() page.get_by_role("button", name="OK").click()
expect(markers).to_have_count(0) expect(markers).to_have_count(0)
expect(page.get_by_text("test datalayer")).to_be_hidden() expect(page.get_by_text("test datalayer")).to_be_hidden()
page.get_by_role("button", name="Cancel edits").click() page.get_by_role("button", name="Undo").click()
page.locator("dialog").get_by_role("button", name="OK").click()
expect(markers).to_have_count(1) expect(markers).to_have_count(1)
expect(page.locator(".umap-browser").get_by_text("test datalayer")).to_be_visible() expect(page.locator(".umap-browser").get_by_text("test datalayer")).to_be_visible()
@ -160,7 +159,6 @@ def test_can_create_new_datalayer(live_server, openmap, page, datalayer):
page.locator('input[name="name"]').click() page.locator('input[name="name"]').click()
page.locator('input[name="name"]').fill("Layer A with a new name") page.locator('input[name="name"]').fill("Layer A with a new name")
expect(page.get_by_text("Layer A with a new name")).to_be_visible() expect(page.get_by_text("Layer A with a new name")).to_be_visible()
page.get_by_role("button", name="Save").click()
with page.expect_response(re.compile(".*/datalayer/update/.*")): with page.expect_response(re.compile(".*/datalayer/update/.*")):
page.get_by_role("button", name="Save").click() page.get_by_role("button", name="Save").click()
assert DataLayer.objects.count() == 2 assert DataLayer.objects.count() == 2

View file

@ -117,8 +117,7 @@ def test_should_reset_style_on_cancel(live_server, openmap, page, bootstrap):
expect(page.locator(".leaflet-overlay-pane path[fill='GoldenRod']")).to_have_count( expect(page.locator(".leaflet-overlay-pane path[fill='GoldenRod']")).to_have_count(
1 1
) )
page.get_by_role("button", name="Cancel edits").click() page.get_by_role("button", name="Undo").click()
page.locator("dialog").get_by_role("button", name="OK").click()
expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1) expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1)

View file

@ -292,9 +292,10 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm
# Change name on page two and save # Change name on page two and save
page_two.locator(".leaflet-marker-icon").click(modifiers=["Shift"]) page_two.locator(".leaflet-marker-icon").click(modifiers=["Shift"])
page_two.locator('input[name="name"]').fill("name from page two") page_two.locator('input[name="name"]').fill("name from page two")
page_two.wait_for_timeout(300) # Time for the input debounce.
# Map should be in dirty status # Map should be in dirty status
expect(page_two.get_by_text("Cancel edits")).to_be_visible() expect(page_two.get_by_text("Save", exact=True)).to_be_enabled()
with page_two.expect_response(re.compile(r".*/datalayer/update/.*")): with page_two.expect_response(re.compile(r".*/datalayer/update/.*")):
page_two.get_by_role("button", name="Save").click() page_two.get_by_role("button", name="Save").click()
@ -306,7 +307,7 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm
# We should have an alert with some actions # We should have an alert with some actions
expect(page_two.get_by_text("Whoops! Other contributor(s) changed")).to_be_visible() expect(page_two.get_by_text("Whoops! Other contributor(s) changed")).to_be_visible()
# Map should still be in dirty status # Map should still be in dirty status
expect(page_two.get_by_text("Cancel edits")).to_be_visible() expect(page_two.get_by_text("Save", exact=True)).to_be_enabled()
# Override data from page two # Override data from page two
with page_two.expect_response(re.compile(r".*/datalayer/update/.*")): with page_two.expect_response(re.compile(r".*/datalayer/update/.*")):
@ -317,4 +318,4 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm
data = json.loads(Path(saved.geojson.path).read_text()) data = json.loads(Path(saved.geojson.path).read_text())
assert data["features"][0]["properties"]["name"] == "name from page two" assert data["features"][0]["properties"]["name"] == "name from page two"
# Map should not be in dirty status anymore # Map should not be in dirty status anymore
expect(page_two.get_by_text("Cancel edits")).to_be_hidden() expect(page_two.get_by_text("Save", exact=True)).to_be_disabled()

View file

@ -16,17 +16,15 @@ def test_reseting_map_would_remove_from_save_queue(
page.on("request", register_request) page.on("request", register_request)
page.locator('input[name="name"]').click() page.locator('input[name="name"]').click()
page.locator('input[name="name"]').fill("new name") page.locator('input[name="name"]').fill("new name")
page.get_by_role("button", name="Cancel edits").click() page.get_by_role("button", name="Undo").click()
page.get_by_role("button", name="OK").click()
page.wait_for_timeout(500) page.wait_for_timeout(500)
page.get_by_role("button", name="Edit").click()
page.get_by_role("button", name="Manage layers").click() page.get_by_role("button", name="Manage layers").click()
page.get_by_role("button", name="Edit", exact=True).click() page.get_by_role("button", name="Edit", exact=True).click()
page.locator('input[name="name"]').click() page.locator('input[name="name"]').click()
page.locator('input[name="name"]').fill("new datalayer name") page.locator('input[name="name"]').fill("new datalayer name")
page.wait_for_timeout(300) # Time of the Input debounce page.wait_for_timeout(300) # Time of the Input debounce
with page.expect_response(re.compile(".*/datalayer/update/.*")): with page.expect_response(re.compile(".*/datalayer/update/.*")):
page.get_by_role("button", name="Save").click() page.get_by_role("button", name="Save", exact=True).click()
assert len(requests) == 1 assert len(requests) == 1
assert requests == [ assert requests == [
( (

View file

@ -0,0 +1,267 @@
import re
from pathlib import Path
import pytest
from playwright.sync_api import expect
from umap.models import Map, TileLayer
from ..base import DataLayerFactory
pytestmark = pytest.mark.django_db
DATALAYER_DATA = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "name poly",
},
"id": "gyNzM",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[11.25, 53.585984],
[10.151367, 52.975108],
[12.689209, 52.167194],
[14.084473, 53.199452],
[12.634277, 53.618579],
[11.25, 53.585984],
[11.25, 53.585984],
],
],
},
},
],
}
@pytest.fixture
def map_with_polygon(map, live_server):
map.settings["properties"]["zoom"] = 6
map.settings["geometry"] = {
"type": "Point",
"coordinates": [8.429, 53.239],
}
map.edit_status = Map.ANONYMOUS
map.save()
DataLayerFactory(map=map, data=DATALAYER_DATA)
return map
def test_can_undo_redo_map_name_change(page, live_server, tilelayer):
page.goto(f"{live_server.url}/en/map/new/")
expect(page.locator(".edit-undo")).to_be_disabled()
expect(page.locator(".edit-redo")).to_be_disabled()
page.get_by_title("Edit map name and caption").click()
name_input = page.locator('.map-metadata input[name="name"]')
expect(name_input).to_be_visible()
name_input.click()
name_input.press("Control+a")
name_input.fill("New map name")
expect(page.locator(".edit-undo")).to_be_enabled()
expect(page.locator(".edit-redo")).to_be_disabled()
map_name = page.locator(".umap-main-edit-toolbox .map-name")
expect(map_name).to_have_text("New map name")
name_input.fill("New name again")
expect(map_name).to_have_text("New name again")
page.locator(".edit-undo").click()
expect(map_name).to_have_text("New map name")
expect(page.locator(".edit-undo")).to_be_enabled()
expect(page.locator(".edit-redo")).to_be_enabled()
page.locator(".edit-redo").click()
expect(map_name).to_have_text("New name again")
expect(page.locator(".edit-undo")).to_be_enabled()
expect(page.locator(".edit-redo")).to_be_disabled()
page.locator(".edit-undo").click()
expect(map_name).to_have_text("New map name")
expect(page.locator(".edit-undo")).to_be_enabled()
expect(page.locator(".edit-redo")).to_be_enabled()
def test_can_undo_redo_layer_color_change(
page, map_with_polygon, live_server, tilelayer
):
page.goto(f"{live_server.url}{map_with_polygon.get_absolute_url()}?edit")
expect(page.locator(".edit-undo")).to_be_disabled()
expect(page.locator(".edit-redo")).to_be_disabled()
page.get_by_role("button", name="Manage layers").click()
page.locator(".panel").get_by_title("Edit", exact=True).click()
page.get_by_text("Shape properties").click()
page.locator(".umap-field-color .define").click()
expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1)
page.get_by_title("DarkRed").first.click()
expect(page.locator(".leaflet-overlay-pane path[fill='DarkRed']")).to_have_count(1)
expect(page.locator(".edit-undo")).to_be_enabled()
expect(page.locator(".edit-redo")).to_be_disabled()
page.locator(".edit-undo").click()
expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1)
expect(page.locator(".leaflet-overlay-pane path[fill='DarkRed']")).to_have_count(0)
expect(page.locator(".edit-undo")).to_be_disabled()
expect(page.locator(".edit-redo")).to_be_enabled()
page.locator(".edit-redo").click()
expect(page.locator(".leaflet-overlay-pane path[fill='DarkRed']")).to_have_count(1)
expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(0)
expect(page.locator(".edit-undo")).to_be_enabled()
expect(page.locator(".edit-redo")).to_be_disabled()
def test_can_undo_redo_tilelayer_change(live_server, page, openmap, tilelayer):
TileLayer.objects.create(
url_template="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
attribution="OSM/Carto",
name="Black Tiles",
)
page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit")
old_pattern = re.compile(
r"https://[abc]{1}.tile.openstreetmap.fr/osmfr/\d+/\d+/\d+.png"
)
tiles = page.locator(".leaflet-tile-pane img")
expect(tiles.first).to_have_attribute("src", old_pattern)
new_pattern = re.compile(
r"https://[abcd]{1}.basemaps.cartocdn.com/dark_all/\d+/\d+/\d+.png"
)
page.get_by_role("button", name="Change tilelayers").click()
page.locator("li").filter(has_text="Black Tiles").get_by_role("img").click()
tiles = page.locator(".leaflet-tile-pane img")
expect(tiles.first).to_have_attribute("src", new_pattern)
page.locator(".edit-undo").click()
tiles = page.locator(".leaflet-tile-pane img")
expect(tiles.first).to_have_attribute("src", old_pattern)
page.locator(".edit-redo").click()
tiles = page.locator(".leaflet-tile-pane img")
expect(tiles.first).to_have_attribute("src", new_pattern)
def test_can_undo_redo_marker_drag(live_server, page, tilelayer):
page.goto(f"{live_server.url}/en/map/new")
marker = page.locator(".leaflet-marker-icon")
map = page.locator("#map")
# Create a marker
page.get_by_title("Draw a marker").click()
map.click(position={"x": 225, "y": 225})
expect(marker).to_have_count(1)
# Drag marker
old_bbox = marker.bounding_box()
marker.first.drag_to(map, target_position={"x": 250, "y": 250})
assert marker.bounding_box() != old_bbox
# Undo
page.locator(".edit-undo").click()
assert marker.bounding_box() == old_bbox
# Redo
page.locator(".edit-redo").click()
assert marker.bounding_box() != old_bbox
def test_can_undo_redo_polygon_geometry_change(live_server, page, tilelayer):
page.goto(f"{live_server.url}/en/map/new")
# Click on the Draw a polygon button on a new map.
page.get_by_title("Draw a polygon").click()
polygon = page.locator("path[fill='DarkBlue']")
expect(polygon).to_have_count(0)
# Click on the map, it will create a polygon.
map = page.locator("#map")
map.click(position={"x": 200, "y": 200})
map.click(position={"x": 100, "y": 200})
map.click(position={"x": 100, "y": 100})
map.click(position={"x": 100, "y": 100})
# It is created on peerA, and should be on peerB
expect(polygon).to_have_count(1)
old_bbox = polygon.bounding_box()
edited_vertex = page.locator(".leaflet-middle-icon:nth-child(3)").first
edited_vertex.drag_to(map, target_position={"x": 250, "y": 250})
page.keyboard.press("Escape")
assert polygon.bounding_box() != old_bbox
page.locator(".edit-undo").click()
assert polygon.bounding_box() == old_bbox
page.locator(".edit-redo").click()
assert polygon.bounding_box() != old_bbox
def test_can_undo_redo_marker_create(live_server, page, tilelayer):
page.goto(f"{live_server.url}/en/map/new")
page.get_by_title("Open Browser").click()
marker = page.locator(".leaflet-marker-icon")
map = page.locator("#map")
# Create a marker
page.get_by_title("Draw a marker").click()
map.click(position={"x": 600, "y": 100})
expect(marker).to_have_count(1)
expect(page.locator(".panel .datalayer")).to_have_count(1)
page.locator(".edit-undo").click()
expect(marker).to_have_count(0)
# Layer still exists
expect(page.locator(".panel .datalayer")).to_have_count(1)
page.locator(".edit-undo").click()
expect(page.locator(".panel .datalayer")).to_have_count(0)
page.locator(".edit-redo").click()
expect(page.locator(".panel .datalayer")).to_have_count(1)
page.locator(".edit-redo").click()
expect(marker).to_have_count(1)
def test_undo_redo_import(live_server, page, tilelayer):
page.goto(f"{live_server.url}/map/new/")
page.get_by_title("Open Browser").click()
page.get_by_title("Import data").click()
file_input = page.locator("input[type='file']")
with page.expect_file_chooser() as fc_info:
file_input.click()
file_chooser = fc_info.value
path = Path(__file__).parent.parent / "fixtures/test_upload_data.json"
file_chooser.set_files(path)
page.get_by_role("button", name="Import data", exact=True).click()
# Close the import panel
page.keyboard.press("Escape")
layers = page.locator(".umap-browser .datalayer")
expect(layers).to_have_count(1)
features_count = page.locator(".umap-browser .datalayer-counter")
expect(features_count).to_have_text("(5)")
page.locator(".edit-undo").click()
expect(features_count).to_be_hidden()
expect(layers).to_have_count(1)
page.locator(".edit-undo").click()
expect(layers).to_have_count(0)
page.locator(".edit-redo").click()
expect(layers).to_have_count(1)
page.locator(".edit-redo").click()
expect(features_count).to_have_text("(5)")

View file

@ -659,3 +659,73 @@ def test_should_sync_line_on_escape(new_page, asgi_live_server, tilelayer):
expect(peerA.locator("path")).to_have_count(1) expect(peerA.locator("path")).to_have_count(1)
expect(peerB.locator("path")).to_have_count(1) expect(peerB.locator("path")).to_have_count(1)
@pytest.mark.xdist_group(name="websockets")
def test_should_sync_datalayer_clear(
new_page, asgi_live_server, tilelayer, map, datalayer
):
map.settings["properties"]["syncEnabled"] = True
map.edit_status = Map.ANONYMOUS
map.save()
# Create two tabs
peerA = new_page("Page A")
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
peerB = new_page("Page B")
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
expect(peerA.locator(".leaflet-marker-icon")).to_have_count(1)
expect(peerB.locator(".leaflet-marker-icon")).to_have_count(1)
# Clear layer in peer A
peerA.get_by_role("button", name="Manage layers").click()
peerA.get_by_role("button", name="Edit", exact=True).click()
peerA.locator("summary").filter(has_text="Advanced actions").click()
peerA.get_by_role("button", name="Empty").click()
expect(peerA.locator(".leaflet-marker-icon")).to_have_count(0)
expect(peerB.locator(".leaflet-marker-icon")).to_have_count(0)
# Undo in peer A
peerA.get_by_role("button", name="Undo").click()
expect(peerA.locator(".leaflet-marker-icon")).to_have_count(1)
expect(peerB.locator(".leaflet-marker-icon")).to_have_count(1)
@pytest.mark.xdist_group(name="websockets")
def test_should_save_remote_dirty_datalayers(new_page, asgi_live_server, tilelayer):
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
map.settings["properties"]["syncEnabled"] = True
map.save()
assert not DataLayer.objects.count()
# Create two tabs
peerA = new_page("Page A")
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
peerB = new_page("Page B")
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
# Create a new layer from peerA
peerA.get_by_role("button", name="Manage layers").click()
peerA.get_by_role("button", name="Add a layer").click()
# Create a new layer from peerB
peerB.get_by_role("button", name="Manage layers").click()
peerB.get_by_role("button", name="Add a layer").click()
# Save from peerA to the server
counter = 0
def on_response(response):
nonlocal counter
if "/datalayer/create/" in response.url:
counter += 1
# Wait for the two datalayer saves
if counter == 2:
return True
return False
with peerA.expect_response(on_response):
peerA.get_by_role("button", name="Save").click()
assert DataLayer.objects.count() == 2

View file

@ -103,6 +103,7 @@ def test_get_version(map, datalayer):
], ],
"type": "Point", "type": "Point",
}, },
"id": "ExNTQ",
"properties": { "properties": {
"_umap_options": { "_umap_options": {
"color": "DarkCyan", "color": "DarkCyan",

View file

@ -694,6 +694,7 @@ def test_download(client, map, datalayer):
"coordinates": [14.68896484375, 48.55297816440071], "coordinates": [14.68896484375, 48.55297816440071],
"type": "Point", "type": "Point",
}, },
"id": "ExNTQ",
"properties": { "properties": {
"_umap_options": {"color": "DarkCyan", "iconClass": "Ball"}, "_umap_options": {"color": "DarkCyan", "iconClass": "Ball"},
"description": "Da place anonymous again 755", "description": "Da place anonymous again 755",