Compare commits

..

22 commits

Author SHA1 Message Date
Yohan Boniface
159d2c0b77
Merge 3b6ff0c57c into 64c7fe1ec9 2025-03-26 17:47:40 +00:00
Yohan Boniface
3b6ff0c57c wip: allow to sync version restore
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:46:59 +01:00
Yohan Boniface
2a460f03b2 wip: add test to make sure saving also save remote dirty datalayers 2025-03-26 18:05:16 +01:00
Yohan Boniface
bbde111fdf wip: do not call document during JS unittests 2025-03-26 18:05:16 +01:00
Yohan Boniface
9c4287ac1e wip: allow to sync/undo filter added/removed from table editor 2025-03-26 18:05:16 +01:00
Yohan Boniface
e9f2ff9a6c wip: permissions does not inherit from ServerStored anymore 2025-03-26 18:05:16 +01:00
Yohan Boniface
12e456d24e wip: allow to undo/sync rules
When editing Rule(s), we are not editing the map data itself, but a
sort of proxy objects. This was done mainly because map.properties.rules
is an array of object, and at this time Leaflet.FormBuilder did not know
how to edit an array (something like properties.rules.0.condition).
Now that we integrated FormBuilder, it still does not know how to do this
but we could teach it, or find another way (real Proxy or use reference
to the original object in the Rule).
2025-03-26 18:05:16 +01:00
Yohan Boniface
e004cd461d wip: uncreated map should always appear as dirty 2025-03-26 18:05:16 +01:00
Yohan Boniface
6bea9339b6 wip: DataLayer does not inherit anymore from ServerStored 2025-03-26 18:05:16 +01:00
Yohan Boniface
90ea3737f2 wip: allow DataLayer.clear to be sync and undone 2025-03-26 18:05:16 +01:00
Yohan Boniface
a7b750740c wip: uMap does not inherit anymore from ServerStored
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
101b036a66 wip: remove not effective code
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
983f7f8cb1 wip: add permissions related fields in schema
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
9718f11faf wip: allow to mark an operation as not undoable
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
88382ab00b wip: tests pass
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
0b84084c6b fixup: make sure to toggle remote client state at save too
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
8b2454936b wip: derive the dirty status from the undoManager
This should pave the way for removing the SaveManager.

Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
98f2f8df65 Update the tests and remove cancel edits
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
2025-03-26 18:05:16 +01:00
757cb375d1 Add integration test for batch undo/redo 2025-03-26 18:05:16 +01:00
Yohan Boniface
4ef1411102 Batch operations
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
Co-authored-by: David Larlet <david@larlet.fr>
2025-03-26 18:05:16 +01:00
Yohan Boniface
01b2053030 wip: undo redo
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
2025-03-26 18:05:16 +01:00
Yohan Boniface
64c7fe1ec9 fix: fix icon and button for version restore
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-03-26 18:05:01 +01:00
20 changed files with 189 additions and 250 deletions

View file

