Compare commits

...

24 commits

Author SHA1 Message Date
Yohan Boniface
053464c64b
Merge f105336d48 into 4dd50690b1 2025-02-11 12:17:03 +01:00
Yohan Boniface
4dd50690b1
chore: bump ruff from 0.9.3 to 0.9.6 (#2499) 2025-02-11 12:16:20 +01:00
Yohan Boniface
f0e87bab83
Fix broken showLabel=null (on hover) (#2490)
Broken since c27e675152
2025-02-11 12:14:52 +01:00
dependabot[bot]
238de28098
chore: bump ruff from 0.9.3 to 0.9.6
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.3 to 0.9.6.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.9.3...0.9.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-11 10:58:25 +00:00
Yohan Boniface
c5467e42d5
chore: bump mkdocs-material from 9.5.50 to 9.6.3 (#2496) 2025-02-11 11:57:17 +01:00
Yohan Boniface
14c388b063
chore: bump pytest-django from 4.9.0 to 4.10.0 (#2495) 2025-02-11 11:56:39 +01:00
Yohan Boniface
e3c5acd52b
chore: bump moto[s3] from 5.0.27 to 5.0.28 (#2498) 2025-02-11 11:56:08 +01:00
Yohan Boniface
c044afb43d
chore: bump django from 5.1.5 to 5.1.6 (#2497) 2025-02-11 11:55:36 +01:00
Yohan Boniface
c5417178c4 fix: "null" value was not honoured in showLabel field 2025-02-11 11:48:01 +01:00
dependabot[bot]
cfef25748b
chore: bump moto[s3] from 5.0.27 to 5.0.28
Bumps [moto[s3]](https://github.com/getmoto/moto) from 5.0.27 to 5.0.28.
- [Release notes](https://github.com/getmoto/moto/releases)
- [Changelog](https://github.com/getmoto/moto/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getmoto/moto/compare/5.0.27...5.0.28)

---
updated-dependencies:
- dependency-name: moto[s3]
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 17:06:37 +00:00
dependabot[bot]
6c835622c0
chore: bump django from 5.1.5 to 5.1.6
Bumps [django](https://github.com/django/django) from 5.1.5 to 5.1.6.
- [Commits](https://github.com/django/django/compare/5.1.5...5.1.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 17:06:31 +00:00
dependabot[bot]
62e23ac49b
chore: bump mkdocs-material from 9.5.50 to 9.6.3
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.50 to 9.6.3.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.50...9.6.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 17:06:26 +00:00
dependabot[bot]
a78cdd227e
chore: bump pytest-django from 4.9.0 to 4.10.0
Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.9.0 to 4.10.0.
- [Release notes](https://github.com/pytest-dev/pytest-django/releases)
- [Changelog](https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.9.0...v4.10.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 17:06:20 +00:00
Yohan Boniface
f105336d48
chore: read datalayer metadata from file if missing in DB (#2494)
When we introduced the DataLayer.settings property, we did not run a
migration for existing datalayers (because there are millions in the
French server).
Now that we simplified the `DataLayer.isLoaded()` logic in the client,
we want to be sure that created DataLayer on the client have full
metadata.
2025-02-10 16:59:49 +01:00
Yohan Boniface
9b01a4b77a chore: read datalayer metadata from file if missing in DB
When we introduced the DataLayer.settings property, we did not run
a migration for existing datalayers (because there are millions in the
French server).
Now that we simplified the `DataLayer.isLoaded()` logic in the client,
we want to be sure that created DataLayer on the client have full
metadata.

Co-authored-by: David Larlet <david@larlet.fr>
2025-02-10 16:56:15 +01:00
Yohan Boniface
e827e77bb9
chore: refactor layer.isLoaded() (#2492) 2025-02-10 16:39:15 +01:00
Yohan Boniface
a8e18c167c chore: no need to reset datalayer._needsFetch after a save
It is reset only after loading the layer data.

Co-authored-by: David Larlet <david@larlet.fr>
2025-02-10 16:30:00 +01:00
Yohan Boniface
175e27a535 chore: remove DataLayer._dataloaded in favor of isLoaded()
At the end, we only need two states: has this datalayer loaded the
data it should load ? yes / no.
Whether it local or remote data should not be a matter.
2025-02-10 15:44:41 +01:00
Yohan Boniface
ba0582deb1 chore: refactor layer.isLoaded() 2025-02-10 13:13:56 +01:00
Yohan Boniface
64068af393 fix: do not save "null" instead of null for showLabel 2025-02-07 21:56:02 +01:00
Yohan Boniface
6793a6bdc7 fix: do not modify schema while iterating on it 2025-02-07 21:50:11 +01:00
Yohan Boniface
eca7ad4772 fixup: prevent to reload a datalayer after other peer has saved it
The scenario to reproduce is:
- peer A creates a datalayer
- peer B add a marker on that datalayer
- peer B saves the datalayer

Before this fix, after the save, peer A would get a new _referenceVersion
for this datalayer, and the render method would make a hide/show,
which would refetch the data from the server, duplicating it.
So forcing the _loaded to be true in this situation prevent this.

We may want to rework the "_loaded" pattern, maybe with the server
returning in a datalayer metadata the length of the data it get for
this given datalayer, so the client knows if it is worth getting
data for a layer when itself (the client) does not have any.

Co-authored-by: David Larlet <david@larlet.fr>
2025-02-07 17:58:27 +01:00
Yohan Boniface
a172c4abea fixup: do not try to sync saved state when not in sync mode
Co-authored-by: David Larlet <david@larlet.fr>
2025-02-07 17:53:48 +01:00
Yohan Boniface
b8db07a4ce chore: sync save state
When a peer save the map, other peers in dirty state should not need
to save the map anymore.

That implementation uses the lastKnownHLC as a reference, but it changes
all dirty states at once. Another impementation could be to have each
object sync its dirty state, but in this case we do not have a HLC per
object as reference, and it also creates more messages.

Co-authored-by: David Larlet <david@larlet.fr>
2025-02-07 16:47:45 +01:00
18 changed files with 184 additions and 75 deletions

View file

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

View file

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

View file

@ -28,7 +28,7 @@ classifiers = [
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
] ]
dependencies = [ dependencies = [
"Django==5.1.5", "Django==5.1.6",
"django-agnocomplete==2.2.0", "django-agnocomplete==2.2.0",
"django-environ==0.12.0", "django-environ==0.12.0",
"django-probes==1.7.0", "django-probes==1.7.0",
@ -44,10 +44,10 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"hatch==1.14.0", "hatch==1.14.0",
"ruff==0.9.3", "ruff==0.9.6",
"djlint==1.36.4", "djlint==1.36.4",
"mkdocs==1.6.1", "mkdocs==1.6.1",
"mkdocs-material==9.5.50", "mkdocs-material==9.6.3",
"mkdocs-static-i18n==1.3.0", "mkdocs-static-i18n==1.3.0",
"vermin==1.6.0", "vermin==1.6.0",
"pymdown-extensions==10.14.3", "pymdown-extensions==10.14.3",
@ -58,11 +58,11 @@ test = [
"factory-boy==3.3.3", "factory-boy==3.3.3",
"playwright>=1.39", "playwright>=1.39",
"pytest==8.3.4", "pytest==8.3.4",
"pytest-django==4.9.0", "pytest-django==4.10.0",
"pytest-playwright==0.7.0", "pytest-playwright==0.7.0",
"pytest-rerunfailures==15.0", "pytest-rerunfailures==15.0",
"pytest-xdist>=3.5.0,<4", "pytest-xdist>=3.5.0,<4",
"moto[s3]==5.0.27" "moto[s3]==5.0.28"
] ]
docker = [ docker = [
"uwsgi==2.0.28", "uwsgi==2.0.28",

View file

@ -523,17 +523,27 @@ class DataLayer(NamedModel):
def metadata(self, request=None): def metadata(self, request=None):
# Retrocompat: minimal settings for maps not saved after settings property # Retrocompat: minimal settings for maps not saved after settings property
# has been introduced # has been introduced
obj = self.settings or { metadata = self.settings
if not metadata:
# Fallback to file for old datalayers.
data = json.loads(self.geojson.read().decode())
metadata = data.get("_umap_options")
if not metadata:
metadata = {
"name": self.name, "name": self.name,
"displayOnLoad": self.display_on_load, "displayOnLoad": self.display_on_load,
} }
# Save it to prevent file reading at each map load.
self.settings = metadata
# Do not update the modified_at.
self.save(update_fields=["settings"])
if self.old_id: if self.old_id:
obj["old_id"] = self.old_id metadata["old_id"] = self.old_id
obj["id"] = self.pk metadata["id"] = self.pk
obj["permissions"] = {"edit_status": self.edit_status} metadata["permissions"] = {"edit_status": self.edit_status}
obj["editMode"] = "advanced" if self.can_edit(request) else "disabled" metadata["editMode"] = "advanced" if self.can_edit(request) else "disabled"
obj["_referenceVersion"] = self.reference_version metadata["_referenceVersion"] = self.reference_version
return obj return metadata
def clone(self, map_inst=None): def clone(self, map_inst=None):
new = self.__class__.objects.get(pk=self.pk) new = self.__class__.objects.get(pk=self.pk)

View file

@ -45,8 +45,6 @@ export class DataLayer extends ServerStored {
this._features = {} this._features = {}
this._geojson = null this._geojson = null
this._propertiesIndex = [] this._propertiesIndex = []
this._loaded = false // Are layer metadata loaded
this._dataloaded = false // Are layer data loaded
this._leafletMap = leafletMap this._leafletMap = leafletMap
this.parentPane = this._leafletMap.getPane('overlayPane') this.parentPane = this._leafletMap.getPane('overlayPane')
@ -85,6 +83,7 @@ export class DataLayer extends ServerStored {
this.connectToMap() this.connectToMap()
this.permissions = new DataLayerPermissions(this._umap, this) this.permissions = new DataLayerPermissions(this._umap, this)
this._needsFetch = this.createdOnServer
if (!this.createdOnServer) { if (!this.createdOnServer) {
if (this.showAtLoad()) this.show() if (this.showAtLoad()) this.show()
} }
@ -243,7 +242,7 @@ export class DataLayer extends ServerStored {
} }
dataChanged() { dataChanged() {
if (!this.hasDataLoaded()) return if (!this.isLoaded()) return
this._umap.onDataLayersChanged() this._umap.onDataLayersChanged()
this.layer.dataChanged() this.layer.dataChanged()
} }
@ -252,13 +251,13 @@ export class DataLayer extends ServerStored {
if (!geojson) return [] if (!geojson) return []
const features = this.addData(geojson, sync) const features = this.addData(geojson, sync)
this._geojson = geojson this._geojson = geojson
this._needsFetch = false
this.onDataLoaded() this.onDataLoaded()
this.dataChanged() this.dataChanged()
return features return features
} }
onDataLoaded() { onDataLoaded() {
this._dataloaded = true
this.renderLegend() this.renderLegend()
} }
@ -268,7 +267,6 @@ export class DataLayer extends ServerStored {
if (geojson._umap_options) this.setOptions(geojson._umap_options) if (geojson._umap_options) this.setOptions(geojson._umap_options)
if (this.isRemoteLayer()) await this.fetchRemoteData() if (this.isRemoteLayer()) await this.fetchRemoteData()
else this.fromGeoJSON(geojson, false) else this.fromGeoJSON(geojson, false)
this._loaded = true
} }
clear() { clear() {
@ -320,7 +318,7 @@ export class DataLayer extends ServerStored {
async fetchRemoteData(force) { async fetchRemoteData(force) {
if (!this.isRemoteLayer()) return if (!this.isRemoteLayer()) return
if (!this.hasDynamicData() && this.hasDataLoaded() && !force) return if (!this.hasDynamicData() && this.isLoaded() && !force) return
if (!this.isVisible()) return if (!this.isVisible()) return
// Keep non proxied url for later use in Alert. // Keep non proxied url for later use in Alert.
const remoteUrl = this._umap.renderUrl(this.options.remoteData.url) const remoteUrl = this._umap.renderUrl(this.options.remoteData.url)
@ -345,11 +343,7 @@ export class DataLayer extends ServerStored {
} }
isLoaded() { isLoaded() {
return !this.createdOnServer || this._loaded return !this._needsFetch
}
hasDataLoaded() {
return this._dataloaded
} }
backupOptions() { backupOptions() {
@ -633,8 +627,6 @@ export class DataLayer extends ServerStored {
this.propagateDelete() this.propagateDelete()
this._leaflet_events_bk = this._leaflet_events this._leaflet_events_bk = this._leaflet_events
this.clear() this.clear()
delete this._loaded
delete this._dataloaded
} }
reset() { reset() {
@ -652,7 +644,6 @@ export class DataLayer extends ServerStored {
this.hide() this.hide()
if (this.isRemoteLayer()) this.fetchRemoteData() if (this.isRemoteLayer()) this.fetchRemoteData()
else if (this._geojson_bk) this.fromGeoJSON(this._geojson_bk) else if (this._geojson_bk) this.fromGeoJSON(this._geojson_bk)
this._loaded = true
this.show() this.show()
this.isDirty = false this.isDirty = false
} }
@ -1108,9 +1099,7 @@ export class DataLayer extends ServerStored {
async save() { async save() {
if (this.isDeleted) return await this.saveDelete() if (this.isDeleted) return await this.saveDelete()
if (!this.isLoaded()) { if (!this.isLoaded()) return
return
}
const geojson = this.umapGeoJSON() const geojson = this.umapGeoJSON()
const formData = new FormData() const formData = new FormData()
formData.append('name', this.options.name) formData.append('name', this.options.name)
@ -1172,7 +1161,6 @@ export class DataLayer extends ServerStored {
this.backupOptions() this.backupOptions()
this.backupData() this.backupData()
this.connectToMap() this.connectToMap()
this._loaded = true
this.redraw() // Needed for reordering features this.redraw() // Needed for reordering features
return true return true
} }

View file

@ -63,7 +63,7 @@ export class Form extends Utils.WithEvents {
try { try {
value = value[sub] value = value[sub]
} catch { } catch {
console.log(field) console.debug(field)
} }
} }
return value return value
@ -142,49 +142,50 @@ export class MutatingForm extends Form {
slugKey: 'PropertyInput', slugKey: 'PropertyInput',
labelKey: 'PropertyInput', labelKey: 'PropertyInput',
} }
for (const [key, schema] of Object.entries(SCHEMA)) { for (const [key, defaults] of Object.entries(SCHEMA)) {
if (schema.type === Boolean) { const properties = Object.assign({}, defaults)
if (schema.nullable) schema.handler = 'NullableChoices' if (properties.type === Boolean) {
else schema.handler = 'Switch' if (properties.nullable) properties.handler = 'NullableChoices'
} else if (schema.type === 'Text') { else properties.handler = 'Switch'
schema.handler = 'Textarea' } else if (properties.type === 'Text') {
} else if (schema.type === Number) { properties.handler = 'Textarea'
if (schema.step) schema.handler = 'Range' } else if (properties.type === Number) {
else schema.handler = 'IntInput' if (properties.step) properties.handler = 'Range'
} else if (schema.choices) { else properties.handler = 'IntInput'
const text_length = schema.choices.reduce( } else if (properties.choices) {
const text_length = properties.choices.reduce(
(acc, [_, label]) => acc + label.length, (acc, [_, label]) => acc + label.length,
0 0
) )
// Try to be smart and use MultiChoice only // Try to be smart and use MultiChoice only
// for choices where labels are shorts… // for choices where labels are shorts…
if (text_length < 40) { if (text_length < 40) {
schema.handler = 'MultiChoice' properties.handler = 'MultiChoice'
} else { } else {
schema.handler = 'Select' properties.handler = 'Select'
schema.selectOptions = schema.choices properties.selectOptions = properties.choices
} }
} else { } else {
switch (key) { switch (key) {
case 'color': case 'color':
case 'fillColor': case 'fillColor':
schema.handler = 'ColorPicker' properties.handler = 'ColorPicker'
break break
case 'iconUrl': case 'iconUrl':
schema.handler = 'IconUrl' properties.handler = 'IconUrl'
break break
case 'licence': case 'licence':
schema.handler = 'LicenceChooser' properties.handler = 'LicenceChooser'
break break
} }
} }
if (customHandlers[key]) { if (customHandlers[key]) {
schema.handler = customHandlers[key] properties.handler = customHandlers[key]
} }
// Input uses this key for its type attribute // Input uses this key for its type attribute
delete schema.type delete properties.type
this.defaultProperties[key] = schema this.defaultProperties[key] = properties
} }
} }
@ -202,7 +203,7 @@ export class MutatingForm extends Form {
getTemplate(helper) { getTemplate(helper) {
let template let template
if (helper.properties.inheritable) { if (helper.properties.inheritable) {
const extraClassName = helper.get(true) === undefined ? ' undefined' : '' const extraClassName = this.getter(helper.field) === undefined ? ' undefined' : ''
template = ` template = `
<div class="umap-field-${helper.name} formbox inheritable${extraClassName}"> <div class="umap-field-${helper.name} formbox inheritable${extraClassName}">
<div class="header" data-ref=header> <div class="header" data-ref=header>

View file

@ -80,11 +80,13 @@ class BaseElement {
this.input.value = '' this.input.value = ''
} }
get(own) { get() {
if (!this.properties.inheritable || own) return this.builder.getter(this.field) if (!this.properties.inheritable) return this.builder.getter(this.field)
const path = this.field.split('.') const path = this.field.split('.')
const key = path[path.length - 1] const key = path[path.length - 1]
return this.obj.getOption(key) || SCHEMA[key]?.default const value = this.obj.getOption(key)
if (value === undefined) return SCHEMA[key]?.default
return value
} }
toHTML() { toHTML() {
@ -1164,7 +1166,7 @@ Fields.MultiChoice = class extends BaseElement {
Fields.TernaryChoices = class extends Fields.MultiChoice { Fields.TernaryChoices = class extends Fields.MultiChoice {
getDefault() { getDefault() {
return 'null' return null
} }
toJS() { toJS() {
@ -1195,7 +1197,7 @@ Fields.NullableChoices = class extends Fields.TernaryChoices {
this.properties.choices || [ this.properties.choices || [
[true, translate('always')], [true, translate('always')],
[false, translate('never')], [false, translate('never')],
['null', translate('hidden')], [null, translate('hidden')],
] ]
) )
} }

View file

@ -75,7 +75,7 @@ const ClassifiedMixin = {
}, },
renderLegend: function (container) { renderLegend: function (container) {
if (!this.datalayer.hasDataLoaded()) return if (!this.datalayer.isLoaded()) return
const parent = DomUtil.create('ul', '', container) const parent = DomUtil.create('ul', '', container)
const items = this.getLegendItems() const items = this.getLegendItems()
for (const [color, label] of items) { for (const [color, label] of items) {

View file

@ -10,6 +10,11 @@ export async function save() {
} }
} }
export function clear() {
_queue.clear()
onUpdate()
}
function add(obj) { function add(obj) {
_queue.add(obj) _queue.add(obj)
onUpdate() onUpdate()

View file

@ -450,7 +450,7 @@ export const SCHEMA = {
choices: [ choices: [
[true, translate('always')], [true, translate('always')],
[false, translate('never')], [false, translate('never')],
['null', translate('on hover')], [null, translate('on hover')],
], ],
}, },
slideshow: { slideshow: {

View file

@ -2,6 +2,7 @@ import * as Utils from '../utils.js'
import { HybridLogicalClock } from './hlc.js' import { HybridLogicalClock } from './hlc.js'
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
import { WebSocketTransport } from './websocket.js' import { WebSocketTransport } from './websocket.js'
import * as SaveManager from '../saving.js'
// Start reconnecting after 2 seconds, then double the delay each time // Start reconnecting after 2 seconds, then double the delay each time
// maxing out at 32 seconds. // maxing out at 32 seconds.
@ -125,6 +126,16 @@ export class SyncEngine {
this._send({ verb: 'delete', subject, metadata, key }) this._send({ verb: 'delete', subject, metadata, key })
} }
saved() {
if (this.offline) return
if (this.transport) {
this.transport.send('SavedMessage', {
sender: this.peerId,
lastKnownHLC: this._operations.getLastKnownHLC(),
})
}
}
_send(inputMessage) { _send(inputMessage) {
const message = this._operations.addLocal(inputMessage) const message = this._operations.addLocal(inputMessage)
@ -168,6 +179,8 @@ export class SyncEngine {
} else if (payload.message.verb === 'ListOperationsResponse') { } else if (payload.message.verb === 'ListOperationsResponse') {
this.onListOperationsResponse(payload) this.onListOperationsResponse(payload)
} }
} else if (kind === 'SavedMessage') {
this.onSavedMessage(payload)
} else { } else {
throw new Error(`Received unknown message from the websocket server: ${kind}`) throw new Error(`Received unknown message from the websocket server: ${kind}`)
} }
@ -280,6 +293,13 @@ export class SyncEngine {
// Else: apply // Else: apply
} }
onSavedMessage({ sender, lastKnownHLC }) {
debug(`received saved message from peer ${sender}`, lastKnownHLC)
if (lastKnownHLC === this._operations.getLastKnownHLC() && SaveManager.isDirty) {
SaveManager.clear()
}
}
/** /**
* Send a message to another peer (via the transport layer) * Send a message to another peer (via the transport layer)
* *
@ -350,7 +370,7 @@ export class Operations {
} }
/** /**
* Tick the clock and add store the passed message in the operations list. * Tick the clock and store the passed message in the operations list.
* *
* @param {*} inputMessage * @param {*} inputMessage
* @returns {*} clock-aware message * @returns {*} clock-aware message

View file

@ -55,9 +55,6 @@ 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) 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 }) {
@ -92,7 +89,7 @@ export class FeatureUpdater extends BaseUpdater {
upsert({ metadata, value }) { upsert({ metadata, value }) {
const { id, layerId } = metadata const { id, layerId } = metadata
const datalayer = this.getDataLayerFromID(layerId) const datalayer = this.getDataLayerFromID(layerId)
const feature = this.getFeatureFromMetadata(metadata, value) const feature = this.getFeatureFromMetadata(metadata)
if (feature) { if (feature) {
feature.geometry = value.geometry feature.geometry = value.geometry
@ -109,7 +106,7 @@ export class FeatureUpdater extends BaseUpdater {
return return
} }
if (key === 'geometry') { if (key === 'geometry') {
const feature = this.getFeatureFromMetadata(metadata, value) const feature = this.getFeatureFromMetadata(metadata)
feature.geometry = value feature.geometry = value
} else { } else {
this.updateObjectValue(feature, key, value) this.updateObjectValue(feature, key, value)

View file

@ -684,6 +684,7 @@ export default class Umap extends ServerStored {
Alert.success(translate('Map has been saved!')) Alert.success(translate('Map has been saved!'))
}) })
} }
this.sync.saved()
this.fire('saved') this.fire('saved')
} }

View file

@ -14,6 +14,7 @@ from .payloads import (
OperationMessage, OperationMessage,
PeerMessage, PeerMessage,
Request, Request,
SavedMessage,
) )
@ -165,6 +166,10 @@ class Peer:
case OperationMessage(): case OperationMessage():
await self.broadcast(text_data) await self.broadcast(text_data)
# Broadcast the new map state to connected peers
case SavedMessage():
await self.broadcast(text_data)
# Send peer messages to the proper peer # Send peer messages to the proper peer
case PeerMessage(): case PeerMessage():
await self.send_to(incoming.root.recipient, text_data) await self.send_to(incoming.root.recipient, text_data)

View file

@ -30,10 +30,17 @@ class PeerMessage(BaseModel):
message: dict message: dict
class SavedMessage(BaseModel):
kind: Literal["SavedMessage"] = "SavedMessage"
lastKnownHLC: str
class Request(RootModel): class Request(RootModel):
"""Any message coming from the websocket should be one of these, and will be rejected otherwise.""" """Any message coming from the websocket should be one of these, and will be rejected otherwise."""
root: Union[PeerMessage, OperationMessage] = Field(discriminator="kind") root: Union[PeerMessage, OperationMessage, SavedMessage] = Field(
discriminator="kind"
)
class JoinResponse(BaseModel): class JoinResponse(BaseModel):

View file

@ -210,3 +210,19 @@ def test_sortkey_impacts_datalayerindex(map, live_server, page):
assert "Z First" == first_listed_feature.text_content() assert "Z First" == first_listed_feature.text_content()
assert "Y Second" == second_listed_feature.text_content() assert "Y Second" == second_listed_feature.text_content()
assert "X Third" == third_listed_feature.text_content() assert "X Third" == third_listed_feature.text_content()
def test_hover_tooltip_setting_should_be_persistent(live_server, map, page):
map.settings["properties"]["showLabel"] = None
map.edit_status = Map.ANONYMOUS
map.save()
page.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
page.get_by_role("button", name="Map advanced properties").click()
page.get_by_text("Default interaction options").click()
expect(page.get_by_text("on hover")).to_be_visible()
expect(page.locator(".umap-field-showLabel")).to_match_aria_snapshot("""
- text: Display label
- button "clear"
- text: always never on hover
""")
expect(page.locator(".umap-field-showLabel input[value=null]")).to_be_checked()

View file

@ -181,3 +181,13 @@ def test_only_visible_markers_are_added_to_dom(live_server, map, page):
) )
expect(markers).to_have_count(1) expect(markers).to_have_count(1)
expect(tooltips).to_have_count(1) expect(tooltips).to_have_count(1)
def test_should_display_tooltip_on_hover(live_server, map, page, bootstrap):
map.settings["properties"]["showLabel"] = None
map.settings["properties"]["labelKey"] = "Foo {name}"
map.save()
page.goto(f"{live_server.url}{map.get_absolute_url()}")
expect(page.get_by_text("Foo test marker")).to_be_hidden()
page.locator(".leaflet-marker-icon").hover()
expect(page.get_by_text("Foo test marker")).to_be_visible()

View file

@ -398,7 +398,9 @@ def test_should_sync_datalayers(new_page, asgi_live_server, tilelayer):
peerA.locator("#map").click() peerA.locator("#map").click()
# Make sure this new marker is in Layer 2 for peerB # Make sure this new marker is in Layer 2 for peerB
expect(peerB.get_by_text("Layer 2")).to_be_visible() # Show features for this layer in the brower.
peerB.get_by_role("heading", name="Layer 2").locator(".datalayer-name").click()
expect(peerB.locator("li").filter(has_text="Layer 2")).to_be_visible()
peerB.locator(".panel.left").get_by_role("button", name="Show/hide layer").nth( peerB.locator(".panel.left").get_by_role("button", name="Show/hide layer").nth(
1 1
).click() ).click()
@ -421,9 +423,11 @@ def test_should_sync_datalayers(new_page, asgi_live_server, tilelayer):
1 1
).click() ).click()
# Now peerA saves the layer 2 to the server # Peer A should not be in dirty state
with peerA.expect_response(re.compile(".*/datalayer/update/.*")): expect(peerA.locator("body")).not_to_have_class(re.compile(".*umap-is-dirty.*"))
peerA.get_by_role("button", name="Save").click()
# Peer A should only have two markers
expect(peerA.locator(".leaflet-marker-icon")).to_have_count(2)
assert DataLayer.objects.count() == 2 assert DataLayer.objects.count() == 2
@ -557,3 +561,46 @@ def test_create_and_sync_map(new_page, asgi_live_server, tilelayer, login, user)
peerA.get_by_role("button", name="Edit").click() peerA.get_by_role("button", name="Edit").click()
expect(markersA).to_have_count(2) expect(markersA).to_have_count(2)
expect(markersB).to_have_count(2) expect(markersB).to_have_count(2)
@pytest.mark.xdist_group(name="websockets")
def test_should_sync_saved_status(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 marker").click()
peerA.locator("#map").click(position={"x": 220, "y": 220})
# Peer A should be in dirty state
expect(peerA.locator("body")).to_have_class(re.compile(".*umap-is-dirty.*"))
# Peer B should not be in dirty state
expect(peerB.locator("body")).not_to_have_class(re.compile(".*umap-is-dirty.*"))
# Create a new marker from peerB
peerB.get_by_title("Draw a marker").click()
peerB.locator("#map").click(position={"x": 200, "y": 250})
# Peer B should be in dirty state
expect(peerB.locator("body")).to_have_class(re.compile(".*umap-is-dirty.*"))
# Peer A should still be in dirty state
expect(peerA.locator("body")).to_have_class(re.compile(".*umap-is-dirty.*"))
# Save layer to the server from peerA
with peerA.expect_response(re.compile(".*/datalayer/create/.*")):
peerA.get_by_role("button", name="Save").click()
# Peer B should not be in dirty state
expect(peerB.locator("body")).not_to_have_class(re.compile(".*umap-is-dirty.*"))
# Peer A should not be in dirty state
expect(peerA.locator("body")).not_to_have_class(re.compile(".*umap-is-dirty.*"))