Compare commits

...

15 commits

Author SHA1 Message Date
Yohan Boniface
d4fb92ec56
Fix duplicated content during sync (#2388)
Some checks are pending
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Here are the main fixes:

- mark a synched datalayer as loaded (so the peer does not try to get
data from the server)
- do not mark synched datalayers as dirty
- properly consume the lastKnownHLC, so to get an accurate list of
operations

fix #2219
2024-12-19 17:38:39 +01:00
Yohan Boniface
650110fe8a
feat: reconnect websocket on disconnection (#2389)
This is a port of this PR: #2235

(But it was easier to copy-paste than rebase, given the split of umap.js
and co.)
2024-12-19 17:38:02 +01:00
Yohan Boniface
36fdb8190c chore: stringify sync payload before putting it in the console
This allow to have them displayed by playwright in the python
console.

Co-authored-by: David Larlet <david@larlet.fr>
2024-12-19 17:00:30 +01:00
Yohan Boniface
56f2d3d2f9 feat: reconnect websocket on disconnection
This is a port of this PR: #2235

(But it was easier to copy-paste than rebase, given the split of umap.js
and co.)

Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
Co-authored-by: David Larlet <david@larlet.fr>
2024-12-19 16:51:10 +01:00
Yohan Boniface
80152bf4fb
fix: update star icon on star/unstar (#2387) 2024-12-19 15:23:36 +01:00
Yohan Boniface
9ddca50d21
fix: honour carriage returns in layer description (in caption panel) (#2386)
This could have been a four letters fix (adding class "text"), but I've
seen that `Caption` was still using `DomUtil` instead of `WithTemplate`,
so I made this little effort more…
2024-12-19 15:23:16 +01:00
Yohan Boniface
1996e315e4 chore: allow to create non dirty DataLayer
There are two situations where we want to create "non dirty" datalayers:

- at normal load, we create datalayers that already exist in DB
- at sync, we create datalayers that will be saved by other peer
2024-12-19 13:38:09 +01:00
Yohan Boniface
dd7641c92e chore: mark synched datalayers as "loaded"
Otherwise, when they will get the "_referenceVersion" later, they
will call the server to fetch the data (while they already have
the data, from the sync itself)
2024-12-19 13:38:09 +01:00
Yohan Boniface
6caf4c3ed1 chore: properly consumme lastKnownHLC on list operations call 2024-12-19 13:38:09 +01:00
Yohan Boniface
471abe1f1b chore: honour "sync" parameter in layer.setReferenceVersion 2024-12-19 13:38:09 +01:00
Yohan Boniface
4ea5a28f04 chore: no need to call layer.redraw in layer.render()
We already call hide/show, which is what redraw does
2024-12-19 13:38:09 +01:00
Yohan Boniface
d24f05907c chore: add failing test for #2219 2024-12-19 13:38:09 +01:00
Yohan Boniface
0bc4900b16 fix(caption): honour carriage returns in datalayer description
fix #2385
2024-12-19 11:10:57 +01:00
Yohan Boniface
0e78d58c03 chore: use WithTemplate for Caption 2024-12-19 11:10:57 +01:00
Yohan Boniface
f93d0b5ca7 fix: update star icon on star/unstar 2024-12-19 10:44:32 +01:00
13 changed files with 302 additions and 122 deletions

View file

@ -32,6 +32,10 @@
background-color: var(--color-lightCyan); background-color: var(--color-lightCyan);
color: var(--color-dark); color: var(--color-dark);
} }
.dark .off.connected-peers {
background-color: var(--color-lightGray);
color: var(--color-darkGray);
}
.leaflet-container .edit-cancel, .leaflet-container .edit-cancel,
.leaflet-container .edit-disable, .leaflet-container .edit-disable,

View file

@ -1,11 +1,39 @@
import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from './i18n.js' import { translate } from './i18n.js'
import * as Utils from './utils.js' import * as Utils from './utils.js'
export default class Caption { const TEMPLATE = `
<div class="umap-caption">
<hgroup>
<h3>
<i class="icon icon-16 icon-caption icon-block"></i>
<span class="map-name" data-ref="name"></span>
</h3>
<h4 data-ref="author"></h4>
</hgroup>
<div class="umap-map-description text" data-ref="description"></div>
<div class="datalayer-container" data-ref="datalayersContainer"></div>
<div class="credits-container">
<details>
<summary>${translate('Credits')}</summary>
<fieldset>
<h5>${translate('User content credits')}</h5>
<p data-ref="userCredits"></p>
<p data-ref="licence">${translate('Map user content has been published under licence')} <a href="#" data-ref="licenceLink"></a></p>
<p data-ref="noLicence">${translate('No licence has been set')}</p>
<h5>${translate('Map background credits')}</h5>
<p><strong data-ref="bgName"></strong> <span data-ref="bgAttribution"></span></p>
<p data-ref="poweredBy"></p>
</fieldset>
</details>
</div>
</div>`
export default class Caption extends Utils.WithTemplate {
constructor(umap, leafletMap) { constructor(umap, leafletMap) {
super()
this._umap = umap this._umap = umap
this._leafletMap = leafletMap this._leafletMap = leafletMap
this.loadTemplate(TEMPLATE)
} }
isOpen() { isOpen() {
@ -18,94 +46,67 @@ export default class Caption {
} }
open() { open() {
const container = DomUtil.create('div', 'umap-caption') this.elements.name.textContent = this._umap.getDisplayName()
const hgroup = DomUtil.element({ tagName: 'hgroup', parent: container }) this.elements.author.innerHTML = ''
DomUtil.createTitle( this._umap.addAuthorLink(this.elements.author)
hgroup,
this._umap.getDisplayName(),
'icon-caption icon-block',
'map-name'
)
const title = Utils.loadTemplate('<h4></h4>')
hgroup.appendChild(title)
this._umap.addAuthorLink(title)
if (this._umap.properties.description) { if (this._umap.properties.description) {
const description = DomUtil.element({ this.elements.description.innerHTML = Utils.toHTML(
tagName: 'div', this._umap.properties.description
className: 'umap-map-description text', )
safeHTML: Utils.toHTML(this._umap.properties.description), } else {
parent: container, this.elements.description.hidden = true
})
} }
const datalayerContainer = DomUtil.create('div', 'datalayer-container', container) this.elements.datalayersContainer.innerHTML = ''
this._umap.eachDataLayerReverse((datalayer) => this._umap.eachDataLayerReverse((datalayer) =>
this.addDataLayer(datalayer, datalayerContainer) this.addDataLayer(datalayer, this.elements.datalayersContainer)
) )
const creditsContainer = DomUtil.create('div', 'credits-container', container) this.addCredits()
this.addCredits(creditsContainer) this._umap.panel.open({ content: this.element }).then(() => {
this._umap.panel.open({ content: container }).then(() => {
// Create the legend when the panel is actually on the DOM // Create the legend when the panel is actually on the DOM
this._umap.eachDataLayerReverse((datalayer) => datalayer.renderLegend()) this._umap.eachDataLayerReverse((datalayer) => datalayer.renderLegend())
}) })
} }
addDataLayer(datalayer, container) { addDataLayer(datalayer, parent) {
if (!datalayer.options.inCaption) return if (!datalayer.options.inCaption) return
const p = DomUtil.create('p', `caption-item ${datalayer.cssId}`, container) const template = `
const legend = DomUtil.create('span', 'datalayer-legend', p) <p class="caption-item ${datalayer.cssId}">
const headline = DomUtil.create('strong', '', p) <span class="datalayer-legend"></span>
<strong data-ref="toolbox"></strong>
<span class="text" data-ref="description"></span>
</p>`
const [element, { toolbox, description }] = Utils.loadTemplateWithRefs(template)
if (datalayer.options.description) { if (datalayer.options.description) {
DomUtil.element({ description.innerHTML = Utils.toHTML(datalayer.options.description)
tagName: 'span', } else {
parent: p, description.hidden = true
safeHTML: Utils.toHTML(datalayer.options.description),
})
} }
datalayer.renderToolbox(headline) datalayer.renderToolbox(toolbox)
DomUtil.add('span', '', headline, `${datalayer.options.name} `) parent.appendChild(element)
// Use textContent for security
const name = Utils.loadTemplate('<span></span>')
name.textContent = datalayer.options.name
toolbox.appendChild(name)
} }
addCredits(container) { addCredits() {
const credits = DomUtil.createFieldset(container, translate('Credits'))
let title = DomUtil.add('h5', '', credits, translate('User content credits'))
if (this._umap.properties.shortCredit || this._umap.properties.longCredit) { if (this._umap.properties.shortCredit || this._umap.properties.longCredit) {
DomUtil.element({ this.elements.userCredits.innerHTML = Utils.toHTML(
tagName: 'p', this._umap.properties.longCredit || this._umap.properties.shortCredit
parent: credits,
safeHTML: Utils.toHTML(
this._umap.properties.longCredit || this._umap.properties.shortCredit
),
})
}
if (this._umap.properties.licence) {
const licence = DomUtil.add(
'p',
'',
credits,
`${translate('Map user content has been published under licence')} `
)
DomUtil.createLink(
'',
licence,
this._umap.properties.licence.name,
this._umap.properties.licence.url
) )
} else { } else {
DomUtil.add('p', '', credits, translate('No licence has been set')) this.elements.userCredits.hidden = true
} }
title = DomUtil.create('h5', '', credits) if (this._umap.properties.licence) {
title.textContent = translate('Map background credits') this.elements.licenceLink.href = this._umap.properties.licence.url
const tilelayerCredit = DomUtil.create('p', '', credits) this.elements.licenceLink.textContent = this._umap.properties.licence.name
DomUtil.element({ this.elements.noLicence.hidden = true
tagName: 'strong', } else {
parent: tilelayerCredit, this.elements.licence.hidden = true
textContent: `${this._leafletMap.selectedTilelayer.options.name} `, }
}) this.elements.bgName.textContent = this._leafletMap.selectedTilelayer.options.name
DomUtil.element({ this.elements.bgAttribution.innerHTML =
tagName: 'span', this._leafletMap.selectedTilelayer.getAttribution()
parent: tilelayerCredit,
safeHTML: this._leafletMap.selectedTilelayer.getAttribution(),
})
const urls = { const urls = {
leaflet: 'http://leafletjs.com', leaflet: 'http://leafletjs.com',
django: 'https://www.djangoproject.com', django: 'https://www.djangoproject.com',
@ -113,7 +114,7 @@ export default class Caption {
changelog: 'https://docs.umap-project.org/en/master/changelog/', changelog: 'https://docs.umap-project.org/en/master/changelog/',
version: this._umap.properties.umap_version, version: this._umap.properties.umap_version,
} }
const creditHTML = translate( this.elements.poweredBy.innerHTML = translate(
` `
Powered by <a href="{leaflet}">Leaflet</a> and Powered by <a href="{leaflet}">Leaflet</a> and
<a href="{django}">Django</a>, <a href="{django}">Django</a>,
@ -122,6 +123,5 @@ export default class Caption {
`, `,
urls urls
) )
DomUtil.element({ tagName: 'p', innerHTML: creditHTML, parent: credits })
} }
} }

View file

@ -20,7 +20,7 @@ import loadPopup from '../rendering/popup.js'
class Feature { class Feature {
constructor(umap, datalayer, geojson = {}, id = null) { constructor(umap, datalayer, geojson = {}, id = null) {
this._umap = umap this._umap = umap
this.sync = umap.sync_engine.proxy(this) this.sync = umap.syncEngine.proxy(this)
this._marked_for_deletion = false this._marked_for_deletion = false
this._isDirty = false this._isDirty = false
this._ui = null this._ui = null

View file

@ -41,7 +41,7 @@ export class DataLayer extends ServerStored {
constructor(umap, leafletMap, data = {}) { constructor(umap, leafletMap, data = {}) {
super() super()
this._umap = umap this._umap = umap
this.sync = umap.sync_engine.proxy(this) this.sync = umap.syncEngine.proxy(this)
this._index = Array() this._index = Array()
this._features = {} this._features = {}
this._geojson = null this._geojson = null
@ -88,7 +88,6 @@ export class DataLayer extends ServerStored {
if (!this.createdOnServer) { if (!this.createdOnServer) {
if (this.showAtLoad()) this.show() if (this.showAtLoad()) this.show()
this.isDirty = true
} }
// Only layers that are displayed on load must be hidden/shown // Only layers that are displayed on load must be hidden/shown
@ -151,7 +150,6 @@ export class DataLayer extends ServerStored {
for (const field of fields) { for (const field of fields) {
this.layer.onEdit(field, builder) this.layer.onEdit(field, builder)
} }
this.redraw()
this.show() this.show()
break break
case 'remote-data': case 'remote-data':
@ -592,7 +590,7 @@ export class DataLayer extends ServerStored {
options.name = translate('Clone of {name}', { name: this.options.name }) options.name = translate('Clone of {name}', { name: this.options.name })
delete options.id delete options.id
const geojson = Utils.CopyJSON(this._geojson) const geojson = Utils.CopyJSON(this._geojson)
const datalayer = this._umap.createDataLayer(options) const datalayer = this._umap.createDirtyDataLayer(options)
datalayer.fromGeoJSON(geojson) datalayer.fromGeoJSON(geojson)
return datalayer return datalayer
} }
@ -1066,7 +1064,7 @@ export class DataLayer extends ServerStored {
setReferenceVersion({ response, sync }) { setReferenceVersion({ response, sync }) {
this._referenceVersion = response.headers.get('X-Datalayer-Version') this._referenceVersion = response.headers.get('X-Datalayer-Version')
this.sync.update('_referenceVersion', this._referenceVersion) if (sync) this.sync.update('_referenceVersion', this._referenceVersion)
} }
async save() { async save() {

View file

@ -161,7 +161,7 @@ export default class Importer extends Utils.WithTemplate {
get layer() { get layer() {
return ( return (
this._umap.datalayers[this.layerId] || this._umap.datalayers[this.layerId] ||
this._umap.createDataLayer({ name: this.layerName }) this._umap.createDirtyDataLayer({ name: this.layerName })
) )
} }

View file

@ -3,6 +3,12 @@ import { HybridLogicalClock } from './hlc.js'
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
import { WebSocketTransport } from './websocket.js' import { WebSocketTransport } from './websocket.js'
// Start reconnecting after 2 seconds, then double the delay each time
// maxing out at 32 seconds.
const RECONNECT_DELAY = 2000
const RECONNECT_DELAY_FACTOR = 2
const MAX_RECONNECT_DELAY = 32000
/** /**
* The syncEngine exposes an API to sync messages between peers over the network. * The syncEngine exposes an API to sync messages between peers over the network.
* *
@ -42,32 +48,65 @@ import { WebSocketTransport } from './websocket.js'
* ``` * ```
*/ */
export class SyncEngine { export class SyncEngine {
constructor(map) { constructor(umap) {
this._umap = umap
this.updaters = { this.updaters = {
map: new MapUpdater(map), map: new MapUpdater(umap),
feature: new FeatureUpdater(map), feature: new FeatureUpdater(umap),
datalayer: new DataLayerUpdater(map), datalayer: new DataLayerUpdater(umap),
} }
this.transport = undefined this.transport = undefined
this._operations = new Operations() this._operations = new Operations()
this._reconnectTimeout = null
this._reconnectDelay = RECONNECT_DELAY
this.websocketConnected = false
} }
async authenticate(tokenURI, webSocketURI, server) { async authenticate() {
const [response, _, error] = await server.get(tokenURI) const websocketTokenURI = this._umap.urls.get('map_websocket_auth_token', {
map_id: this._umap.id,
})
const [response, _, error] = await this._umap.server.get(websocketTokenURI)
if (!error) { if (!error) {
this.start(webSocketURI, response.token) this.start(response.token)
} }
} }
start(webSocketURI, authToken) { start(authToken) {
this.transport = new WebSocketTransport(webSocketURI, authToken, this) this.transport = new WebSocketTransport(
this._umap.properties.websocketURI,
authToken,
this
)
} }
stop() { stop() {
if (this.transport) this.transport.close() if (this.transport) {
this.transport.close()
}
this.transport = undefined this.transport = undefined
} }
onConnection() {
this._reconnectTimeout = null
this._reconnectDelay = RECONNECT_DELAY
this.websocketConnected = true
this.updaters.map.update({ key: 'numberOfConnectedPeers' })
}
reconnect() {
this.websocketConnected = false
this.updaters.map.update({ key: 'numberOfConnectedPeers' })
this._reconnectTimeout = setTimeout(() => {
if (this._reconnectDelay < MAX_RECONNECT_DELAY) {
this._reconnectDelay = this._reconnectDelay * RECONNECT_DELAY_FACTOR
}
this.authenticate()
}, this._reconnectDelay)
}
upsert(subject, metadata, value) { upsert(subject, metadata, value) {
this._send({ verb: 'upsert', subject, metadata, value }) this._send({ verb: 'upsert', subject, metadata, value })
} }
@ -183,9 +222,13 @@ export class SyncEngine {
* @param {string} payload.sender the uuid of the requesting peer * @param {string} payload.sender the uuid of the requesting peer
* @param {string} payload.latestKnownHLC the latest known HLC of the requesting peer * @param {string} payload.latestKnownHLC the latest known HLC of the requesting peer
*/ */
onListOperationsRequest({ sender, lastKnownHLC }) { onListOperationsRequest({ sender, message }) {
debug(
`received operations request from peer ${sender} (since ${message.lastKnownHLC})`
)
this.sendToPeer(sender, 'ListOperationsResponse', { this.sendToPeer(sender, 'ListOperationsResponse', {
operations: this._operations.getOperationsSince(lastKnownHLC), operations: this._operations.getOperationsSince(message.lastKnownHLC),
}) })
} }
@ -446,5 +489,5 @@ export class Operations {
} }
function debug(...args) { function debug(...args) {
console.debug('SYNC ⇆', ...args) console.debug('SYNC ⇆', ...args.map((x) => JSON.stringify(x)))
} }

View file

@ -54,7 +54,10 @@ 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.
this._umap.createDataLayer(value, false) const datalayer = this._umap.createDataLayer(value, false)
// Prevent the layer to get data from the server, as it will get it
// from the sync.
datalayer._loaded = true
} }
update({ key, metadata, value }) { update({ key, metadata, value }) {

View file

@ -1,15 +1,59 @@
const PONG_TIMEOUT = 5000
const PING_INTERVAL = 30000
const FIRST_CONNECTION_TIMEOUT = 2000
export class WebSocketTransport { export class WebSocketTransport {
constructor(webSocketURI, authToken, messagesReceiver) { constructor(webSocketURI, authToken, messagesReceiver) {
this.receiver = messagesReceiver
this.closeRequested = false
this.websocket = new WebSocket(webSocketURI) this.websocket = new WebSocket(webSocketURI)
this.websocket.onopen = () => { this.websocket.onopen = () => {
this.send('JoinRequest', { token: authToken }) this.send('JoinRequest', { token: authToken })
this.receiver.onConnection()
} }
this.websocket.addEventListener('message', this.onMessage.bind(this)) this.websocket.addEventListener('message', this.onMessage.bind(this))
this.receiver = messagesReceiver this.websocket.onclose = () => {
console.log('websocket closed')
if (!this.closeRequested) {
console.log('Not requested, reconnecting...')
this.receiver.reconnect()
}
}
this.ensureOpen = setInterval(() => {
if (this.websocket.readyState !== WebSocket.OPEN) {
this.websocket.close()
clearInterval(this.ensureOpen)
}
}, FIRST_CONNECTION_TIMEOUT)
// To ensure the connection is still alive, we send ping and expect pong back.
// Websocket provides a `ping` method to keep the connection alive, but it's
// unfortunately not possible to access it from the WebSocket object.
// See https://making.close.com/posts/reliable-websockets/ for more details.
this.pingInterval = setInterval(() => {
if (this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send('ping')
this.pongReceived = false
setTimeout(() => {
if (!this.pongReceived) {
console.warn('No pong received, reconnecting...')
this.websocket.close()
clearInterval(this.pingInterval)
}
}, PONG_TIMEOUT)
}
}, PING_INTERVAL)
} }
onMessage(wsMessage) { onMessage(wsMessage) {
this.receiver.receive(JSON.parse(wsMessage.data)) if (wsMessage.data === 'pong') {
this.pongReceived = true
} else {
this.receiver.receive(JSON.parse(wsMessage.data))
}
} }
send(kind, payload) { send(kind, payload) {
@ -20,6 +64,7 @@ export class WebSocketTransport {
} }
close() { close() {
this.closeRequested = true
this.websocket.close() this.websocket.close()
} }
} }

View file

@ -61,8 +61,6 @@ export default class Umap extends ServerStored {
) )
this.searchParams = new URLSearchParams(window.location.search) this.searchParams = new URLSearchParams(window.location.search)
this.sync_engine = new SyncEngine(this)
this.sync = this.sync_engine.proxy(this)
// Locale name (pt_PT, en_US…) // Locale name (pt_PT, en_US…)
// To be used for Django localization // To be used for Django localization
if (geojson.properties.locale) setLocale(geojson.properties.locale) if (geojson.properties.locale) setLocale(geojson.properties.locale)
@ -124,6 +122,9 @@ export default class Umap extends ServerStored {
this.share = new Share(this) this.share = new Share(this)
this.rules = new Rules(this) this.rules = new Rules(this)
this.syncEngine = new SyncEngine(this)
this.sync = this.syncEngine.proxy(this)
if (this.hasEditMode()) { if (this.hasEditMode()) {
this.editPanel = new EditPanel(this, this._leafletMap) this.editPanel = new EditPanel(this, this._leafletMap)
this.fullPanel = new FullPanel(this, this._leafletMap) this.fullPanel = new FullPanel(this, this._leafletMap)
@ -323,14 +324,14 @@ export default class Umap extends ServerStored {
dataUrl = decodeURIComponent(dataUrl) dataUrl = decodeURIComponent(dataUrl)
dataUrl = this.renderUrl(dataUrl) dataUrl = this.renderUrl(dataUrl)
dataUrl = this.proxyUrl(dataUrl) dataUrl = this.proxyUrl(dataUrl)
const datalayer = this.createDataLayer() const datalayer = this.createDirtyDataLayer()
await datalayer await datalayer
.importFromUrl(dataUrl, dataFormat) .importFromUrl(dataUrl, dataFormat)
.then(() => datalayer.zoomTo()) .then(() => datalayer.zoomTo())
} }
} else if (data) { } else if (data) {
data = decodeURIComponent(data) data = decodeURIComponent(data)
const datalayer = this.createDataLayer() const datalayer = this.createDirtyDataLayer()
await datalayer.importRaw(data, dataFormat).then(() => datalayer.zoomTo()) await datalayer.importRaw(data, dataFormat).then(() => datalayer.zoomTo())
} }
} }
@ -598,8 +599,14 @@ export default class Umap extends ServerStored {
return datalayer return datalayer
} }
createDirtyDataLayer(options) {
const datalayer = this.createDataLayer(options, true)
datalayer.isDirty = true
return datalayer
}
newDataLayer() { newDataLayer() {
const datalayer = this.createDataLayer({}) const datalayer = this.createDirtyDataLayer({})
datalayer.edit() datalayer.edit()
} }
@ -1257,18 +1264,13 @@ export default class Umap extends ServerStored {
} }
async initSyncEngine() { async initSyncEngine() {
// this.properties.websocketEnabled is set by the server admin
if (this.properties.websocketEnabled === false) return if (this.properties.websocketEnabled === false) return
// this.properties.syncEnabled is set by the user in the map settings
if (this.properties.syncEnabled !== true) { if (this.properties.syncEnabled !== true) {
this.sync.stop() this.sync.stop()
} else { } else {
const ws_token_uri = this.urls.get('map_websocket_auth_token', { await this.sync.authenticate()
map_id: this.id,
})
await this.sync.authenticate(
ws_token_uri,
this.properties.websocketURI,
this.server
)
} }
} }
@ -1343,7 +1345,17 @@ export default class Umap extends ServerStored {
}, },
numberOfConnectedPeers: () => { numberOfConnectedPeers: () => {
Utils.eachElement('.connected-peers span', (el) => { Utils.eachElement('.connected-peers span', (el) => {
el.textContent = this.sync.getNumberOfConnectedPeers() if (this.sync.websocketConnected) {
el.textContent = this.sync.getNumberOfConnectedPeers()
} else {
el.textContent = translate('Disconnected')
}
el.parentElement.classList.toggle('off', !this.sync.websocketConnected)
})
},
'properties.starred': () => {
Utils.eachElement('.map-star', (el) => {
el.classList.toggle('starred', this.properties.starred)
}) })
}, },
} }
@ -1383,7 +1395,7 @@ export default class Umap extends ServerStored {
fallback.show() fallback.show()
return fallback return fallback
} }
return this.createDataLayer() return this.createDirtyDataLayer()
} }
findDataLayer(method, context) { findDataLayer(method, context) {
@ -1531,7 +1543,7 @@ export default class Umap extends ServerStored {
? translate('Map has been starred') ? translate('Map has been starred')
: translate('Map has been unstarred') : translate('Map has been unstarred')
) )
this.render(['starred']) this.render(['properties.starred'])
} }
processFileToImport(file, layer, type) { processFileToImport(file, layer, type) {
@ -1547,7 +1559,7 @@ export default class Umap extends ServerStored {
if (type === 'umap') { if (type === 'umap') {
this.importUmapFile(file, 'umap') this.importUmapFile(file, 'umap')
} else { } else {
if (!layer) layer = this.createDataLayer({ name: file.name }) if (!layer) layer = this.createDirtyDataLayer({ name: file.name })
layer.importFromFile(file, type) layer.importFromFile(file, type)
} }
} }
@ -1573,7 +1585,7 @@ export default class Umap extends ServerStored {
delete geojson._storage delete geojson._storage
} }
delete geojson._umap_options?.id // Never trust an id at this stage delete geojson._umap_options?.id // Never trust an id at this stage
const dataLayer = this.createDataLayer(geojson._umap_options) const dataLayer = this.createDirtyDataLayer(geojson._umap_options)
dataLayer.fromUmapGeoJSON(geojson) dataLayer.fromUmapGeoJSON(geojson)
} }

View file

@ -541,11 +541,7 @@ U.StarControl = L.Control.Button.extend({
options: { options: {
position: 'topleft', position: 'topleft',
title: L._('Star this map'), title: L._('Star this map'),
}, className: 'leaflet-control-star map-star umap-control',
getClassName: function () {
const status = this._umap.properties.starred ? ' starred' : ''
return `leaflet-control-star umap-control${status}`
}, },
onClick: function () { onClick: function () {

View file

@ -8,8 +8,11 @@ import { MapUpdater } from '../js/modules/sync/updaters.js'
import { SyncEngine, Operations } from '../js/modules/sync/engine.js' import { SyncEngine, Operations } from '../js/modules/sync/engine.js'
describe('SyncEngine', () => { describe('SyncEngine', () => {
const websocketTokenURI = 'http://localhost:8000/api/v1/maps/1/websocket_auth_token/'
const websocketURI = 'ws://localhost:8000/ws/maps/1/'
it('should initialize methods even before start', () => { it('should initialize methods even before start', () => {
const engine = new SyncEngine({}) const engine = new SyncEngine({}, websocketTokenURI, websocketURI)
engine.upsert() engine.upsert()
engine.update() engine.update()
engine.delete() engine.delete()

View file

@ -39,6 +39,9 @@ def test_websocket_connection_can_sync_markers(
a_map_el.click(position={"x": 220, "y": 220}) a_map_el.click(position={"x": 220, "y": 220})
expect(a_marker_pane).to_have_count(1) expect(a_marker_pane).to_have_count(1)
expect(b_marker_pane).to_have_count(1) expect(b_marker_pane).to_have_count(1)
# Peer B should not be in state dirty
expect(peerB.get_by_role("button", name="View")).to_be_visible()
expect(peerB.get_by_role("button", name="Cancel edits")).to_be_hidden()
peerA.locator("body").type("Synced name") peerA.locator("body").type("Synced name")
peerA.locator("body").press("Escape") peerA.locator("body").press("Escape")
@ -415,3 +418,69 @@ def test_should_sync_datalayers(new_page, live_server, websocket_server, tilelay
peerA.get_by_role("button", name="Save").click() peerA.get_by_role("button", name="Save").click()
assert DataLayer.objects.count() == 2 assert DataLayer.objects.count() == 2
@pytest.mark.xdist_group(name="websockets")
def test_create_and_sync_map(
new_page, live_server, websocket_server, tilelayer, login, user
):
# Create a syncable map with peerA
peerA = login(user, prefix="Page A")
peerA.goto(f"{live_server.url}/en/map/new/")
with peerA.expect_response(re.compile("./map/create/.*")):
peerA.get_by_role("button", name="Save Draft").click()
peerA.get_by_role("link", name="Map advanced properties").click()
peerA.get_by_text("Real-time collaboration", exact=True).click()
peerA.get_by_text("Enable real-time").click()
peerA.get_by_role("link", name="Update permissions and editors").click()
peerA.locator('select[name="share_status"]').select_option(str(Map.PUBLIC))
with peerA.expect_response(re.compile("./update/settings/.*")):
peerA.get_by_role("button", name="Save").click()
expect(peerA.get_by_role("button", name="Cancel edits")).to_be_hidden()
# Quit edit mode
peerA.get_by_role("button", name="View").click()
# Open map and go to edit mode with peer B
peerB = new_page("Page B")
peerB.goto(peerA.url)
peerB.get_by_role("button", name="Edit").click()
# Create a marker from peerA
markersA = peerA.locator(".leaflet-marker-pane > div")
markersB = peerB.locator(".leaflet-marker-pane > div")
expect(markersA).to_have_count(0)
expect(markersB).to_have_count(0)
# Add a marker from peer A
peerA.get_by_role("button", name="Edit").click()
peerA.get_by_title("Draw a marker").click()
peerA.locator("#map").click(position={"x": 220, "y": 220})
expect(markersA).to_have_count(1)
expect(markersB).to_have_count(1)
# Save and quit edit mode again
with peerA.expect_response(re.compile("./datalayer/create/.*")):
peerA.get_by_role("button", name="Save").click()
peerA.get_by_role("button", name="View").click()
expect(markersA).to_have_count(1)
expect(markersB).to_have_count(1)
peerA.wait_for_timeout(500)
expect(markersA).to_have_count(1)
expect(markersB).to_have_count(1)
# Peer B should not be in state dirty
expect(peerB.get_by_role("button", name="View")).to_be_visible()
expect(peerB.get_by_role("button", name="Cancel edits")).to_be_hidden()
# Add a marker from peer B
peerB.get_by_title("Draw a marker").click()
peerB.locator("#map").click(position={"x": 200, "y": 200})
expect(markersB).to_have_count(2)
expect(markersA).to_have_count(1)
with peerB.expect_response(re.compile("./datalayer/update/.*")):
peerB.get_by_role("button", name="Save").click()
expect(markersB).to_have_count(2)
expect(markersA).to_have_count(1)
peerA.get_by_role("button", name="Edit").click()
expect(markersA).to_have_count(2)
expect(markersB).to_have_count(2)

View file

@ -126,6 +126,10 @@ async def join_and_listen(
try: try:
async for raw_message in websocket: async for raw_message in websocket:
if raw_message == "ping":
await websocket.send("pong")
continue
# recompute the peers list at the time of message-sending. # recompute the peers list at the time of message-sending.
# as doing so beforehand would miss new connections # as doing so beforehand would miss new connections
other_peers = connections.get_other_peers(websocket) other_peers = connections.get_other_peers(websocket)
@ -192,4 +196,7 @@ def run(host: str, port: int):
logging.debug(f"Waiting for connections on {host}:{port}") logging.debug(f"Waiting for connections on {host}:{port}")
await asyncio.Future() # run forever await asyncio.Future() # run forever
asyncio.run(_serve()) try:
asyncio.run(_serve())
except KeyboardInterrupt:
print("Closing WebSocket server")