@ -18,7 +18,6 @@ import { translate } from '../i18n.js'
import { DataLayerPermissions } from '../permissions.js' import { DataLayerPermissions } from '../permissions.js'
import { Point, LineString, Polygon } from './features.js' import { Point, LineString, Polygon } from './features.js'
import TableEditor from '../tableeditor.js' import TableEditor from '../tableeditor.js'
import { ServerStored } from '../saving.js'
import * as Schema from '../schema.js' import * as Schema from '../schema.js'
import { MutatingForm } from '../form/builder.js' import { MutatingForm } from '../form/builder.js'
@ -36,9 +35,8 @@ const LAYER_MAP = LAYER_TYPES.reduce((acc, klass) => {
return acc return acc
}, {}) }, {})
export class DataLayer extends ServerStored { export class DataLayer {
constructor(umap, leafletMap, data = {}) { constructor(umap, leafletMap, data = {}) {
super()
this._umap = umap this._umap = umap
this.sync = umap.syncEngine.proxy(this) this.sync = umap.syncEngine.proxy(this)
this._index = Array() this._index = Array()
@ -114,7 +112,6 @@ export class DataLayer extends ServerStored {
set isDeleted(status) { set isDeleted(status) {
this._isDeleted = status this._isDeleted = status
if (status) this.isDirty = status
} }
get isDeleted() { get isDeleted() {
@ -530,10 +527,6 @@ export class DataLayer extends ServerStored {
return this._umap.formatter return this._umap.formatter
.parse(raw, format) .parse(raw, format)
.then((geojson) => this.addData(geojson)) .then((geojson) => this.addData(geojson))
.then((data) => {
if (data?.length) this.isDirty = true
return data
})
.catch((error) => { .catch((error) => {
console.debug(error) console.debug(error)
Alert.error(translate('Import failed: invalid data')) Alert.error(translate('Import failed: invalid data'))
@ -610,7 +603,6 @@ export class DataLayer extends ServerStored {
empty() { empty() {
if (this.isRemoteLayer()) return if (this.isRemoteLayer()) return
this.clear() this.clear()
this.isDirty = true
} }
clone() { clone() {
@ -634,25 +626,6 @@ export class DataLayer extends ServerStored {
this.clear() 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() { redraw() {
if (!this.isVisible()) return if (!this.isVisible()) return
this.eachFeature((feature) => feature.redraw()) this.eachFeature((feature) => feature.redraw())
@ -835,8 +808,9 @@ export class DataLayer extends ServerStored {
this this
) )
if (this._umap.properties.urls.datalayer_versions) if (this._umap.properties.urls.datalayer_versions) {
this.buildVersionsFieldset(container) this.buildVersionsFieldset(container)
}
const advancedActions = DomUtil.createFieldset( const advancedActions = DomUtil.createFieldset(
container, container,
@ -911,10 +885,15 @@ export class DataLayer extends ServerStored {
const appendVersion = (data) => { const appendVersion = (data) => {
const date = new Date(Number.parseInt(data.at, 10)) const date = new Date(Number.parseInt(data.at, 10))
const content = `${date.toLocaleString(U.lang)} (${Number.parseInt(data.size) / 1000}Kb)` const content = `${date.toLocaleString(U.lang)} (${Number.parseInt(data.size) / 1000}Kb)`
const el = DomUtil.create('div', 'umap-datalayer-version', versionsContainer) const [el, { button }] = Utils.loadTemplateWithRefs(
const button = DomUtil.createButton('', el, '', () => this.restore(data.ref)) `<div class="umap-datalayer-version">
button.title = translate('Restore this version') <button type="button" title="${translate('Restore this version')}" data-ref=button>
DomUtil.add('span', '', el, content) <i class="icon icon-16 icon-restore"></i> ${content}
</button>
</div>`
)
versionsContainer.appendChild(el)
button.addEventListener('click', () => this.restore(data.ref))
} }
const versionsContainer = DomUtil.createFieldset(container, translate('Versions'), { const versionsContainer = DomUtil.createFieldset(container, translate('Versions'), {
@ -938,11 +917,14 @@ export class DataLayer extends ServerStored {
) )
if (!error) { if (!error) {
if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat. 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() this.empty()
if (this.isRemoteLayer()) this.fetchRemoteData() if (this.isRemoteLayer()) this.fetchRemoteData()
else this.addData(geojson) else this.addData(geojson)
this.isDirty = true
} }
}) })
} }
@ -1123,6 +1105,10 @@ export class DataLayer extends ServerStored {
} }
async _trySave(url, headers, formData) { async _trySave(url, headers, formData) {
if (this._forceSave) {
headers = {}
this._forceSave = false
}
const [data, response, error] = await this._umap.server.post(url, headers, formData) const [data, response, error] = await this._umap.server.post(url, headers, formData)
if (error) { if (error) {
if (response && response.status === 412) { if (response && response.status === 412) {
@ -1132,15 +1118,8 @@ export class DataLayer extends ServerStored {
'This situation is tricky, you have to choose carefully which version is pertinent.' 'This situation is tricky, you have to choose carefully which version is pertinent.'
), ),
async () => { async () => {
// Save again this layer this._forceSave = true
const status = await this._trySave(url, {}, formData) await this._umap.saveAll()
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()
}
} }
) )
} }

View file

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

View file

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

View file

@ -1,18 +1,15 @@
import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from './i18n.js' import { translate } from './i18n.js'
import { uMapAlert as Alert } from '../components/alerts/alert.js' import { uMapAlert as Alert } from '../components/alerts/alert.js'
import { ServerStored } from './saving.js'
import * as Utils from './utils.js' import * as Utils from './utils.js'
import { MutatingForm } from './form/builder.js' import { MutatingForm } from './form/builder.js'
// Dedicated object so we can deal with a separate dirty status, and thus // 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. // call the endpoint only when needed, saving one call at each save.
export class MapPermissions extends ServerStored { export class MapPermissions {
constructor(umap) { constructor(umap) {
super()
this.setProperties(umap.properties.permissions) this.setProperties(umap.properties.permissions)
this._umap = umap this._umap = umap
this._isDirty = false
this.sync = umap.syncEngine.proxy(this) this.sync = umap.syncEngine.proxy(this)
} }
@ -196,7 +193,6 @@ export class MapPermissions extends ServerStored {
} }
async save() { async save() {
if (!this.isDirty) return
const formData = new FormData() const formData = new FormData()
if (!this.isAnonymousMap() && this.properties.editors) { if (!this.isAnonymousMap() && this.properties.editors) {
const editors = this.properties.editors.map((u) => u.id) const editors = this.properties.editors.map((u) => u.id)
@ -255,9 +251,8 @@ export class MapPermissions extends ServerStored {
} }
} }
export class DataLayerPermissions extends ServerStored { export class DataLayerPermissions {
constructor(umap, datalayer) { constructor(umap, datalayer) {
super()
this._umap = umap this._umap = umap
this.properties = Object.assign( this.properties = Object.assign(
{ {
@ -305,7 +300,6 @@ export class DataLayerPermissions extends ServerStored {
} }
async save() { async save() {
if (!this.isDirty) return
const formData = new FormData() const formData = new FormData()
formData.append('edit_status', this.properties.edit_status) formData.append('edit_status', this.properties.edit_status)
const [data, response, error] = await this._umap.server.post( const [data, response, error] = await this._umap.server.post(

View file

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

View file

@ -17,20 +17,10 @@ class Rule {
this.parse() this.parse()
} }
get isDirty() {
return this._isDirty
}
set isDirty(status) {
this._isDirty = status
if (status) this._umap.isDirty = status
}
constructor(umap, condition = '', options = {}) { constructor(umap, condition = '', options = {}) {
// TODO make this public properties when browser coverage is ok // TODO make this public properties when browser coverage is ok
// cf https://caniuse.com/?search=public%20class%20field // cf https://caniuse.com/?search=public%20class%20field
this._condition = null this._condition = null
this._isDirty = false
this.OPERATORS = [ this.OPERATORS = [
['>', this.gt], ['>', this.gt],
['<', this.lt], ['<', this.lt],
@ -190,17 +180,25 @@ class Rule {
_delete() { _delete() {
this._umap.rules.rules = this._umap.rules.rules.filter((rule) => rule !== this) 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 { export default class Rules {
constructor(umap) { constructor(umap) {
this._umap = umap this._umap = umap
this.rules = []
this.load() this.load()
} }
load() { load() {
this.rules = []
if (!this._umap.properties.rules?.length) return if (!this._umap.properties.rules?.length) return
for (const { condition, options } of this._umap.properties.rules) { for (const { condition, options } of this._umap.properties.rules) {
if (!condition) continue if (!condition) continue
@ -222,8 +220,8 @@ export default class Rules {
else if (finalIndex > initialIndex) newIdx = referenceIdx else if (finalIndex > initialIndex) newIdx = referenceIdx
else newIdx = referenceIdx + 1 else newIdx = referenceIdx + 1
this.rules.splice(newIdx, 0, moved) this.rules.splice(newIdx, 0, moved)
moved.isDirty = true
this._umap.render(['rules']) this._umap.render(['rules'])
this.commit()
} }
edit(container) { edit(container) {
@ -242,7 +240,6 @@ export default class Rules {
addRule() { addRule() {
const rule = new Rule(this._umap) const rule = new Rule(this._umap)
rule.isDirty = true
this.rules.push(rule) this.rules.push(rule)
rule.edit(map) 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

@ -9,7 +9,6 @@ import {
} from './updaters.js' } from './updaters.js'
import { WebSocketTransport } from './websocket.js' import { WebSocketTransport } from './websocket.js'
import { UndoManager } from './undo.js' import { UndoManager } from './undo.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.
@ -73,7 +72,7 @@ export class SyncEngine {
this.websocketConnected = false this.websocketConnected = false
this.closeRequested = false this.closeRequested = false
this.peerId = Utils.generateId() this.peerId = Utils.generateId()
this._undoManager = new UndoManager(this.updaters, this) this._undoManager = new UndoManager(umap, this.updaters, this)
} }
get isOpen() { get isOpen() {

View file

@ -2,7 +2,8 @@ import * as Utils from '../utils.js'
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
export class UndoManager { export class UndoManager {
constructor(updaters, syncEngine) { constructor(umap, updaters, syncEngine) {
this._umap = umap
this._syncEngine = syncEngine this._syncEngine = syncEngine
this.updaters = updaters this.updaters = updaters
this._undoStack = [] this._undoStack = []
@ -10,6 +11,8 @@ export class UndoManager {
} }
toggleState() { toggleState() {
// document is undefined during unittests
if (typeof document === 'undefined') return
const undoButton = document.querySelector('.edit-undo') const undoButton = document.querySelector('.edit-undo')
const redoButton = document.querySelector('.edit-redo') const redoButton = document.querySelector('.edit-redo')
if (undoButton) undoButton.disabled = !this._undoStack.length if (undoButton) undoButton.disabled = !this._undoStack.length
@ -25,6 +28,7 @@ export class UndoManager {
} }
isDirty() { isDirty() {
if (!this._umap.id) return true
for (const stage of this._undoStack) { for (const stage of this._undoStack) {
if (stage.operation.dirty) return true if (stage.operation.dirty) return true
} }

View file

@ -1,4 +1,4 @@
import { fieldInSchema } from '../utils.js' import * as Utils from '../utils.js'
/** /**
* Updaters are classes able to convert messages * Updaters are classes able to convert messages
@ -10,27 +10,6 @@ class BaseUpdater {
this._umap = umap 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) { getDataLayerFromID(layerId) {
return this._umap.getDataLayerByUmapId(layerId) return this._umap.getDataLayerByUmapId(layerId)
} }
@ -43,8 +22,8 @@ class BaseUpdater {
export class MapUpdater extends BaseUpdater { export class MapUpdater extends BaseUpdater {
update({ key, value }) { update({ key, value }) {
if (fieldInSchema(key)) { if (Utils.fieldInSchema(key)) {
this.updateObjectValue(this._umap, key, value) Utils.setObjectValue(this._umap, key, value)
} }
this._umap.onPropertiesUpdated([key]) this._umap.onPropertiesUpdated([key])
@ -73,8 +52,10 @@ export class DataLayerUpdater extends BaseUpdater {
update({ key, metadata, value }) { update({ key, metadata, value }) {
const datalayer = this.getDataLayerFromID(metadata.id) const datalayer = this.getDataLayerFromID(metadata.id)
if (fieldInSchema(key)) { if (key === 'options') {
this.updateObjectValue(datalayer, key, value) datalayer.setOptions(value)
} else if (Utils.fieldInSchema(key)) {
Utils.setObjectValue(datalayer, key, value)
} else { } else {
console.debug( console.debug(
'Not applying update for datalayer because key is not in the schema', 'Not applying update for datalayer because key is not in the schema',
@ -127,7 +108,7 @@ export class FeatureUpdater extends BaseUpdater {
const feature = this.getFeatureFromMetadata(metadata) const feature = this.getFeatureFromMetadata(metadata)
feature.geometry = value feature.geometry = value
} else { } else {
this.updateObjectValue(feature, key, value) Utils.setObjectValue(feature, key, value)
feature.datalayer.indexProperties(feature) feature.datalayer.indexProperties(feature)
} }
@ -148,8 +129,8 @@ export class FeatureUpdater extends BaseUpdater {
export class MapPermissionsUpdater extends BaseUpdater { export class MapPermissionsUpdater extends BaseUpdater {
update({ key, value }) { update({ key, value }) {
if (fieldInSchema(key)) { if (Utils.fieldInSchema(key)) {
this.updateObjectValue(this._umap.permissions, key, value) Utils.setObjectValue(this._umap.permissions, key, value)
} }
} }
@ -160,8 +141,8 @@ export class MapPermissionsUpdater extends BaseUpdater {
export class DataLayerPermissionsUpdater extends BaseUpdater { export class DataLayerPermissionsUpdater extends BaseUpdater {
update({ key, value, metadata }) { update({ key, value, metadata }) {
if (fieldInSchema(key)) { if (Utils.fieldInSchema(key)) {
this.updateObjectValue(this.getDataLayerFromID(metadata.id), key, value) Utils.setObjectValue(this.getDataLayerFromID(metadata.id), key, value)
} }
} }

View file

@ -162,6 +162,7 @@ export class TopBar extends WithTemplate {
this.elements.view.disabled = this._umap.sync._undoManager.isDirty() this.elements.view.disabled = this._umap.sync._undoManager.isDirty()
this.elements.saveLabel.hidden = this._umap.permissions.isDraft() this.elements.saveLabel.hidden = this._umap.permissions.isDraft()
this.elements.saveDraftLabel.hidden = !this._umap.permissions.isDraft() this.elements.saveDraftLabel.hidden = !this._umap.permissions.isDraft()
this._umap.sync._undoManager.toggleState()
} }
} }

View file

@ -1159,7 +1159,6 @@ export default class Umap {
} }
async save() { async save() {
this.rules.commit()
const geojson = { const geojson = {
type: 'Feature', type: 'Feature',
geometry: this.geometry(), geometry: this.geometry(),
@ -1320,6 +1319,9 @@ export default class Umap {
this.bottomBar.redraw() this.bottomBar.redraw()
break break
case 'data': case 'data':
if (fields.includes('properties.rules')) {
this.rules.load()
}
this.eachVisibleDataLayer((datalayer) => { this.eachVisibleDataLayer((datalayer) => {
datalayer.redraw() datalayer.redraw()
}) })

View file

@ -481,6 +481,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 = [ export const COLORS = [
'Black', 'Black',
'Navy', 'Navy',

View file

@ -475,22 +475,6 @@ ul.photon-autocomplete {
font-style: italic; font-style: italic;
} }
.umap-datalayer-version {
padding: 5px 0;
border-bottom: 1px solid #202425;
}
.umap-datalayer-version button {
display: inline-block;
width: 24px;
min-height: 24px;
background-position: -122px -73px;
background-repeat: no-repeat;
background-image: url('./img/16-white.svg');
margin-inline-end: 10px;
}
.leaflet-toolbar-tip { .leaflet-toolbar-tip {
background-color: var(--color-darkGray); background-color: var(--color-darkGray);
} }

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

View file

@ -862,4 +862,51 @@ describe('Utils', () => {
assert.equal(Utils.isObject(''), false) 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

@ -261,6 +261,9 @@ def test_can_create_new_rule(live_server, page, openmap):
page.get_by_title("AliceBlue").first.click() page.get_by_title("AliceBlue").first.click()
colors = getColors(markers) colors = getColors(markers)
assert colors.count("rgb(240, 248, 255)") == 3 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): def test_can_deactive_rule_from_list(live_server, page, openmap):

View file

@ -689,3 +689,43 @@ def test_should_sync_datalayer_clear(
peerA.get_by_role("button", name="Undo").click() peerA.get_by_role("button", name="Undo").click()
expect(peerA.locator(".leaflet-marker-icon")).to_have_count(1) expect(peerA.locator(".leaflet-marker-icon")).to_have_count(1)
expect(peerB.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", "type": "Point",
}, },
"id": "ExNTQ",
"properties": { "properties": {
"_umap_options": { "_umap_options": {
"color": "DarkCyan", "color": "DarkCyan",

View file

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