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
This commit is contained in:
Yohan Boniface 2025-04-03 10:32:47 +02:00 committed by GitHub
commit e2f154f62e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 992 additions and 405 deletions

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';
@ -242,3 +239,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) {
@ -190,13 +176,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

@ -541,14 +541,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'],
@ -500,6 +516,9 @@ export const SCHEMA = {
helpEntries: ['sync'],
default: false,
},
team: {
type: Object,
},
tilelayer: {
type: Object,
impacts: ['background'],
@ -566,7 +585,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')
}
@ -1019,35 +1017,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) {
@ -1160,23 +1159,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(),
@ -1301,16 +1284,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
@ -1324,7 +1297,6 @@ export default class Umap extends ServerStored {
getSyncMetadata() {
return {
engine: this.sync,
subject: 'map',
}
}
@ -1348,6 +1320,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()
})
@ -1522,7 +1497,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]
@ -1553,7 +1528,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
}
@ -1669,7 +1644,6 @@ export default class Umap extends ServerStored {
)
this.render(fields)
this._leafletMap._setDefaultCenter()
this.isDirty = true
}
importUmapFile(file) {
@ -1768,13 +1742,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

@ -986,7 +986,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

@ -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

@ -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",