Compare commits

...

13 commits

Author SHA1 Message Date
Yohan Boniface
48daa0a77f fix: do not show tooltip with only spaces
Some checks failed
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
Test & Docs / lint (push) Has been cancelled
Co-authored-by: David Larlet <david@larlet.fr>
2025-02-27 19:28:38 +01:00
Yohan Boniface
ba20f5791c fix: shift-click on a path should toggle edit
This was broken in 4adc558560

Co-authored-by: David Larlet <david@larlet.fr>
2025-02-27 19:21:43 +01:00
Yohan Boniface
0fe2103b71 chore: bump Leaflet.Editable and DOMPurify 2025-02-27 17:12:02 +01:00
Yohan Boniface
55d505e7d8
chore: bump rjsmin from 1.2.3 to 1.2.4 (#2519) 2025-02-27 15:48:37 +01:00
Yohan Boniface
14e90a1a0f
feat: add a quick link to layer's permalink (#2529)
This will open a new page with only this given layer shown.


![image](https://github.com/user-attachments/assets/4db637b9-03f4-4b05-9ce3-6656aba11786)
2025-02-27 15:36:45 +01:00
Yohan Boniface
e919c5f168
fix(sync): make datalayer upsert idempotent (#2528)
That fix does not really fix the original issue, but it makes it
impactless, and I think it's safer anyway to have upsert idempotent.

The pattern to reproduce is:
- peer A create a synced map, add a datalayer, save it
- peer B loads the map, click on edit
- at this time, peer B have twice the datalayer data, once from the
server AND once from the sync

So a better fix would be to make that peer B send a meaningfull HLC to
peer A I guess.
For this we may save the last HLC is the map properties, or maybe try to
merge the "reference_version" and the HLC.
2025-02-27 15:32:09 +01:00
Yohan Boniface
b1076dcb7b
fix: make sure we sync a line when hitting esc while drawing (#2526)
I first tried to handle this on Leaflet.Editable side, to make it fire
the "editable:edited" event we use to trigger the sync, but deciding
what to do with a feature on escape needs some decisions that seems hard
to implement in a generic way in Leaflet.Editable.
We call stopDrawing, which then calls cancelDrawing, so here one need to
decide if cancelDrawing should keep the already drawn line (but cancel
the point being drawn) or cancel everything.
This is why I end up making this change in uMap itself.
2025-02-27 15:26:54 +01:00
Yohan Boniface
69ca89a6ba
fix: prevent client to open two websocket connections (#2524)
To reproduce:
- create a map
- saved it
- change the "syncEnabled" setting to on
- save again
- open another tab with this map
- switch on edit mode

In this case, the second client will try to authenticate:
- once switch on edit mode
- and once receiving the operation message from peer A about the
syncEnabled (which calls render, which calls initSyncEngine in in this
case)

I think we want to keep render to call initSyncEngine, so if a map owner
switch off the syncEnabled setting, this will (should) disconnect the
other peers.
2025-02-27 15:20:16 +01:00
Yohan Boniface
df0faa75aa feat: add a quick link to layer's permalink
This will open a new page with only this given layer shown.
2025-02-26 15:38:48 +01:00
Yohan Boniface
b400ade44b fix(sync): make datalayer upsert idempotent
That fix does not really fix the original issue, but it make it impactless.

The pattern is:
- peer A create a synced map, add a datalayer, save it
- peer B loads the map, click on edit
- at this time, peer B have twice the datalayer data, once from the server
  AND once from the sync

So a better fix would be to make that peer B send a meaningfull HLC to
peer A I guess.
For this we may save the last HLC is the map properties, or maybe try
to merge the "reference_version" and the HLC.
2025-02-26 11:55:21 +01:00
Yohan Boniface
9858fc2190 fix: make sure we sync a line when hitting esc while drawing
I first tried to handle this on Leaflet.Editable side, to make
it fire the "editable:edited" event we use to trigger the sync,
but deciding what to do with a feature on escape needs some
decisions that seems hard to implement in a generic way in
Leaflet.Editable.
We call stopDrawing, which then calls cancelDrawing, so here
one need to decide if cancelDrawing should keep the already
drawn line (but cancel the point being drawn) or cancel
everything.
This is why I end up making this change in uMap itself.
2025-02-25 17:17:49 +01:00
Yohan Boniface
5ddd973eae fix: prevent client to open two websocket connections
To reproduce:
- create a map
- saved it
- change the "syncEnabled" setting to on
- save again
- open another tab with this map
- switch on edit mode

In this case, the second client will try to authenticate:
- once switch on edit mode
- and once receiving the operation message from peer A about the
  syncEnabled (which calls render, which calls initSyncEngine in
  in this case)

I think we want to keep render to call initSyncEngine, so if a map
owner switch off the syncEnabled setting, this will (should) disconnect
the other peers.
2025-02-25 08:33:14 +01:00
dependabot[bot]
d342336930
chore: bump rjsmin from 1.2.3 to 1.2.4
Bumps [rjsmin](https://github.com/ndparker/rjsmin) from 1.2.3 to 1.2.4.
- [Changelog](https://github.com/ndparker/rjsmin/blob/master/CHANGES)
- [Commits](https://github.com/ndparker/rjsmin/compare/1.2.3...1.2.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-24 14:09:11 +00:00
13 changed files with 243 additions and 381 deletions

View file

@ -45,7 +45,7 @@
"georsstogeojson": "^0.2.0", "georsstogeojson": "^0.2.0",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"leaflet": "1.9.4", "leaflet": "1.9.4",
"leaflet-editable": "^1.3.0", "leaflet-editable": "^1.3.1",
"leaflet-editinosm": "0.2.3", "leaflet-editinosm": "0.2.3",
"leaflet-fullscreen": "1.0.2", "leaflet-fullscreen": "1.0.2",
"leaflet-hash": "0.2.1", "leaflet-hash": "0.2.1",

View file

@ -36,7 +36,7 @@ dependencies = [
"psycopg==3.2.4", "psycopg==3.2.4",
"requests==2.32.3", "requests==2.32.3",
"rcssmin==1.2.1", "rcssmin==1.2.1",
"rjsmin==1.2.3", "rjsmin==1.2.4",
"social-auth-core==4.5.4", "social-auth-core==4.5.4",
"social-auth-app-django==5.4.2", "social-auth-app-django==5.4.2",
] ]

View file

@ -335,15 +335,15 @@ class Feature {
// Variables mode. // Variables mode.
if (labelKey) { if (labelKey) {
if (Utils.hasVar(labelKey)) { if (Utils.hasVar(labelKey)) {
return Utils.greedyTemplate(labelKey, this.extendedProperties()) return Utils.greedyTemplate(labelKey, this.extendedProperties()).trim()
} }
keys.unshift(labelKey) keys.unshift(labelKey)
} }
for (const key of keys) { for (const key of keys) {
const value = this.properties[key] const value = this.properties[key]
if (value) return value if (value) return value.trim()
} }
return this.datalayer.getName() return this.datalayer.getName().trim()
} }
hasPopupFooter() { hasPopupFooter() {
@ -640,6 +640,12 @@ class Feature {
window.open(permalink) window.open(permalink)
}, },
}) })
items.push({
label: translate('Layer permalink'),
action: () => {
window.open(this.datalayer.getPermalink())
},
})
} }
items.push({ items.push({
label: translate('Copy as GeoJSON'), label: translate('Copy as GeoJSON'),

View file

@ -1174,6 +1174,12 @@ export class DataLayer extends ServerStored {
return this.options.name || translate('Untitled layer') return this.options.name || translate('Untitled layer')
} }
getPermalink() {
return `${Utils.getBaseUrl()}?${Utils.buildQueryString({ datalayers: this.id })}${
window.location.hash
}`
}
tableEdit() { tableEdit() {
if (!this.isVisible()) return if (!this.isVisible()) return
const editor = new TableEditor(this._umap, this, this._leafletMap) const editor = new TableEditor(this._umap, this, this._leafletMap)

View file

@ -60,8 +60,7 @@ const FeatureMixin = {
if (event.originalEvent.ctrlKey || event.originalEvent.metaKey) { if (event.originalEvent.ctrlKey || event.originalEvent.metaKey) {
this.feature.datalayer.edit(event) this.feature.datalayer.edit(event)
} else { } else {
if (this.feature._toggleEditing) this.feature._toggleEditing(event) this.feature.toggleEditing(event)
else this.feature.edit(event)
} }
} else if (!this._map.editTools?.drawing()) { } else if (!this._map.editTools?.drawing()) {
this._map._umap.editContextmenu.open( this._map._umap.editContextmenu.open(

View file

@ -66,7 +66,12 @@ export class SyncEngine {
this.peerId = Utils.generateId() this.peerId = Utils.generateId()
} }
get isOpen() {
return this.transport?.isOpen
}
async authenticate() { async authenticate() {
if (this.isOpen) return
const websocketTokenURI = this._umap.urls.get('map_websocket_auth_token', { const websocketTokenURI = this._umap.urls.get('map_websocket_auth_token', {
map_id: this._umap.id, map_id: this._umap.id,
}) })
@ -198,6 +203,7 @@ export class SyncEngine {
*/ */
onOperationMessage(payload) { onOperationMessage(payload) {
if (payload.sender === this.peerId) return if (payload.sender === this.peerId) return
debug('received operation', payload)
this._operations.storeRemoteOperations([payload]) this._operations.storeRemoteOperations([payload])
this._applyOperation(payload) this._applyOperation(payload)
} }
@ -261,7 +267,7 @@ export class SyncEngine {
* @param {*} operations The list of (encoded operations) * @param {*} operations The list of (encoded operations)
*/ */
onListOperationsResponse({ sender, message }) { onListOperationsResponse({ sender, message }) {
debug(`received operations from peer ${sender}`, message.operations) debug(`received operations list from peer ${sender}`, message.operations)
if (message.operations.length === 0) return if (message.operations.length === 0) return
@ -502,11 +508,7 @@ export class Operations {
* @return {bool} true if the two operations share the same context. * @return {bool} true if the two operations share the same context.
*/ */
static haveSameContext(local, remote) { static haveSameContext(local, remote) {
const shouldCheckKey = const shouldCheckKey = local.key !== undefined && remote.key !== undefined
local.hasOwnProperty('key') &&
remote.hasOwnProperty('key') &&
typeof local.key !== 'undefined' &&
typeof remote.key !== 'undefined'
return ( return (
Utils.deepEqual(local.subject, remote.subject) && Utils.deepEqual(local.subject, remote.subject) &&

View file

@ -54,7 +54,11 @@ export class MapUpdater extends BaseUpdater {
export class DataLayerUpdater extends BaseUpdater { export class DataLayerUpdater extends BaseUpdater {
upsert({ value }) { upsert({ value }) {
// Upsert only happens when a new datalayer is created. // Upsert only happens when a new datalayer is created.
const datalayer = this._umap.createDataLayer(value, false) try {
this.getDataLayerFromID(value.id)
} catch {
this._umap.createDataLayer(value, false)
}
} }
update({ key, metadata, value }) { update({ key, metadata, value }) {

View file

@ -76,4 +76,8 @@ export class WebSocketTransport {
this.receiver.closeRequested = true this.receiver.closeRequested = true
this.websocket.close() this.websocket.close()
} }
get isOpen() {
return this.websocket?.readyState === WebSocket.OPEN
}
} }

View file

@ -699,6 +699,7 @@ U.Editable = L.Editable.extend({
if (!event.layer.feature.hasGeom()) { if (!event.layer.feature.hasGeom()) {
event.layer.feature.del() event.layer.feature.del()
} else { } else {
event.layer.feature.onCommit()
event.layer.feature.edit() event.layer.feature.edit()
} }
}) })

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1248,6 +1248,7 @@
// 🍂event editable:vertex:deleted: VertexEvent // 🍂event editable:vertex:deleted: VertexEvent
// Fired after a vertex has been deleted by user. // Fired after a vertex has been deleted by user.
this.fireAndForward('editable:vertex:deleted', e) this.fireAndForward('editable:vertex:deleted', e)
this.onEdited()
}, },
onVertexMarkerCtrlClick: function (e) { onVertexMarkerCtrlClick: function (e) {

View file

@ -535,6 +535,11 @@ def test_create_and_sync_map(new_page, asgi_live_server, tilelayer, login, user)
expect(markersA).to_have_count(1) expect(markersA).to_have_count(1)
expect(markersB).to_have_count(1) expect(markersB).to_have_count(1)
# Make sure only one layer has been created on peer B
peerB.get_by_role("button", name="Open browser").click()
expect(peerB.locator("h5").get_by_text("Layer 1")).to_be_visible()
peerB.get_by_role("button", name="Close").click()
# Save and quit edit mode again # Save and quit edit mode again
with peerA.expect_response(re.compile("./datalayer/create/.*")): with peerA.expect_response(re.compile("./datalayer/create/.*")):
peerA.get_by_role("button", name="Save").click() peerA.get_by_role("button", name="Save").click()
@ -563,6 +568,34 @@ def test_create_and_sync_map(new_page, asgi_live_server, tilelayer, login, user)
expect(markersB).to_have_count(2) expect(markersB).to_have_count(2)
@pytest.mark.xdist_group(name="websockets")
def test_saved_datalayer_are_not_duplicated(new_page, asgi_live_server, tilelayer):
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
map.settings["properties"]["syncEnabled"] = True
map.save()
# Create one tab
peerA = new_page("Page A")
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
# Create a new datalayer
peerA.get_by_title("Manage layers").click()
peerA.get_by_title("Add a layer").click()
peerA.locator("#map").click(position={"x": 220, "y": 220})
# Save layer to the server, so now the datalayer exist on the server AND
# is still in the live operations of peer A
with peerA.expect_response(re.compile(".*/datalayer/create/.*")):
peerA.get_by_role("button", name="Save").click()
# Now load the map from another tab
peerB = new_page("Page B")
peerB.goto(peerA.url)
peerB.get_by_role("button", name="Open browser").click()
expect(peerB.get_by_text("Layer 1")).to_be_visible()
peerB.get_by_role("button", name="Edit").click()
peerA.wait_for_timeout(300) # Let the synchro roll on.
expect(peerB.get_by_text("Layer 1")).to_be_visible()
@pytest.mark.xdist_group(name="websockets") @pytest.mark.xdist_group(name="websockets")
def test_should_sync_saved_status(new_page, asgi_live_server, tilelayer): def test_should_sync_saved_status(new_page, asgi_live_server, tilelayer):
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS) map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
@ -604,3 +637,25 @@ def test_should_sync_saved_status(new_page, asgi_live_server, tilelayer):
# Peer A should not be in dirty state # Peer A should not be in dirty state
expect(peerA.locator("body")).not_to_have_class(re.compile(".*umap-is-dirty.*")) expect(peerA.locator("body")).not_to_have_class(re.compile(".*umap-is-dirty.*"))
@pytest.mark.xdist_group(name="websockets")
def test_should_sync_line_on_escape(new_page, asgi_live_server, tilelayer):
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
map.settings["properties"]["syncEnabled"] = True
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")
# Create a new marker from peerA
peerA.get_by_title("Draw a polyline").click()
peerA.locator("#map").click(position={"x": 220, "y": 220})
peerA.locator("#map").click(position={"x": 200, "y": 200})
peerA.locator("body").press("Escape")
expect(peerA.locator("path")).to_have_count(1)
expect(peerB.locator("path")).to_have_count(1)