Compare commits

..

38 commits

Author SHA1 Message Date
Yohan Boniface
a16aaf21d8
Merge e24695b68c into e2f154f62e 2025-04-03 12:52:24 +00:00
Yohan Boniface
e24695b68c wip: display tags in maps list 2025-04-03 14:51:04 +02:00
Yohan Boniface
db72bfad8d wip: sort tags by label, not by value 2025-04-03 11:10:36 +02:00
Yohan Boniface
738af24dfc chore: fix tags related test 2025-04-03 11:10:36 +02:00
Yohan Boniface
06f963c07f chore: align search form elements
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-03 11:10:36 +02:00
Yohan Boniface
c6ed896a6d feat: associate icons to tags
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-03 11:10:36 +02:00
Yohan Boniface
1344fe6b46 feat: basic filtering by tags 2025-04-03 11:10:36 +02:00
Yohan Boniface
39f38a9cdf feat: add Map.tags and allow to edit from client
cf #2283
2025-04-03 11:10:33 +02:00
Yohan Boniface
e2f154f62e
feat: add atomic undo redo (#2570)
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
Todo:

- [ ] undo/sync a version restore
- [x] add test for scenario where two peers create a layer and one click
on save (both should be saved)
- [x] undo/sync of conditional rules
- [x] undo/sync of filter management from table editor
2025-04-03 10:32:47 +02:00
Yohan Boniface
7196ffe101
chore: bump mkdocs-material from 9.6.9 to 9.6.10 (#2593) 2025-04-03 08:55:19 +02:00
Yohan Boniface
99ea09e79e
chore: bump pydantic from 2.10.6 to 2.11.1 (#2594) 2025-04-03 08:55:01 +02:00
Yohan Boniface
b3f587ccf3
chore: bump django from 5.1.7 to 5.1.8 (#2595) 2025-04-03 08:49:20 +02:00
dependabot[bot]
64cf408233
chore: bump django from 5.1.7 to 5.1.8
Bumps [django](https://github.com/django/django) from 5.1.7 to 5.1.8.
- [Commits](https://github.com/django/django/compare/5.1.7...5.1.8)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-02 20:41:42 +00:00
dependabot[bot]
e26e8edc8a
chore: bump pydantic from 2.10.6 to 2.11.1
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.10.6 to 2.11.1.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.10.6...v2.11.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-31 17:38:06 +00:00
dependabot[bot]
1b8e217768
chore: bump mkdocs-material from 9.6.9 to 9.6.10
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.6.9 to 9.6.10.
- [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.9...9.6.10)

---
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-31 17:38:01 +00:00
Yohan Boniface
5259cab027 chore: remove saving.js import added by mistake during rebase 2025-03-28 18:00:14 +01:00
Yohan Boniface
7ede27bf0f chore: top edit bar responsiveness
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-28 18:00:14 +01:00
Yohan Boniface
5807cfbbcd wip: move undo/redo buttons to the left
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-28 18:00:14 +01:00
Yohan Boniface
e41ad4e069 wip: allow to sync version restore
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-28 18:00:14 +01:00
Yohan Boniface
c933df585c wip: add test to make sure saving also save remote dirty datalayers 2025-03-28 18:00:14 +01:00
Yohan Boniface
50f2c08ecb wip: do not call document during JS unittests 2025-03-28 18:00:14 +01:00
Yohan Boniface
d61e045903 wip: allow to sync/undo filter added/removed from table editor 2025-03-28 18:00:14 +01:00
Yohan Boniface
6b2038e83e wip: permissions does not inherit from ServerStored anymore 2025-03-28 18:00:14 +01:00
Yohan Boniface
0389e9a185 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-28 18:00:14 +01:00
Yohan Boniface
a2e864ad73 wip: uncreated map should always appear as dirty 2025-03-28 18:00:14 +01:00
Yohan Boniface
d0fb85d552 wip: DataLayer does not inherit anymore from ServerStored 2025-03-28 18:00:14 +01:00
Yohan Boniface
fa83764c8b wip: allow DataLayer.clear to be sync and undone 2025-03-28 18:00:14 +01:00
Yohan Boniface
d438a007e4 wip: uMap does not inherit anymore from ServerStored
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-28 18:00:14 +01:00
Yohan Boniface
be52e7ca2f wip: remove not effective code
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-28 18:00:14 +01:00
Yohan Boniface
172de0e2d0 wip: add permissions related fields in schema
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-28 18:00:14 +01:00
Yohan Boniface
77da6425c2 wip: allow to mark an operation as not undoable
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-28 18:00:14 +01:00
Yohan Boniface
093ed061c1 wip: tests pass
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-28 18:00:14 +01:00
Yohan Boniface
5cb7cb2738 fixup: make sure to toggle remote client state at save too
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-28 18:00:14 +01:00
Yohan Boniface
dfdfae0080 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-28 18:00:14 +01:00
Yohan Boniface
4fd066387d Update the tests and remove cancel edits
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
2025-03-28 18:00:14 +01:00
fa3ba46ca8 Add integration test for batch undo/redo 2025-03-28 18:00:14 +01:00
Yohan Boniface
cb46a5f875 Batch operations
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-28 18:00:14 +01:00
Yohan Boniface
cc2625bfac wip: undo redo
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
2025-03-28 18:00:14 +01:00
47 changed files with 1023 additions and 415 deletions

View file

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

View file

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

View file

@ -28,7 +28,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]
dependencies = [
"Django==5.1.7",
"Django==5.1.8",
"django-agnocomplete==2.2.0",
"django-environ==0.12.0",
"django-probes==1.7.0",
@ -47,7 +47,7 @@ dev = [
"ruff==0.11.2",
"djlint==1.36.4",
"mkdocs==1.6.1",
"mkdocs-material==9.6.9",
"mkdocs-material==9.6.10",
"mkdocs-static-i18n==1.3.0",
"vermin==1.6.0",
"pymdown-extensions==10.14.3",
@ -71,7 +71,7 @@ s3 = [
"django-storages[s3]==1.14.5",
]
sync = [
"pydantic==2.10.6",
"pydantic==2.11.1",
"redis==5.2.1",
]

View file

@ -417,13 +417,17 @@ class Map(NamedModel):
datalayer.clone(map_inst=new)
return new
def get_tags_display(self):
labels = dict(settings.UMAP_TAGS)
return [(t, labels.get(t, t)) for t in self.tags]
@classproperty
def extra_schema(self):
return {
"iconUrl": {
"default": "%sumap/img/marker.svg" % settings.STATIC_URL,
},
"tags": {"choices": settings.UMAP_TAGS},
"tags": {"choices": sorted(settings.UMAP_TAGS, key=lambda i: i[0])},
}

View file

@ -171,7 +171,17 @@ h2.tabs a:hover {
.more_button {
min-height: var(--map-fragment-height);
}
.tag-list {
margin-top: var(--text-margin);
margin-bottom: var(--text-margin);
}
.tag-list li {
border: 1px solid var(--color-darkCyan);
border-radius: 3vmin;
display: inline-block;
padding: var(--button-padding-small);
white-space: nowrap;
}
/* **************************** */
/* colors */

View file

@ -14,11 +14,12 @@
background-color: inherit;
}
.leaflet-container .edit-save,
.leaflet-container .edit-cancel,
.leaflet-container .edit-undo,
.leaflet-container .edit-redo,
.leaflet-container .edit-disable,
.leaflet-container .connected-peers
{
display: block;
display: inline-block;
height: 32px;
line-height: 30px;
padding: 0 20px;
@ -39,7 +40,8 @@
color: var(--color-darkGray);
}
.leaflet-container .edit-cancel:hover,
.leaflet-container .edit-undo:hover,
.leaflet-container .edit-redo:hover,
.leaflet-container .edit-disable:hover {
border: 0.5px solid rgba(153, 153, 153, 0.80);
text-decoration: none;
@ -76,19 +78,13 @@
background: rgba(66, 236, 230, 0.10);
}
.leaflet-container .edit-save,
.leaflet-container .edit-cancel,
.leaflet-container .edit-disable,
.umap-edit-enabled .edit-enable {
display: none;
}
.umap-edit-enabled .edit-save,
.umap-edit-enabled .edit-disable,
.umap-edit-enabled.umap-is-dirty .edit-cancel {
.umap-edit-enabled .edit-disable {
display: inline-block;
}
.umap-is-dirty .edit-disable {
display: none;
}
.umap-caption-bar {
display: none;
}
@ -115,8 +111,6 @@
.umap-right-edit-toolbox {
display: flex;
column-gap: 10px;
}
.umap-right-edit-toolbox {
align-items: center;
}
@ -135,17 +129,20 @@
text-indent: -9999px;
}
.umap-main-edit-toolbox .map-name {
display: inline-block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: bold;
text-align: start;
}
.truncate {
display: inline-flex;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.umap-main-edit-toolbox .username {
max-width: 100px;
}
.umap-main-edit-toolbox .share-status {
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
}
.map-name:after {
content: '\00a0';
@ -243,3 +240,14 @@
padding: 0;
margin: 0;
}
@media all and (max-width: 980px) {
.umap-main-edit-toolbox button span {
display: none;
}
}
@media all and (max-width: 770px) {
.umap-main-edit-toolbox .umap-help-link,
.umap-main-edit-toolbox .share-status {
display: none !important;
}
}

View file

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

View file

@ -18,6 +18,9 @@
<clipPath id="clip0_3071_861">
<rect id="rect3" width="18" height="20" fill="#fff"/>
</clipPath>
<clipPath id="clip0_241_10857-3">
<rect id="rect586-6" width="18.05" height="19.01" fill="#fff"/>
</clipPath>
</defs>
<metadata id="metadata7">
<rdf:RDF>
@ -67,9 +70,12 @@
</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"/>
<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"/>
</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)">
<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"/>
@ -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="path582" d="m4.75 8.45-4.04-4.05 4.04-4.05"/>
</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">
<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"/>

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">
<rect width="18" height="20" fill="#ffffff" id="rect3" x="0" y="0" />
</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>
<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 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>
@ -78,9 +81,12 @@
</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" />
<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" />
</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)">
<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" />
@ -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 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 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)">
<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" />

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) {
this._geometry_bk = Utils.CopyJSON(this._geometry)
this._geometry = value
this.pushGeometry()
}
@ -104,13 +105,15 @@ class Feature {
}
pullGeometry(sync = true) {
const oldGeometry = Utils.CopyJSON(this._geometry)
this.fromLatLngs(this._getLatLngs())
if (sync) {
this.sync.update('geometry', this.geometry)
this.sync.update('geometry', this.geometry, oldGeometry)
}
}
fromLatLngs(latlngs) {
this._geometry_bk = Utils.CopyJSON(this._geometry)
this._geometry = this.convertLatLngs(latlngs)
}
@ -145,8 +148,15 @@ class Feature {
onCommit() {
// 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.
const oldGeoJSON = this._just_married ? null : Utils.CopyJSON(this.toGeoJSON())
this.pullGeometry(false)
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() {

View file

@ -16,7 +16,6 @@ import { Default as DefaultLayer } from '../rendering/layers/base.js'
import { Categorized, Choropleth, Circles } from '../rendering/layers/classified.js'
import { Cluster } from '../rendering/layers/cluster.js'
import { Heat } from '../rendering/layers/heat.js'
import { ServerStored } from '../saving.js'
import * as Schema from '../schema.js'
import TableEditor from '../tableeditor.js'
import * as Utils from '../utils.js'
@ -36,9 +35,8 @@ const LAYER_MAP = LAYER_TYPES.reduce((acc, klass) => {
return acc
}, {})
export class DataLayer extends ServerStored {
export class DataLayer {
constructor(umap, leafletMap, data = {}) {
super()
this._umap = umap
this.sync = umap.syncEngine.proxy(this)
this._index = Array()
@ -49,7 +47,6 @@ export class DataLayer extends ServerStored {
this._leafletMap = leafletMap
this.parentPane = this._leafletMap.getPane('overlayPane')
this.pane = this._leafletMap.createPane(`datalayer${stamp(this)}`, this.parentPane)
this.pane.dataset.id = stamp(this)
// FIXME: should be on layer
this.renderer = L.svg({ pane: this.pane })
this.defaultOptions = {
@ -66,6 +63,7 @@ export class DataLayer extends ServerStored {
data.id = data.id || crypto.randomUUID()
this.setOptions(data)
this.pane.dataset.id = this.id
if (!Utils.isObject(this.options.remoteData)) {
this.options.remoteData = {}
@ -114,7 +112,6 @@ export class DataLayer extends ServerStored {
set isDeleted(status) {
this._isDeleted = status
if (status) this.isDirty = status
}
get isDeleted() {
@ -269,13 +266,11 @@ export class DataLayer extends ServerStored {
}
clear() {
this.layer.clearLayers()
this._features = {}
this._index = Array()
if (this._geojson) {
this.backupData()
this._geojson = null
this.sync.startBatch()
for (const feature of Object.values(this._features)) {
feature.del()
}
this.sync.commitBatch()
this.dataChanged()
}
@ -366,9 +361,8 @@ export class DataLayer extends ServerStored {
}
connectToMap() {
const id = stamp(this)
if (!this._umap.datalayers[id]) {
this._umap.datalayers[id] = this
if (!this._umap.datalayers[this.id]) {
this._umap.datalayers[this.id] = this
}
if (!this._umap.datalayersIndex.includes(this)) {
this._umap.datalayersIndex.push(this)
@ -417,7 +411,10 @@ export class DataLayer extends ServerStored {
removeFeature(feature, sync) {
const id = stamp(feature)
if (sync !== false) feature.sync.delete()
if (sync !== false) {
const oldValue = feature.toGeoJSON()
feature.sync.delete(oldValue)
}
this.hideFeature(feature)
delete this._umap.featuresIndex[feature.getSlug()]
feature.disconnectFromDataLayer(this)
@ -460,7 +457,10 @@ export class DataLayer extends ServerStored {
try {
// Do not fail if remote data is somehow invalid,
// 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) {
console.debug('Error with DataLayer', this.id)
console.error(err)
@ -518,7 +518,7 @@ export class DataLayer extends ServerStored {
}
if (feature && !feature.isEmpty()) {
this.addFeature(feature)
if (sync) feature.onCommit()
if (sync) feature.sync.upsert(feature.toGeoJSON(), null)
return feature
}
}
@ -527,10 +527,6 @@ export class DataLayer extends ServerStored {
return this._umap.formatter
.parse(raw, format)
.then((geojson) => this.addData(geojson))
.then((data) => {
if (data?.length) this.isDirty = true
return data
})
.catch((error) => {
console.debug(error)
Alert.error(translate('Import failed: invalid data'))
@ -596,17 +592,17 @@ export class DataLayer extends ServerStored {
}
del(sync = true) {
const oldValue = Utils.CopyJSON(this.umapGeoJSON())
this.erase()
if (sync) {
this.isDeleted = true
this.sync.delete()
this.sync.delete(oldValue)
}
}
empty() {
if (this.isRemoteLayer()) return
this.clear()
this.isDirty = true
}
clone() {
@ -630,25 +626,6 @@ export class DataLayer extends ServerStored {
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() {
if (!this.isVisible()) return
this.eachFeature((feature) => feature.redraw())
@ -940,11 +917,14 @@ export class DataLayer extends ServerStored {
)
if (!error) {
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()
if (this.isRemoteLayer()) this.fetchRemoteData()
else this.addData(geojson)
this.isDirty = true
}
})
}
@ -1098,7 +1078,11 @@ export class DataLayer extends ServerStored {
setReferenceVersion({ response, sync }) {
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() {
@ -1127,6 +1111,10 @@ export class DataLayer extends ServerStored {
}
async _trySave(url, headers, formData) {
if (this._forceSave) {
headers = {}
this._forceSave = false
}
const [data, response, error] = await this._umap.server.post(url, headers, formData)
if (error) {
if (response && response.status === 412) {
@ -1136,15 +1124,8 @@ export class DataLayer extends ServerStored {
'This situation is tricky, you have to choose carefully which version is pertinent.'
),
async () => {
// Save again this layer
const status = await this._trySave(url, {}, formData)
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()
}
this._forceSave = true
await this._umap.saveAll()
}
)
}
@ -1179,7 +1160,7 @@ export class DataLayer extends ServerStored {
}
commitDelete() {
delete this._umap.datalayers[stamp(this)]
delete this._umap.datalayers[this.id]
}
getName() {

View file

@ -135,7 +135,13 @@ export default class Facets {
for (const [property, { label, type }] of parsed) {
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) {
@ -146,15 +152,13 @@ export default class Facets {
const defined = this.getDefined()
if (!defined.has(property)) {
defined.set(property, { label, type })
this._umap.properties.facetKey = this.dumps(defined)
this._umap.isDirty = true
this.dumps(defined)
}
}
remove(property) {
const defined = this.getDefined()
defined.delete(property)
this._umap.properties.facetKey = this.dumps(defined)
this._umap.isDirty = true
this.dumps(defined)
}
}

View file

@ -70,21 +70,7 @@ export class Form extends Utils.WithEvents {
}
setter(field, value) {
const path = field.split('.')
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]
}
}
Utils.setObjectValue(this.obj, field, value)
}
restoreField(field) {
@ -193,13 +179,17 @@ export class MutatingForm extends Form {
}
setter(field, value) {
super.setter(field, value)
this.obj.isDirty = true
const oldValue = this.getter(field)
if ('setter' in this.obj) {
this.obj.setter(field, value)
} else {
super.setter(field, value)
}
if ('render' in this.obj) {
this.obj.render([field], this)
}
if ('sync' in this.obj) {
this.obj.sync.update(field, value)
this.obj.sync.update(field, value, oldValue)
}
}

View file

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

View file

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

View file

@ -2,17 +2,15 @@ import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
import { uMapAlert as Alert } from '../components/alerts/alert.js'
import { MutatingForm } from './form/builder.js'
import { translate } from './i18n.js'
import { ServerStored } from './saving.js'
import * as Utils from './utils.js'
// 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.
export class MapPermissions extends ServerStored {
export class MapPermissions {
constructor(umap) {
super()
this.setProperties(umap.properties.permissions)
this._umap = umap
this._isDirty = false
this.sync = umap.syncEngine.proxy(this)
}
setProperties(properties) {
@ -28,6 +26,13 @@ export class MapPermissions extends ServerStored {
)
}
getSyncMetadata() {
return {
subject: 'mappermissions',
metadata: {},
}
}
render() {
this._umap.render(['properties.permissions'])
}
@ -188,7 +193,6 @@ export class MapPermissions extends ServerStored {
}
async save() {
if (!this.isDirty) return
const formData = new FormData()
if (!this.isAnonymousMap() && this.properties.editors) {
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) {
super()
this._umap = umap
this.properties = Object.assign(
{
@ -259,6 +262,14 @@ export class DataLayerPermissions extends ServerStored {
)
this.datalayer = datalayer
this.sync = umap.syncEngine.proxy(this)
}
getSyncMetadata() {
return {
subject: 'datalayerpermissions',
metadata: { id: this.datalayer.id },
}
}
edit(container) {
@ -289,7 +300,6 @@ export class DataLayerPermissions extends ServerStored {
}
async save() {
if (!this.isDirty) return
const formData = new FormData()
formData.append('edit_status', this.properties.edit_status)
const [data, response, error] = await this._umap.server.post(

View file

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

View file

@ -97,7 +97,6 @@ const FeatureMixin = {
},
onCommit: function () {
this.feature.pullGeometry(false)
this.feature.onCommit()
},
}
@ -112,7 +111,7 @@ const PointMixin = {
this.on('dragend', (event) => {
this.isDirty = true
this.feature.edit(event)
this.feature.pullGeometry(false)
// this.feature.pullGeometry(false)
})
if (!this.feature.isReadOnly()) this.on('mouseover', this._enableDragging)
this.on('mouseout', this._onMouseOut)
@ -303,13 +302,13 @@ const PathMixin = {
this._container = null
FeatureMixin.onAdd.call(this, map)
this.setStyle()
if (this.editing?.enabled()) this.editing.addHooks()
if (this.editor?.enabled()) this.editor.addHooks()
this.resetTooltip()
this._path.dataset.feature = this.feature.id
},
onRemove: function (map) {
if (this.editing?.enabled()) this.editing.removeHooks()
if (this.editor?.enabled()) this.editor.removeHooks()
FeatureMixin.onRemove.call(this, map)
},
@ -362,6 +361,13 @@ const PathMixin = {
isOnScreen: function (bounds) {
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({

View file

@ -17,20 +17,10 @@ class Rule {
this.parse()
}
get isDirty() {
return this._isDirty
}
set isDirty(status) {
this._isDirty = status
if (status) this._umap.isDirty = status
}
constructor(umap, condition = '', options = {}) {
// TODO make this public properties when browser coverage is ok
// cf https://caniuse.com/?search=public%20class%20field
this._condition = null
this._isDirty = false
this.OPERATORS = [
['>', this.gt],
['<', this.lt],
@ -190,17 +180,25 @@ class Rule {
_delete() {
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 {
constructor(umap) {
this._umap = umap
this.rules = []
this.load()
}
load() {
this.rules = []
if (!this._umap.properties.rules?.length) return
for (const { condition, options } of this._umap.properties.rules) {
if (!condition) continue
@ -222,8 +220,8 @@ export default class Rules {
else if (finalIndex > initialIndex) newIdx = referenceIdx
else newIdx = referenceIdx + 1
this.rules.splice(newIdx, 0, moved)
moved.isDirty = true
this._umap.render(['rules'])
this.commit()
}
edit(container) {
@ -242,7 +240,6 @@ export default class Rules {
addRule() {
const rule = new Rule(this._umap)
rule.isDirty = true
this.rules.push(rule)
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,
impacts: ['data'],
},
center: {
type: Object,
impacts: [], // default center, doesn't need any update of the map
},
color: {
type: String,
impacts: ['data'],
@ -118,6 +122,9 @@ export const SCHEMA = {
default: false,
label: translate('Animated transitions'),
},
edit_status: {
type: Number,
},
editinosmControl: {
type: Boolean,
impacts: ['ui'],
@ -125,6 +132,9 @@ export const SCHEMA = {
label: translate('Display the control to open OpenStreetMap editor'),
default: null,
},
editors: {
type: Array,
},
embedControl: {
type: Boolean,
impacts: ['ui'],
@ -362,6 +372,9 @@ export const SCHEMA = {
type: Object,
impacts: ['background'],
},
owner: {
type: Object,
},
permanentCredit: {
type: 'Text',
impacts: ['ui'],
@ -436,6 +449,9 @@ export const SCHEMA = {
label: translate('Display the search control'),
default: true,
},
share_status: {
type: Number,
},
shortCredit: {
type: String,
impacts: ['ui'],
@ -503,6 +519,9 @@ export const SCHEMA = {
tags: {
type: Array,
},
team: {
type: Object,
},
tilelayer: {
type: Object,
impacts: ['background'],
@ -569,7 +588,6 @@ export const SCHEMA = {
type: Object,
impacts: ['data'],
},
_referenceVersion: {
type: Number,
impacts: ['data'],

View file

@ -1,7 +1,13 @@
import * as SaveManager from '../saving.js'
import * as Utils from '../utils.js'
import { HybridLogicalClock } from './hlc.js'
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
import { UndoManager } from './undo.js'
import {
DataLayerUpdater,
FeatureUpdater,
MapUpdater,
MapPermissionsUpdater,
DataLayerPermissionsUpdater,
} from './updaters.js'
import { WebSocketTransport } from './websocket.js'
// Start reconnecting after 2 seconds, then double the delay each time
@ -55,6 +61,8 @@ export class SyncEngine {
map: new MapUpdater(umap),
feature: new FeatureUpdater(umap),
datalayer: new DataLayerUpdater(umap),
mappermissions: new MapPermissionsUpdater(umap),
datalayerpermissions: new DataLayerPermissionsUpdater(umap),
}
this.transport = undefined
this._operations = new Operations()
@ -64,6 +72,7 @@ export class SyncEngine {
this.websocketConnected = false
this.closeRequested = false
this.peerId = Utils.generateId()
this._undoManager = new UndoManager(umap, this.updaters, this)
}
get isOpen() {
@ -122,16 +131,107 @@ export class SyncEngine {
await this.authenticate()
}, this._reconnectDelay)
}
upsert(subject, metadata, value) {
this._send({ verb: 'upsert', subject, metadata, value })
startBatch() {
this._batch = []
}
update(subject, metadata, key, value) {
this._send({ verb: 'update', subject, metadata, key, value })
commitBatch(subject, metadata) {
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) {
this._send({ verb: 'delete', subject, metadata, key })
upsert(subject, metadata, value, oldValue) {
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() {
@ -144,8 +244,8 @@ export class SyncEngine {
}
}
_send(inputMessage) {
const message = this._operations.addLocal(inputMessage)
_send(operation) {
const message = this._operations.addLocal(operation)
if (this.offline) return
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)) {
return this.updaters[subject]
}
@ -161,7 +265,15 @@ export class SyncEngine {
}
_applyOperation(operation) {
if (operation.verb === 'batch') {
operation.operations.map((op) => this._applyOperation(op))
return
}
const updater = this._getUpdater(operation.subject, operation.metadata)
if (!updater) {
debug('No updater for', operation)
return
}
updater.applyMessage(operation)
}
@ -304,9 +416,8 @@ export class SyncEngine {
onSavedMessage({ sender, lastKnownHLC }) {
debug(`received saved message from peer ${sender}`, lastKnownHLC)
if (lastKnownHLC === this._operations.getLastKnownHLC() && SaveManager.isDirty) {
SaveManager.clear()
}
this._operations.saved(lastKnownHLC)
this._undoManager.toggleState()
}
/**
@ -356,7 +467,7 @@ export class SyncEngine {
const handler = {
get(target, prop) {
// Only proxy these methods
if (['upsert', 'update', 'delete'].includes(prop)) {
if (['upsert', 'update', 'delete', 'commitBatch'].includes(prop)) {
const { subject, metadata } = object.getSyncMetadata()
// Reflect.get is calling the original method.
// .bind is adding the parameters automatically
@ -378,16 +489,22 @@ export class Operations {
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.
*
* @param {*} inputMessage
* @returns {*} clock-aware message
*/
addLocal(inputMessage) {
const message = { ...inputMessage, hlc: this._hlc.tick() }
this._operations.push(message)
return message
addLocal(operation) {
operation.hlc = this._hlc.tick()
this._operations.push(operation)
return operation
}
/**
@ -445,6 +562,11 @@ export class Operations {
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.
*/

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
@ -10,27 +10,6 @@ class BaseUpdater {
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) {
return this._umap.getDataLayerByUmapId(layerId)
}
@ -43,13 +22,17 @@ class BaseUpdater {
export class MapUpdater extends BaseUpdater {
update({ key, value }) {
if (fieldInSchema(key)) {
this.updateObjectValue(this._umap, key, value)
if (Utils.fieldInSchema(key)) {
Utils.setObjectValue(this._umap, key, value)
}
this._umap.onPropertiesUpdated([key])
this._umap.render([key])
}
getStoredObject() {
return this._umap
}
}
export class DataLayerUpdater extends BaseUpdater {
@ -58,14 +41,21 @@ export class DataLayerUpdater extends BaseUpdater {
try {
this.getDataLayerFromID(value.id)
} 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 }) {
const datalayer = this.getDataLayerFromID(metadata.id)
if (fieldInSchema(key)) {
this.updateObjectValue(datalayer, key, value)
if (key === 'options') {
datalayer.setOptions(value)
} else if (Utils.fieldInSchema(key)) {
Utils.setObjectValue(datalayer, key, value)
} else {
console.debug(
'Not applying update for datalayer because key is not in the schema',
@ -82,6 +72,10 @@ export class DataLayerUpdater extends BaseUpdater {
datalayer.commitDelete()
}
}
getStoredObject(metadata) {
return this.getDataLayerFromID(metadata.id)
}
}
export class FeatureUpdater extends BaseUpdater {
@ -114,7 +108,7 @@ export class FeatureUpdater extends BaseUpdater {
const feature = this.getFeatureFromMetadata(metadata)
feature.geometry = value
} else {
this.updateObjectValue(feature, key, value)
Utils.setObjectValue(feature, key, value)
feature.datalayer.indexProperties(feature)
}
@ -127,4 +121,32 @@ export class FeatureUpdater extends BaseUpdater {
const feature = this.getFeatureFromMetadata(metadata)
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

@ -9,8 +9,16 @@ const TOP_BAR_TEMPLATE = `
<div class="umap-main-edit-toolbox with-transition dark">
<div class="umap-left-edit-toolbox" data-ref="left">
<div class="logo"><a class="" href="/" title="${translate('Go to the homepage')}">uMap</a></div>
<button class="map-name flat" type="button" data-ref="name"></button>
<button class="share-status flat" type="button" data-ref="share"></button>
<button class="map-name flat truncate" type="button" data-ref="name"></button>
<button class="share-status flat truncate" type="button" data-ref="share"></button>
<button class="edit-undo round" type="button" data-ref="undo" disabled>
<i class="icon icon-16 icon-undo"></i>
<span>${translate('Undo')}</span>
</button>
<button class="edit-redo round" type="button" data-ref="redo" disabled>
<i class="icon icon-16 icon-redo"></i>
<span>${translate('Redo')}</span>
</button>
</div>
<div class="umap-right-edit-toolbox" data-ref="right">
<button class="connected-peers round" type="button" data-ref="peers">
@ -19,18 +27,14 @@ const TOP_BAR_TEMPLATE = `
</button>
<button class="umap-user flat" type="button" data-ref="user">
<i class="icon icon-16 icon-profile"></i>
<span class="username" data-ref="username"></span>
<span class="username truncate" data-ref="username"></span>
</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">
<i class="icon icon-16 icon-restore"></i>
<span class="">${translate('Cancel edits')}</span>
</button>
<button class="edit-disable round" type="button" data-ref="view">
<button class="edit-disable round disabled-on-dirty" type="button" data-ref="view">
<i class="icon icon-16 icon-eye"></i>
<span class="">${translate('View')}</span>
<span>${translate('View')}</span>
</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-disabled"></i>
<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.cancel.addEventListener('click', () => this._umap.askForReset())
this.elements.cancel.addEventListener('mouseover', () => {
this.elements.redo.addEventListener('click', () => this._umap.redo())
this.elements.undo.addEventListener('click', () => this._umap.undo())
this.elements.undo.addEventListener('mouseover', () => {
this._umap.tooltip.open({
content: this._umap.help.displayLabel('CANCEL'),
anchor: this.elements.cancel,
anchor: this.elements.undo,
position: 'bottom',
delay: 500,
duration: 5000,
@ -154,9 +159,10 @@ export class TopBar extends WithTemplate {
redraw() {
const syncEnabled = this._umap.getProperty('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.saveDraftLabel.hidden = !this._umap.permissions.isDraft()
this._umap.sync._undoManager.toggleState()
}
}
@ -270,10 +276,7 @@ export class EditBar extends WithTemplate {
DomEvent.disableClickPropagation(this.element)
this._onClick('marker', () => this._leafletMap.editTools.startMarker())
this._onClick('polyline', () => this._leafletMap.editTools.startPolyline())
this._onClick('multiline', () => {
console.log('click click')
this._umap.editedFeature.ui.editor.newShape()
})
this._onClick('multiline', () => this._umap.editedFeature.ui.editor.newShape())
this._onClick('polygon', () => this._leafletMap.editTools.startPolygon())
this._onClick('multipolygon', () => this._umap.editedFeature.ui.editor.newShape())
this._onClick('caption', () => this._umap.editCaption())

View file

@ -22,8 +22,6 @@ import { MapPermissions } from './permissions.js'
import { LeafletMap } from './rendering/map.js'
import { Request, ServerRequest } from './request.js'
import Rules from './rules.js'
import { ServerStored } from './saving.js'
import * as SAVEMANAGER from './saving.js'
import { SCHEMA } from './schema.js'
import Share from './share.js'
import Slideshow from './slideshow.js'
@ -36,9 +34,8 @@ import Tooltip from './ui/tooltip.js'
import URLs from './urls.js'
import * as Utils from './utils.js'
export default class Umap extends ServerStored {
export default class Umap {
constructor(element, geojson) {
super()
// We need to call async function in the init process,
// the init itself does not need to be awaited, but some calls
// in the process must be blocker
@ -96,6 +93,10 @@ export default class Umap extends ServerStored {
this._leafletMap.latLng(center)
}
// Needed for permissions
this.syncEngine = new SyncEngine(this)
this.sync = this.syncEngine.proxy(this)
// Needed to render controls
this.permissions = new MapPermissions(this)
this.urls = new URLs(this.properties.urls)
@ -130,9 +131,6 @@ export default class Umap extends ServerStored {
this.share = new Share(this)
this.rules = new Rules(this)
this.syncEngine = new SyncEngine(this)
this.sync = this.syncEngine.proxy(this)
if (this.hasEditMode()) {
this.editPanel = new EditPanel(this, this._leafletMap)
this.fullPanel = new FullPanel(this, this._leafletMap)
@ -196,7 +194,6 @@ export default class Umap extends ServerStored {
// Creation mode
if (!this.id) {
if (!this.properties.preview) {
this.isDirty = true
this.enableEdit()
}
this._defaultExtent = true
@ -212,10 +209,14 @@ export default class Umap extends ServerStored {
this.propagate()
}
window.onbeforeunload = () => (this.editEnabled && SAVEMANAGER.isDirty) || null
window.onbeforeunload = () => (this.editEnabled && this.isDirty) || null
this.backup()
}
get isDirty() {
return this.sync._undoManager.isDirty()
}
get editedFeature() {
return this._editedFeature
}
@ -349,7 +350,7 @@ export default class Umap extends ServerStored {
const items = []
if (this.hasEditMode()) {
if (this.editEnabled) {
if (!SAVEMANAGER.isDirty) {
if (!this.isDirty) {
items.push({
label: this.help.displayLabel('STOP_EDIT'),
action: () => this.disableEdit(),
@ -543,19 +544,17 @@ export default class Umap extends ServerStored {
let used = true
switch (event.key) {
case 'e':
if (!SAVEMANAGER.isDirty) this.disableEdit()
if (!this.isDirty) this.disableEdit()
break
case 's':
if (SAVEMANAGER.isDirty) this.saveAll()
if (this.isDirty) this.saveAll()
break
case 'z':
if (Utils.isWritable(event.target)) {
used = false
break
}
if (SAVEMANAGER.isDirty) {
this.askForReset()
}
this.sync._undoManager.undo()
break
case 'm':
this._leafletMap.editTools.startMarker()
@ -671,10 +670,10 @@ export default class Umap extends ServerStored {
}
async saveAll() {
if (!SAVEMANAGER.isDirty) return
if (!this.isDirty) return
if (this._defaultExtent) this._setCenterAndZoom()
this.backup()
await SAVEMANAGER.save()
await this.sync.save()
// 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
// save action
@ -685,7 +684,6 @@ export default class Umap extends ServerStored {
Alert.success(translate('Map has been saved!'))
})
}
this.sync.saved()
this.fire('saved')
}
@ -1025,35 +1023,36 @@ export default class Umap extends ServerStored {
'button',
boundsButtons,
translate('Use current bounds'),
function () {
() => {
const bounds = this._leafletMap.getBounds()
const oldLimitBounds = { ...this.properties.limitBounds }
this.properties.limitBounds.south = LeafletUtil.formatNum(bounds.getSouth())
this.properties.limitBounds.west = LeafletUtil.formatNum(bounds.getWest())
this.properties.limitBounds.north = LeafletUtil.formatNum(bounds.getNorth())
this.properties.limitBounds.east = LeafletUtil.formatNum(bounds.getEast())
boundsBuilder.fetchAll()
this.sync.update(this, 'properties.limitBounds', this.properties.limitBounds)
this.isDirty = true
this.sync.update(
'properties.limitBounds',
this.properties.limitBounds,
oldLimitBounds
)
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) {
@ -1166,23 +1165,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() {
this.rules.commit()
const geojson = {
type: 'Feature',
geometry: this.geometry(),
@ -1308,16 +1291,6 @@ export default class Umap extends ServerStored {
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() {
// this.properties.websocketEnabled is set by the server admin
if (this.properties.websocketEnabled === false) return
@ -1331,7 +1304,6 @@ export default class Umap extends ServerStored {
getSyncMetadata() {
return {
engine: this.sync,
subject: 'map',
}
}
@ -1355,6 +1327,9 @@ export default class Umap extends ServerStored {
this.bottomBar.redraw()
break
case 'data':
if (fields.includes('properties.rules')) {
this.rules.load()
}
this.eachVisibleDataLayer((datalayer) => {
datalayer.redraw()
})
@ -1529,7 +1504,7 @@ export default class Umap extends ServerStored {
const form = builder.build()
row.appendChild(form)
row.classList.toggle('off', !datalayer.isVisible())
row.dataset.id = stamp(datalayer)
row.dataset.id = datalayer.id
})
const onReorder = (src, dst, initialIndex, finalIndex) => {
const movedLayer = this.datalayers[src.dataset.id]
@ -1560,7 +1535,7 @@ export default class Umap extends ServerStored {
}
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}`)
return datalayer
}
@ -1676,7 +1651,6 @@ export default class Umap extends ServerStored {
)
this.render(fields)
this._leafletMap._setDefaultCenter()
this.isDirty = true
}
importUmapFile(file) {
@ -1775,13 +1749,26 @@ export default class Umap extends ServerStored {
}
_setCenterAndZoom() {
const oldCenter = { ...this.properties.center }
const oldZoom = this.properties.zoom
this.properties.center = this._leafletMap.getCenter()
this.properties.zoom = this._leafletMap.getZoom()
this.isDirty = true
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) {
return SCHEMA.iconUrl.default.replace('marker.svg', name)
}
undo() {
this.sync._undoManager.undo()
}
redo() {
this.sync._undoManager.redo()
}
}

View file

@ -484,6 +484,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 = [
'Black',
'Navy',

View file

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

View file

@ -1000,7 +1000,7 @@ a.umap-control-caption,
.umap-main-edit-toolbox .umap-user span,
.leaflet-container .leaflet-control-edit-save span,
.leaflet-container .leaflet-control-edit-disable span,
.leaflet-container .leaflet-control-edit-cancel span {
.leaflet-container .edit-cancel span {
display: none;
}
.umap-main-edit-toolbox .umap-help-button {

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('haveSameContext', () => {

View file

@ -862,4 +862,51 @@ describe('Utils', () => {
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
@ -14,10 +14,11 @@ class OperationMessage(BaseModel):
"""Message sent from one peer to all the others"""
kind: Literal["OperationMessage"] = "OperationMessage"
verb: Literal["upsert", "update", "delete"]
verb: Literal["upsert", "update", "delete", "batch"]
subject: Literal["map", "datalayer", "feature"]
metadata: Optional[dict] = None
key: Optional[str] = None
operations: Optional[List] = None
class PeerMessage(BaseModel):

View file

@ -4,6 +4,13 @@
<div>
{% map_fragment map_inst prefix=prefix page=request.GET.p %}
<hgroup>
{% if map_inst.tags %}
<ul class="tag-list">
{% for tag, label in map_inst.get_tags_display %}
<li><a href="{% url 'search' %}?tags={{ tag }}">{{ label }}</a></li>
{% endfor %}
</ul>
{% endif %}
<h3><a href="{{ map_inst.get_absolute_url }}">{{ map_inst.name }}</a></h3>
{% with author=map_inst.get_author %}
{% if author %}

View file

@ -18,6 +18,7 @@ DATALAYER_DATA = {
"features": [
{
"type": "Feature",
"id": "ExNTQ",
"geometry": {
"type": "Point",
"coordinates": [14.68896484375, 48.55297816440071],
@ -41,7 +42,7 @@ class LicenceFactory(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"
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()
page.locator("dialog").get_by_role("button", name="OK").click()
expect(buttons).to_have_count(2)
page.get_by_role("button", name="Cancel edits").click()
page.locator("dialog").get_by_role("button", name="OK").click()
page.get_by_role("button", name="Undo").click()
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()
colors = getColors(markers)
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):

View file

@ -64,8 +64,7 @@ def test_cancel_deleting_datalayer_should_restore(
page.get_by_role("button", name="OK").click()
expect(markers).to_have_count(0)
expect(page.get_by_text("test datalayer")).to_be_hidden()
page.get_by_role("button", name="Cancel edits").click()
page.locator("dialog").get_by_role("button", name="OK").click()
page.get_by_role("button", name="Undo").click()
expect(markers).to_have_count(1)
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"]').fill("Layer A with a new name")
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/.*")):
page.get_by_role("button", name="Save").click()
assert DataLayer.objects.count() == 2

View file

@ -229,15 +229,15 @@ def test_hover_tooltip_setting_should_be_persistent(live_server, map, page):
def test_can_edit_map_tags(live_server, map, page):
map.settings["properties"]["tags"] = ["art"]
map.settings["properties"]["tags"] = ["arts"]
map.edit_status = Map.ANONYMOUS
map.save()
page.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
page.get_by_role("button", name="Edit map name and caption").click()
page.get_by_text("Tags").click()
expect(page.get_by_label("Art and Culture")).to_be_checked()
page.get_by_label("Bike").check()
page.get_by_label("Cycling").check()
with page.expect_response(re.compile("./update/settings/.*")):
page.get_by_role("button", name="Save").click()
saved = Map.objects.get(pk=map.pk)
assert saved.tags == ["art", "bike"]
assert saved.tags == ["arts", "cycling"]

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(
1
)
page.get_by_role("button", name="Cancel edits").click()
page.locator("dialog").get_by_role("button", name="OK").click()
page.get_by_role("button", name="Undo").click()
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
page_two.locator(".leaflet-marker-icon").click(modifiers=["Shift"])
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
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/.*")):
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
expect(page_two.get_by_text("Whoops! Other contributor(s) changed")).to_be_visible()
# 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
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())
assert data["features"][0]["properties"]["name"] == "name from page two"
# 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.locator('input[name="name"]').click()
page.locator('input[name="name"]').fill("new name")
page.get_by_role("button", name="Cancel edits").click()
page.get_by_role("button", name="OK").click()
page.get_by_role("button", name="Undo").click()
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="Edit", exact=True).click()
page.locator('input[name="name"]').click()
page.locator('input[name="name"]').fill("new datalayer name")
page.wait_for_timeout(300) # Time of the Input debounce
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 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(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",
},
"id": "ExNTQ",
"properties": {
"_umap_options": {
"color": "DarkCyan",

View file

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