diff --git a/umap/static/umap/css/bar.css b/umap/static/umap/css/bar.css index cf71e2c1..4dc36f6d 100644 --- a/umap/static/umap/css/bar.css +++ b/umap/static/umap/css/bar.css @@ -14,11 +14,12 @@ background-color: inherit; } .leaflet-container .edit-save, -.leaflet-container .edit-cancel, +.leaflet-container .edit-undo, +.leaflet-container .edit-redo, .leaflet-container .edit-disable, .leaflet-container .connected-peers { - display: block; + display: inline-block; height: 32px; line-height: 30px; padding: 0 20px; @@ -39,7 +40,8 @@ color: var(--color-darkGray); } -.leaflet-container .edit-cancel:hover, +.leaflet-container .edit-undo:hover, +.leaflet-container .edit-redo:hover, .leaflet-container .edit-disable:hover { border: 0.5px solid rgba(153, 153, 153, 0.80); text-decoration: none; @@ -76,19 +78,13 @@ background: rgba(66, 236, 230, 0.10); } .leaflet-container .edit-save, -.leaflet-container .edit-cancel, -.leaflet-container .edit-disable, .umap-edit-enabled .edit-enable { display: none; } .umap-edit-enabled .edit-save, -.umap-edit-enabled .edit-disable, -.umap-edit-enabled.umap-is-dirty .edit-cancel { +.umap-edit-enabled .edit-disable { display: inline-block; } -.umap-is-dirty .edit-disable { - display: none; -} .umap-caption-bar { display: none; } @@ -115,8 +111,6 @@ .umap-right-edit-toolbox { display: flex; column-gap: 10px; -} -.umap-right-edit-toolbox { align-items: center; } @@ -135,17 +129,20 @@ text-indent: -9999px; } .umap-main-edit-toolbox .map-name { - display: inline-block; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; font-weight: bold; text-align: start; } +.truncate { + display: inline-flex; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.umap-main-edit-toolbox .username { + max-width: 100px; +} .umap-main-edit-toolbox .share-status { font-style: italic; - overflow: hidden; - text-overflow: ellipsis; } .map-name:after { content: '\00a0'; @@ -242,3 +239,14 @@ padding: 0; margin: 0; } +@media all and (max-width: 980px) { + .umap-main-edit-toolbox button span { + display: none; + } +} +@media all and (max-width: 770px) { + .umap-main-edit-toolbox .umap-help-link, + .umap-main-edit-toolbox .share-status { + display: none !important; + } +} diff --git a/umap/static/umap/css/icon.css b/umap/static/umap/css/icon.css index dfcffa72..30d5ce3b 100644 --- a/umap/static/umap/css/icon.css +++ b/umap/static/umap/css/icon.css @@ -167,11 +167,15 @@ html[dir="rtl"] .icon { .icon-profile { background-position: 0 calc(var(--tile) * 4); } +.icon-redo { + background-position: calc(var(--tile) * 3) calc(var(--tile) * 7); +} .icon-resize { background-position: calc(var(--tile) * 3) calc(var(--tile) * 6); } +.icon-undo, .icon-restore { - background-position: calc(var(--tile) * 5) calc(var(--tile) * 3); + background-position: calc(var(--tile) * 2) calc(var(--tile) * 7); } .expanded .icon-resize { background-position: calc(var(--tile) * 2) calc(var(--tile) * 6); diff --git a/umap/static/umap/img/16-white.svg b/umap/static/umap/img/16-white.svg index f61c1682..eae286ae 100644 --- a/umap/static/umap/img/16-white.svg +++ b/umap/static/umap/img/16-white.svg @@ -18,6 +18,9 @@ + + + @@ -67,9 +70,12 @@ - + + + + @@ -143,6 +149,10 @@ + + + + diff --git a/umap/static/umap/img/16.svg b/umap/static/umap/img/16.svg index e33dc876..eb32402f 100644 --- a/umap/static/umap/img/16.svg +++ b/umap/static/umap/img/16.svg @@ -1 +1 @@ -image/svg+xml +image/svg+xml diff --git a/umap/static/umap/img/source/16-white.svg b/umap/static/umap/img/source/16-white.svg index 7bb8097b..34efb72f 100644 --- a/umap/static/umap/img/source/16-white.svg +++ b/umap/static/umap/img/source/16-white.svg @@ -21,8 +21,11 @@ + + + - + @@ -78,9 +81,12 @@ - + + + + @@ -154,6 +160,10 @@ + + + + diff --git a/umap/static/umap/img/source/16.svg b/umap/static/umap/img/source/16.svg index 454d2fad..d264c313 100644 --- a/umap/static/umap/img/source/16.svg +++ b/umap/static/umap/img/source/16.svg @@ -1,4 +1,4 @@ -image/svg+xml +image/svg+xml diff --git a/umap/static/umap/js/modules/data/features.js b/umap/static/umap/js/modules/data/features.js index 8ccfe9ba..cd04dba3 100644 --- a/umap/static/umap/js/modules/data/features.js +++ b/umap/static/umap/js/modules/data/features.js @@ -91,6 +91,7 @@ class Feature { } set geometry(value) { + this._geometry_bk = Utils.CopyJSON(this._geometry) this._geometry = value this.pushGeometry() } @@ -104,13 +105,15 @@ class Feature { } pullGeometry(sync = true) { + const oldGeometry = Utils.CopyJSON(this._geometry) this.fromLatLngs(this._getLatLngs()) if (sync) { - this.sync.update('geometry', this.geometry) + this.sync.update('geometry', this.geometry, oldGeometry) } } fromLatLngs(latlngs) { + this._geometry_bk = Utils.CopyJSON(this._geometry) this._geometry = this.convertLatLngs(latlngs) } @@ -145,8 +148,15 @@ class Feature { onCommit() { // When the layer is a remote layer, we don't want to sync the creation of the // points via the websocket, as the other peers will get them themselves. + const oldGeoJSON = this._just_married ? null : Utils.CopyJSON(this.toGeoJSON()) + this.pullGeometry(false) if (this.datalayer?.isRemoteLayer()) return - this.sync.upsert(this.toGeoJSON()) + if (this._just_married) { + this.sync.upsert(this.toGeoJSON(), null) + this._just_married = false + } else { + this.sync.update('geometry', this.geometry, this._geometry_bk) + } } isReadOnly() { diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index 88ab9667..5d7e8eaf 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -16,7 +16,6 @@ import { Default as DefaultLayer } from '../rendering/layers/base.js' import { Categorized, Choropleth, Circles } from '../rendering/layers/classified.js' import { Cluster } from '../rendering/layers/cluster.js' import { Heat } from '../rendering/layers/heat.js' -import { ServerStored } from '../saving.js' import * as Schema from '../schema.js' import TableEditor from '../tableeditor.js' import * as Utils from '../utils.js' @@ -36,9 +35,8 @@ const LAYER_MAP = LAYER_TYPES.reduce((acc, klass) => { return acc }, {}) -export class DataLayer extends ServerStored { +export class DataLayer { constructor(umap, leafletMap, data = {}) { - super() this._umap = umap this.sync = umap.syncEngine.proxy(this) this._index = Array() @@ -49,7 +47,6 @@ export class DataLayer extends ServerStored { this._leafletMap = leafletMap this.parentPane = this._leafletMap.getPane('overlayPane') this.pane = this._leafletMap.createPane(`datalayer${stamp(this)}`, this.parentPane) - this.pane.dataset.id = stamp(this) // FIXME: should be on layer this.renderer = L.svg({ pane: this.pane }) this.defaultOptions = { @@ -66,6 +63,7 @@ export class DataLayer extends ServerStored { data.id = data.id || crypto.randomUUID() this.setOptions(data) + this.pane.dataset.id = this.id if (!Utils.isObject(this.options.remoteData)) { this.options.remoteData = {} @@ -114,7 +112,6 @@ export class DataLayer extends ServerStored { set isDeleted(status) { this._isDeleted = status - if (status) this.isDirty = status } get isDeleted() { @@ -269,13 +266,11 @@ export class DataLayer extends ServerStored { } clear() { - this.layer.clearLayers() - this._features = {} - this._index = Array() - if (this._geojson) { - this.backupData() - this._geojson = null + this.sync.startBatch() + for (const feature of Object.values(this._features)) { + feature.del() } + this.sync.commitBatch() this.dataChanged() } @@ -366,9 +361,8 @@ export class DataLayer extends ServerStored { } connectToMap() { - const id = stamp(this) - if (!this._umap.datalayers[id]) { - this._umap.datalayers[id] = this + if (!this._umap.datalayers[this.id]) { + this._umap.datalayers[this.id] = this } if (!this._umap.datalayersIndex.includes(this)) { this._umap.datalayersIndex.push(this) @@ -417,7 +411,10 @@ export class DataLayer extends ServerStored { removeFeature(feature, sync) { const id = stamp(feature) - if (sync !== false) feature.sync.delete() + if (sync !== false) { + const oldValue = feature.toGeoJSON() + feature.sync.delete(oldValue) + } this.hideFeature(feature) delete this._umap.featuresIndex[feature.getSlug()] feature.disconnectFromDataLayer(this) @@ -460,7 +457,10 @@ export class DataLayer extends ServerStored { try { // Do not fail if remote data is somehow invalid, // otherwise the layer becomes uneditable. - return this.makeFeatures(geojson, sync) + this.sync.startBatch() + const features = this.makeFeatures(geojson, sync) + this.sync.commitBatch() + return features } catch (err) { console.debug('Error with DataLayer', this.id) console.error(err) @@ -518,7 +518,7 @@ export class DataLayer extends ServerStored { } if (feature && !feature.isEmpty()) { this.addFeature(feature) - if (sync) feature.onCommit() + if (sync) feature.sync.upsert(feature.toGeoJSON(), null) return feature } } @@ -527,10 +527,6 @@ export class DataLayer extends ServerStored { return this._umap.formatter .parse(raw, format) .then((geojson) => this.addData(geojson)) - .then((data) => { - if (data?.length) this.isDirty = true - return data - }) .catch((error) => { console.debug(error) Alert.error(translate('Import failed: invalid data')) @@ -596,17 +592,17 @@ export class DataLayer extends ServerStored { } del(sync = true) { + const oldValue = Utils.CopyJSON(this.umapGeoJSON()) this.erase() if (sync) { this.isDeleted = true - this.sync.delete() + this.sync.delete(oldValue) } } empty() { if (this.isRemoteLayer()) return this.clear() - this.isDirty = true } clone() { @@ -630,25 +626,6 @@ export class DataLayer extends ServerStored { this.clear() } - reset() { - if (!this.createdOnServer) { - this.erase() - return - } - - this.resetOptions() - this.parentPane.appendChild(this.pane) - if (this._leaflet_events_bk && !this._leaflet_events) { - this._leaflet_events = this._leaflet_events_bk - } - this.clear() - this.hide() - if (this.isRemoteLayer()) this.fetchRemoteData() - else if (this._geojson_bk) this.fromGeoJSON(this._geojson_bk) - this.show() - this.isDirty = false - } - redraw() { if (!this.isVisible()) return this.eachFeature((feature) => feature.redraw()) @@ -940,11 +917,14 @@ export class DataLayer extends ServerStored { ) if (!error) { if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat. - if (geojson._umap_options) this.setOptions(geojson._umap_options) + if (geojson._umap_options) { + const oldOptions = Utils.CopyJSON(this.options) + this.setOptions(geojson._umap_options) + this.sync.update('options', this.options, oldOptions) + } this.empty() if (this.isRemoteLayer()) this.fetchRemoteData() else this.addData(geojson) - this.isDirty = true } }) } @@ -1098,7 +1078,11 @@ export class DataLayer extends ServerStored { setReferenceVersion({ response, sync }) { this._referenceVersion = response.headers.get('X-Datalayer-Version') - if (sync) this.sync.update('_referenceVersion', this._referenceVersion) + if (sync) { + this.sync.update('_referenceVersion', this._referenceVersion, null, { + undo: false, + }) + } } async save() { @@ -1127,6 +1111,10 @@ export class DataLayer extends ServerStored { } async _trySave(url, headers, formData) { + if (this._forceSave) { + headers = {} + this._forceSave = false + } const [data, response, error] = await this._umap.server.post(url, headers, formData) if (error) { if (response && response.status === 412) { @@ -1136,15 +1124,8 @@ export class DataLayer extends ServerStored { 'This situation is tricky, you have to choose carefully which version is pertinent.' ), async () => { - // Save again this layer - const status = await this._trySave(url, {}, formData) - if (status) { - this.isDirty = false - - // Call the main save, in case something else needs to be saved - // as the conflict stopped the saving flow - await this._umap.saveAll() - } + this._forceSave = true + await this._umap.saveAll() } ) } @@ -1179,7 +1160,7 @@ export class DataLayer extends ServerStored { } commitDelete() { - delete this._umap.datalayers[stamp(this)] + delete this._umap.datalayers[this.id] } getName() { diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js index da896af1..e41a7e3d 100644 --- a/umap/static/umap/js/modules/facets.js +++ b/umap/static/umap/js/modules/facets.js @@ -135,7 +135,13 @@ export default class Facets { for (const [property, { label, type }] of parsed) { dumped.push([property, label, type].filter(Boolean).join('|')) } - return dumped.join(',') + const oldValue = this._umap.properties.facetKey + this._umap.properties.facetKey = dumped.join(',') + this._umap.sync.update( + 'properties.facetKey', + this._umap.properties.facetKey, + oldValue + ) } has(property) { @@ -146,15 +152,13 @@ export default class Facets { const defined = this.getDefined() if (!defined.has(property)) { defined.set(property, { label, type }) - this._umap.properties.facetKey = this.dumps(defined) - this._umap.isDirty = true + this.dumps(defined) } } remove(property) { const defined = this.getDefined() defined.delete(property) - this._umap.properties.facetKey = this.dumps(defined) - this._umap.isDirty = true + this.dumps(defined) } } diff --git a/umap/static/umap/js/modules/form/builder.js b/umap/static/umap/js/modules/form/builder.js index b505c817..88698d39 100644 --- a/umap/static/umap/js/modules/form/builder.js +++ b/umap/static/umap/js/modules/form/builder.js @@ -70,21 +70,7 @@ export class Form extends Utils.WithEvents { } setter(field, value) { - const path = field.split('.') - let obj = this.obj - let what - for (let i = 0, l = path.length; i < l; i++) { - what = path[i] - if (what === path[l - 1]) { - if (typeof value === 'undefined') { - delete obj[what] - } else { - obj[what] = value - } - } else { - obj = obj[what] - } - } + Utils.setObjectValue(this.obj, field, value) } restoreField(field) { @@ -190,13 +176,17 @@ export class MutatingForm extends Form { } setter(field, value) { - super.setter(field, value) - this.obj.isDirty = true + const oldValue = this.getter(field) + if ('setter' in this.obj) { + this.obj.setter(field, value) + } else { + super.setter(field, value) + } if ('render' in this.obj) { this.obj.render([field], this) } if ('sync' in this.obj) { - this.obj.sync.update(field, value) + this.obj.sync.update(field, value, oldValue) } } diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js index 71abe87d..5a8be8d6 100644 --- a/umap/static/umap/js/modules/form/fields.js +++ b/umap/static/umap/js/modules/form/fields.js @@ -541,14 +541,14 @@ Fields.DataLayerSwitcher = class extends Fields.Select { !datalayer.isDataReadOnly() && datalayer.isBrowsable() ) { - options.push([L.stamp(datalayer), datalayer.getName()]) + options.push([datalayer.id, datalayer.getName()]) } }) return options } toHTML() { - return L.stamp(this.obj.datalayer) + return this.obj.datalayer.id } toJS() { diff --git a/umap/static/umap/js/modules/importer.js b/umap/static/umap/js/modules/importer.js index 83e09812..f2738f08 100644 --- a/umap/static/umap/js/modules/importer.js +++ b/umap/static/umap/js/modules/importer.js @@ -249,7 +249,7 @@ export default class Importer extends Utils.WithTemplate { tagName: 'option', parent: layerSelect, textContent: datalayer.options.name, - value: L.stamp(datalayer), + value: datalayer.id, }) } }) diff --git a/umap/static/umap/js/modules/permissions.js b/umap/static/umap/js/modules/permissions.js index ffaab4fa..04b0b0bd 100644 --- a/umap/static/umap/js/modules/permissions.js +++ b/umap/static/umap/js/modules/permissions.js @@ -2,17 +2,15 @@ import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' import { uMapAlert as Alert } from '../components/alerts/alert.js' import { MutatingForm } from './form/builder.js' import { translate } from './i18n.js' -import { ServerStored } from './saving.js' import * as Utils from './utils.js' // Dedicated object so we can deal with a separate dirty status, and thus // call the endpoint only when needed, saving one call at each save. -export class MapPermissions extends ServerStored { +export class MapPermissions { constructor(umap) { - super() this.setProperties(umap.properties.permissions) this._umap = umap - this._isDirty = false + this.sync = umap.syncEngine.proxy(this) } setProperties(properties) { @@ -28,6 +26,13 @@ export class MapPermissions extends ServerStored { ) } + getSyncMetadata() { + return { + subject: 'mappermissions', + metadata: {}, + } + } + render() { this._umap.render(['properties.permissions']) } @@ -188,7 +193,6 @@ export class MapPermissions extends ServerStored { } async save() { - if (!this.isDirty) return const formData = new FormData() if (!this.isAnonymousMap() && this.properties.editors) { const editors = this.properties.editors.map((u) => u.id) @@ -247,9 +251,8 @@ export class MapPermissions extends ServerStored { } } -export class DataLayerPermissions extends ServerStored { +export class DataLayerPermissions { constructor(umap, datalayer) { - super() this._umap = umap this.properties = Object.assign( { @@ -259,6 +262,14 @@ export class DataLayerPermissions extends ServerStored { ) this.datalayer = datalayer + this.sync = umap.syncEngine.proxy(this) + } + + getSyncMetadata() { + return { + subject: 'datalayerpermissions', + metadata: { id: this.datalayer.id }, + } } edit(container) { @@ -289,7 +300,6 @@ export class DataLayerPermissions extends ServerStored { } async save() { - if (!this.isDirty) return const formData = new FormData() formData.append('edit_status', this.properties.edit_status) const [data, response, error] = await this._umap.server.post( diff --git a/umap/static/umap/js/modules/rendering/layers/classified.js b/umap/static/umap/js/modules/rendering/layers/classified.js index a9b2cfb6..65b234bf 100644 --- a/umap/static/umap/js/modules/rendering/layers/classified.js +++ b/umap/static/umap/js/modules/rendering/layers/classified.js @@ -117,7 +117,7 @@ export const Choropleth = FeatureGroup.extend({ }, _getValue: function (feature) { - const key = this.datalayer.options.choropleth.property || 'value' + const key = this.datalayer.options.choropleth?.property || 'value' const value = +feature.properties[key] if (!Number.isNaN(value)) return value }, @@ -130,12 +130,12 @@ export const Choropleth = FeatureGroup.extend({ this.options.colors = [] return } - const mode = this.datalayer.options.choropleth.mode - let classes = +this.datalayer.options.choropleth.classes || 5 + const mode = this.datalayer.options.choropleth?.mode + let classes = +this.datalayer.options.choropleth?.classes || 5 let breaks classes = Math.min(classes, values.length) if (mode === 'manual') { - const manualBreaks = this.datalayer.options.choropleth.breaks + const manualBreaks = this.datalayer.options.choropleth?.breaks if (manualBreaks) { breaks = manualBreaks .split(',') diff --git a/umap/static/umap/js/modules/rendering/ui.js b/umap/static/umap/js/modules/rendering/ui.js index 1755c046..6f18bbda 100644 --- a/umap/static/umap/js/modules/rendering/ui.js +++ b/umap/static/umap/js/modules/rendering/ui.js @@ -97,7 +97,6 @@ const FeatureMixin = { }, onCommit: function () { - this.feature.pullGeometry(false) this.feature.onCommit() }, } @@ -112,7 +111,7 @@ const PointMixin = { this.on('dragend', (event) => { this.isDirty = true this.feature.edit(event) - this.feature.pullGeometry(false) + // this.feature.pullGeometry(false) }) if (!this.feature.isReadOnly()) this.on('mouseover', this._enableDragging) this.on('mouseout', this._onMouseOut) @@ -303,13 +302,13 @@ const PathMixin = { this._container = null FeatureMixin.onAdd.call(this, map) this.setStyle() - if (this.editing?.enabled()) this.editing.addHooks() + if (this.editor?.enabled()) this.editor.addHooks() this.resetTooltip() this._path.dataset.feature = this.feature.id }, onRemove: function (map) { - if (this.editing?.enabled()) this.editing.removeHooks() + if (this.editor?.enabled()) this.editor.removeHooks() FeatureMixin.onRemove.call(this, map) }, @@ -362,6 +361,13 @@ const PathMixin = { isOnScreen: function (bounds) { return bounds.overlaps(this.getBounds()) }, + + _setLatLngs: function (latlngs) { + this.parentClass.prototype._setLatLngs.call(this, latlngs) + if (this.editor?.enabled()) { + this.editor.reset() + } + }, } export const LeafletPolyline = Polyline.extend({ diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index a6c39ad1..4ecf3724 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -17,20 +17,10 @@ class Rule { this.parse() } - get isDirty() { - return this._isDirty - } - - set isDirty(status) { - this._isDirty = status - if (status) this._umap.isDirty = status - } - constructor(umap, condition = '', options = {}) { // TODO make this public properties when browser coverage is ok // cf https://caniuse.com/?search=public%20class%20field this._condition = null - this._isDirty = false this.OPERATORS = [ ['>', this.gt], ['<', this.lt], @@ -190,17 +180,25 @@ class Rule { _delete() { this._umap.rules.rules = this._umap.rules.rules.filter((rule) => rule !== this) + this._umap.rules.commit() + } + + setter(key, value) { + const oldRules = Utils.CopyJSON(this._umap.properties.rules || {}) + Utils.setObjectValue(this, key, value) + this._umap.rules.commit() + this._umap.sync.update('properties.rules', this._umap.properties.rules, oldRules) } } export default class Rules { constructor(umap) { this._umap = umap - this.rules = [] this.load() } load() { + this.rules = [] if (!this._umap.properties.rules?.length) return for (const { condition, options } of this._umap.properties.rules) { if (!condition) continue @@ -222,8 +220,8 @@ export default class Rules { else if (finalIndex > initialIndex) newIdx = referenceIdx else newIdx = referenceIdx + 1 this.rules.splice(newIdx, 0, moved) - moved.isDirty = true this._umap.render(['rules']) + this.commit() } edit(container) { @@ -242,7 +240,6 @@ export default class Rules { addRule() { const rule = new Rule(this._umap) - rule.isDirty = true this.rules.push(rule) rule.edit(map) } diff --git a/umap/static/umap/js/modules/saving.js b/umap/static/umap/js/modules/saving.js deleted file mode 100644 index 501fe19a..00000000 --- a/umap/static/umap/js/modules/saving.js +++ /dev/null @@ -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) {} -} diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js index d2142f8c..24a44d0b 100644 --- a/umap/static/umap/js/modules/schema.js +++ b/umap/static/umap/js/modules/schema.js @@ -44,6 +44,10 @@ export const SCHEMA = { type: Object, impacts: ['data'], }, + center: { + type: Object, + impacts: [], // default center, doesn't need any update of the map + }, color: { type: String, impacts: ['data'], @@ -118,6 +122,9 @@ export const SCHEMA = { default: false, label: translate('Animated transitions'), }, + edit_status: { + type: Number, + }, editinosmControl: { type: Boolean, impacts: ['ui'], @@ -125,6 +132,9 @@ export const SCHEMA = { label: translate('Display the control to open OpenStreetMap editor'), default: null, }, + editors: { + type: Array, + }, embedControl: { type: Boolean, impacts: ['ui'], @@ -362,6 +372,9 @@ export const SCHEMA = { type: Object, impacts: ['background'], }, + owner: { + type: Object, + }, permanentCredit: { type: 'Text', impacts: ['ui'], @@ -436,6 +449,9 @@ export const SCHEMA = { label: translate('Display the search control'), default: true, }, + share_status: { + type: Number, + }, shortCredit: { type: String, impacts: ['ui'], @@ -500,6 +516,9 @@ export const SCHEMA = { helpEntries: ['sync'], default: false, }, + team: { + type: Object, + }, tilelayer: { type: Object, impacts: ['background'], @@ -566,7 +585,6 @@ export const SCHEMA = { type: Object, impacts: ['data'], }, - _referenceVersion: { type: Number, impacts: ['data'], diff --git a/umap/static/umap/js/modules/sync/engine.js b/umap/static/umap/js/modules/sync/engine.js index 63af120e..b6f0d151 100644 --- a/umap/static/umap/js/modules/sync/engine.js +++ b/umap/static/umap/js/modules/sync/engine.js @@ -1,7 +1,13 @@ -import * as SaveManager from '../saving.js' import * as Utils from '../utils.js' import { HybridLogicalClock } from './hlc.js' -import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' +import { UndoManager } from './undo.js' +import { + DataLayerUpdater, + FeatureUpdater, + MapUpdater, + MapPermissionsUpdater, + DataLayerPermissionsUpdater, +} from './updaters.js' import { WebSocketTransport } from './websocket.js' // Start reconnecting after 2 seconds, then double the delay each time @@ -55,6 +61,8 @@ export class SyncEngine { map: new MapUpdater(umap), feature: new FeatureUpdater(umap), datalayer: new DataLayerUpdater(umap), + mappermissions: new MapPermissionsUpdater(umap), + datalayerpermissions: new DataLayerPermissionsUpdater(umap), } this.transport = undefined this._operations = new Operations() @@ -64,6 +72,7 @@ export class SyncEngine { this.websocketConnected = false this.closeRequested = false this.peerId = Utils.generateId() + this._undoManager = new UndoManager(umap, this.updaters, this) } get isOpen() { @@ -122,16 +131,107 @@ export class SyncEngine { await this.authenticate() }, this._reconnectDelay) } - upsert(subject, metadata, value) { - this._send({ verb: 'upsert', subject, metadata, value }) + + startBatch() { + this._batch = [] } - update(subject, metadata, key, value) { - this._send({ verb: 'update', subject, metadata, key, value }) + commitBatch(subject, metadata) { + if (!this._batch.length) { + this._batch = null + return + } + const operations = this._batch.map((stage) => stage.operation) + const operation = { verb: 'batch', operations, subject, metadata } + this._undoManager.add({ operation, stages: this._batch }) + this._send(operation) + this._batch = null } - delete(subject, metadata, key) { - this._send({ verb: 'delete', subject, metadata, key }) + upsert(subject, metadata, value, oldValue) { + const operation = { + verb: 'upsert', + subject, + metadata, + value, + } + const stage = { + operation, + newValue: value, + oldValue: oldValue, + } + if (this._batch) { + this._batch.push(stage) + return + } + this._undoManager.add(stage) + this._send(operation) + } + + update(subject, metadata, key, value, oldValue, { undo } = { undo: true }) { + const operation = { + verb: 'update', + subject, + metadata, + key, + value, + } + const stage = { + operation, + oldValue: oldValue, + newValue: value, + } + if (this._batch) { + this._batch.push(stage) + return + } + if (undo) this._undoManager.add(stage) + this._send(operation) + } + + delete(subject, metadata, oldValue) { + const operation = { + verb: 'delete', + subject, + metadata, + } + const stage = { + operation, + oldValue: oldValue, + } + if (this._batch) { + this._batch.push(stage) + return + } + this._undoManager.add(stage) + this._send(operation) + } + + async save() { + const needSave = new Map() + if (!this._umap.id) { + // There is no operation for first map save + needSave.set(this._umap, []) + } + for (const operation of this._operations.sorted()) { + if (operation.dirty) { + const updater = this._getUpdater(operation.subject) + const obj = updater.getStoredObject(operation.metadata) + if (!needSave.has(obj)) { + needSave.set(obj, []) + } + needSave.get(obj).push(operation) + } + } + for (const [obj, operations] of needSave.entries()) { + const ok = await obj.save() + if (!ok) break + for (const operation of operations) { + operation.dirty = false + } + } + this.saved() + this._undoManager.toggleState() } saved() { @@ -144,8 +244,8 @@ export class SyncEngine { } } - _send(inputMessage) { - const message = this._operations.addLocal(inputMessage) + _send(operation) { + const message = this._operations.addLocal(operation) if (this.offline) return if (this.transport) { @@ -153,7 +253,11 @@ export class SyncEngine { } } - _getUpdater(subject, metadata) { + _getUpdater(subject, metadata, sync) { + // For now, prevent permissions to be synced, for security reasons + if (sync && (subject === 'mappermissions' || subject === 'datalayerpermissions')) { + return + } if (Object.keys(this.updaters).includes(subject)) { return this.updaters[subject] } @@ -161,7 +265,15 @@ export class SyncEngine { } _applyOperation(operation) { + if (operation.verb === 'batch') { + operation.operations.map((op) => this._applyOperation(op)) + return + } const updater = this._getUpdater(operation.subject, operation.metadata) + if (!updater) { + debug('No updater for', operation) + return + } updater.applyMessage(operation) } @@ -304,9 +416,8 @@ export class SyncEngine { onSavedMessage({ sender, lastKnownHLC }) { debug(`received saved message from peer ${sender}`, lastKnownHLC) - if (lastKnownHLC === this._operations.getLastKnownHLC() && SaveManager.isDirty) { - SaveManager.clear() - } + this._operations.saved(lastKnownHLC) + this._undoManager.toggleState() } /** @@ -356,7 +467,7 @@ export class SyncEngine { const handler = { get(target, prop) { // Only proxy these methods - if (['upsert', 'update', 'delete'].includes(prop)) { + if (['upsert', 'update', 'delete', 'commitBatch'].includes(prop)) { const { subject, metadata } = object.getSyncMetadata() // Reflect.get is calling the original method. // .bind is adding the parameters automatically @@ -378,16 +489,22 @@ export class Operations { this._operations = new Array() } + saved(hlc) { + for (const operation of this.getOperationsBefore(hlc)) { + operation.dirty = false + } + } + /** * Tick the clock and store the passed message in the operations list. * * @param {*} inputMessage * @returns {*} clock-aware message */ - addLocal(inputMessage) { - const message = { ...inputMessage, hlc: this._hlc.tick() } - this._operations.push(message) - return message + addLocal(operation) { + operation.hlc = this._hlc.tick() + this._operations.push(operation) + return operation } /** @@ -445,6 +562,11 @@ export class Operations { return this._operations.filter((op) => op.hlc > hlc) } + getOperationsBefore(hlc) { + if (!hlc) return this._operations + return this._operations.filter((op) => op.hlc <= hlc) + } + /** * Returns the last known HLC value. */ diff --git a/umap/static/umap/js/modules/sync/undo.js b/umap/static/umap/js/modules/sync/undo.js new file mode 100644 index 00000000..47e0a3de --- /dev/null +++ b/umap/static/umap/js/modules/sync/undo.js @@ -0,0 +1,101 @@ +import * as Utils from '../utils.js' +import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' + +export class UndoManager { + constructor(umap, updaters, syncEngine) { + this._umap = umap + this._syncEngine = syncEngine + this.updaters = updaters + this._undoStack = [] + this._redoStack = [] + } + + toggleState() { + // document is undefined during unittests + if (typeof document === 'undefined') return + const undoButton = document.querySelector('.edit-undo') + const redoButton = document.querySelector('.edit-redo') + if (undoButton) undoButton.disabled = !this._undoStack.length + if (redoButton) redoButton.disabled = !this._redoStack.length + const dirty = this.isDirty() + document.body.classList.toggle('umap-is-dirty', dirty) + for (const button of document.querySelectorAll('.disabled-on-dirty')) { + button.disabled = dirty + } + for (const button of document.querySelectorAll('.enabled-on-dirty')) { + button.disabled = !dirty + } + } + + isDirty() { + if (!this._umap.id) return true + for (const stage of this._undoStack) { + if (stage.operation.dirty) return true + } + for (const stage of this._redoStack) { + if (stage.operation.dirty) return true + } + return false + } + + add(stage) { + stage.operation.dirty = true + this._redoStack = [] + this._undoStack.push(stage) + this.toggleState() + } + + copyOperation(stage, redo) { + const operation = Utils.CopyJSON(stage.operation) + const value = redo ? stage.newValue : stage.oldValue + operation.value = value + if (['delete', 'upsert'].includes(operation.verb)) { + operation.verb = value === null || value === undefined ? 'delete' : 'upsert' + } + return operation + } + + undo(redo = false) { + const fromStack = redo ? this._redoStack : this._undoStack + const toStack = redo ? this._undoStack : this._redoStack + const stage = fromStack.pop() + if (!stage) return + stage.operation.dirty = !stage.operation.dirty + if (stage.operation.verb === 'batch') { + for (const st of stage.stages) { + this.applyOperation(this.copyOperation(st, redo)) + } + } else { + this.applyOperation(this.copyOperation(stage, redo)) + } + toStack.push(stage) + this.toggleState() + } + + redo() { + this.undo(true) + } + + applyOperation(operation) { + const updater = this._getUpdater(operation.subject, operation.metadata) + switch (operation.verb) { + case 'update': + updater.update(operation) + break + case 'delete': + updater.delete(operation) + break + case 'upsert': + updater.upsert(operation) + break + } + this._syncEngine._send(operation) + } + + _getUpdater(subject, metadata) { + if (Object.keys(this.updaters).includes(subject)) { + return this.updaters[subject] + } + throw new Error(`Unknown updater ${subject}, ${metadata}`) + } +} diff --git a/umap/static/umap/js/modules/sync/updaters.js b/umap/static/umap/js/modules/sync/updaters.js index e6055091..07d96660 100644 --- a/umap/static/umap/js/modules/sync/updaters.js +++ b/umap/static/umap/js/modules/sync/updaters.js @@ -1,4 +1,4 @@ -import { fieldInSchema } from '../utils.js' +import * as Utils from '../utils.js' /** * Updaters are classes able to convert messages @@ -10,27 +10,6 @@ class BaseUpdater { this._umap = umap } - updateObjectValue(obj, key, value) { - const parts = key.split('.') - const lastKey = parts.pop() - - // Reduce the current list of attributes, - // to find the object to set the property onto - const objectToSet = parts.reduce((currentObj, part) => { - if (currentObj !== undefined && part in currentObj) return currentObj[part] - }, obj) - - // In case the given path doesn't exist, stop here - if (objectToSet === undefined) return - - // Set the value (or delete it) - if (typeof value === 'undefined') { - delete objectToSet[lastKey] - } else { - objectToSet[lastKey] = value - } - } - getDataLayerFromID(layerId) { return this._umap.getDataLayerByUmapId(layerId) } @@ -43,13 +22,17 @@ class BaseUpdater { export class MapUpdater extends BaseUpdater { update({ key, value }) { - if (fieldInSchema(key)) { - this.updateObjectValue(this._umap, key, value) + if (Utils.fieldInSchema(key)) { + Utils.setObjectValue(this._umap, key, value) } this._umap.onPropertiesUpdated([key]) this._umap.render([key]) } + + getStoredObject() { + return this._umap + } } export class DataLayerUpdater extends BaseUpdater { @@ -58,14 +41,21 @@ export class DataLayerUpdater extends BaseUpdater { try { this.getDataLayerFromID(value.id) } catch { - this._umap.createDataLayer(value, false) + const datalayer = this._umap.createDataLayer(value._umap_options || value, false) + if (value.features) { + // FIXME: this will create new stages in the undoStack, thus this will empty + // the redoStack + datalayer.addData(value) + } } } update({ key, metadata, value }) { const datalayer = this.getDataLayerFromID(metadata.id) - if (fieldInSchema(key)) { - this.updateObjectValue(datalayer, key, value) + if (key === 'options') { + datalayer.setOptions(value) + } else if (Utils.fieldInSchema(key)) { + Utils.setObjectValue(datalayer, key, value) } else { console.debug( 'Not applying update for datalayer because key is not in the schema', @@ -82,6 +72,10 @@ export class DataLayerUpdater extends BaseUpdater { datalayer.commitDelete() } } + + getStoredObject(metadata) { + return this.getDataLayerFromID(metadata.id) + } } export class FeatureUpdater extends BaseUpdater { @@ -114,7 +108,7 @@ export class FeatureUpdater extends BaseUpdater { const feature = this.getFeatureFromMetadata(metadata) feature.geometry = value } else { - this.updateObjectValue(feature, key, value) + Utils.setObjectValue(feature, key, value) feature.datalayer.indexProperties(feature) } @@ -127,4 +121,32 @@ export class FeatureUpdater extends BaseUpdater { const feature = this.getFeatureFromMetadata(metadata) if (feature) feature.del(false) } + + getStoredObject(metadata) { + return this.getDataLayerFromID(metadata.layerId) + } +} + +export class MapPermissionsUpdater extends BaseUpdater { + update({ key, value }) { + if (Utils.fieldInSchema(key)) { + Utils.setObjectValue(this._umap.permissions, key, value) + } + } + + getStoredObject(metadata) { + return this._umap.permissions + } +} + +export class DataLayerPermissionsUpdater extends BaseUpdater { + update({ key, value, metadata }) { + if (Utils.fieldInSchema(key)) { + Utils.setObjectValue(this.getDataLayerFromID(metadata.id), key, value) + } + } + + getStoredObject(metadata) { + return this.getDataLayerFromID(metadata.id).permissions + } } diff --git a/umap/static/umap/js/modules/ui/bar.js b/umap/static/umap/js/modules/ui/bar.js index f38616fa..04659db4 100644 --- a/umap/static/umap/js/modules/ui/bar.js +++ b/umap/static/umap/js/modules/ui/bar.js @@ -9,8 +9,16 @@ const TOP_BAR_TEMPLATE = ` uMap - - + + + + + ${translate('Undo')} + + + + ${translate('Redo')} + @@ -19,18 +27,14 @@ const TOP_BAR_TEMPLATE = ` - + ${translate('Help')} - - - ${translate('Cancel edits')} - - + - ${translate('View')} + ${translate('View')} - + ${translate('Save')} @@ -118,11 +122,12 @@ export class TopBar extends WithTemplate { }) this.elements.help.addEventListener('click', () => this._umap.help.showGetStarted()) - this.elements.cancel.addEventListener('click', () => this._umap.askForReset()) - this.elements.cancel.addEventListener('mouseover', () => { + this.elements.redo.addEventListener('click', () => this._umap.redo()) + this.elements.undo.addEventListener('click', () => this._umap.undo()) + this.elements.undo.addEventListener('mouseover', () => { this._umap.tooltip.open({ content: this._umap.help.displayLabel('CANCEL'), - anchor: this.elements.cancel, + anchor: this.elements.undo, position: 'bottom', delay: 500, duration: 5000, @@ -154,9 +159,10 @@ export class TopBar extends WithTemplate { redraw() { const syncEnabled = this._umap.getProperty('syncEnabled') this.elements.peers.hidden = !syncEnabled - this.elements.cancel.hidden = syncEnabled + this.elements.view.disabled = this._umap.sync._undoManager.isDirty() this.elements.saveLabel.hidden = this._umap.permissions.isDraft() this.elements.saveDraftLabel.hidden = !this._umap.permissions.isDraft() + this._umap.sync._undoManager.toggleState() } } @@ -270,10 +276,7 @@ export class EditBar extends WithTemplate { DomEvent.disableClickPropagation(this.element) this._onClick('marker', () => this._leafletMap.editTools.startMarker()) this._onClick('polyline', () => this._leafletMap.editTools.startPolyline()) - this._onClick('multiline', () => { - console.log('click click') - this._umap.editedFeature.ui.editor.newShape() - }) + this._onClick('multiline', () => this._umap.editedFeature.ui.editor.newShape()) this._onClick('polygon', () => this._leafletMap.editTools.startPolygon()) this._onClick('multipolygon', () => this._umap.editedFeature.ui.editor.newShape()) this._onClick('caption', () => this._umap.editCaption()) diff --git a/umap/static/umap/js/modules/umap.js b/umap/static/umap/js/modules/umap.js index c21df57f..82733692 100644 --- a/umap/static/umap/js/modules/umap.js +++ b/umap/static/umap/js/modules/umap.js @@ -22,8 +22,6 @@ import { MapPermissions } from './permissions.js' import { LeafletMap } from './rendering/map.js' import { Request, ServerRequest } from './request.js' import Rules from './rules.js' -import { ServerStored } from './saving.js' -import * as SAVEMANAGER from './saving.js' import { SCHEMA } from './schema.js' import Share from './share.js' import Slideshow from './slideshow.js' @@ -36,9 +34,8 @@ import Tooltip from './ui/tooltip.js' import URLs from './urls.js' import * as Utils from './utils.js' -export default class Umap extends ServerStored { +export default class Umap { constructor(element, geojson) { - super() // We need to call async function in the init process, // the init itself does not need to be awaited, but some calls // in the process must be blocker @@ -96,6 +93,10 @@ export default class Umap extends ServerStored { this._leafletMap.latLng(center) } + // Needed for permissions + this.syncEngine = new SyncEngine(this) + this.sync = this.syncEngine.proxy(this) + // Needed to render controls this.permissions = new MapPermissions(this) this.urls = new URLs(this.properties.urls) @@ -130,9 +131,6 @@ export default class Umap extends ServerStored { this.share = new Share(this) this.rules = new Rules(this) - this.syncEngine = new SyncEngine(this) - this.sync = this.syncEngine.proxy(this) - if (this.hasEditMode()) { this.editPanel = new EditPanel(this, this._leafletMap) this.fullPanel = new FullPanel(this, this._leafletMap) @@ -196,7 +194,6 @@ export default class Umap extends ServerStored { // Creation mode if (!this.id) { if (!this.properties.preview) { - this.isDirty = true this.enableEdit() } this._defaultExtent = true @@ -212,10 +209,14 @@ export default class Umap extends ServerStored { this.propagate() } - window.onbeforeunload = () => (this.editEnabled && SAVEMANAGER.isDirty) || null + window.onbeforeunload = () => (this.editEnabled && this.isDirty) || null this.backup() } + get isDirty() { + return this.sync._undoManager.isDirty() + } + get editedFeature() { return this._editedFeature } @@ -349,7 +350,7 @@ export default class Umap extends ServerStored { const items = [] if (this.hasEditMode()) { if (this.editEnabled) { - if (!SAVEMANAGER.isDirty) { + if (!this.isDirty) { items.push({ label: this.help.displayLabel('STOP_EDIT'), action: () => this.disableEdit(), @@ -543,19 +544,17 @@ export default class Umap extends ServerStored { let used = true switch (event.key) { case 'e': - if (!SAVEMANAGER.isDirty) this.disableEdit() + if (!this.isDirty) this.disableEdit() break case 's': - if (SAVEMANAGER.isDirty) this.saveAll() + if (this.isDirty) this.saveAll() break case 'z': if (Utils.isWritable(event.target)) { used = false break } - if (SAVEMANAGER.isDirty) { - this.askForReset() - } + this.sync._undoManager.undo() break case 'm': this._leafletMap.editTools.startMarker() @@ -671,10 +670,10 @@ export default class Umap extends ServerStored { } async saveAll() { - if (!SAVEMANAGER.isDirty) return + if (!this.isDirty) return if (this._defaultExtent) this._setCenterAndZoom() this.backup() - await SAVEMANAGER.save() + await this.sync.save() // Do a blind render for now, as we are not sure what could // have changed, we'll be more subtil when we'll remove the // save action @@ -685,7 +684,6 @@ export default class Umap extends ServerStored { Alert.success(translate('Map has been saved!')) }) } - this.sync.saved() this.fire('saved') } @@ -1019,35 +1017,36 @@ export default class Umap extends ServerStored { 'button', boundsButtons, translate('Use current bounds'), - function () { + () => { const bounds = this._leafletMap.getBounds() + const oldLimitBounds = { ...this.properties.limitBounds } this.properties.limitBounds.south = LeafletUtil.formatNum(bounds.getSouth()) this.properties.limitBounds.west = LeafletUtil.formatNum(bounds.getWest()) this.properties.limitBounds.north = LeafletUtil.formatNum(bounds.getNorth()) this.properties.limitBounds.east = LeafletUtil.formatNum(bounds.getEast()) boundsBuilder.fetchAll() - - this.sync.update(this, 'properties.limitBounds', this.properties.limitBounds) - this.isDirty = true + this.sync.update( + 'properties.limitBounds', + this.properties.limitBounds, + oldLimitBounds + ) this._leafletMap.handleLimitBounds() - }, - this - ) - DomUtil.createButton( - 'button', - boundsButtons, - translate('Empty'), - function () { - this.properties.limitBounds.south = null - this.properties.limitBounds.west = null - this.properties.limitBounds.north = null - this.properties.limitBounds.east = null - boundsBuilder.fetchAll() - this.isDirty = true - this._leafletMap.handleLimitBounds() - }, - this + } ) + DomUtil.createButton('button', boundsButtons, translate('Empty'), () => { + const oldLimitBounds = { ...this.properties.limitBounds } + this.properties.limitBounds.south = null + this.properties.limitBounds.west = null + this.properties.limitBounds.north = null + this.properties.limitBounds.east = null + boundsBuilder.fetchAll() + this._leafletMap.handleLimitBounds() + this.sync.update( + 'properties.limitBounds', + this.properties.limitBounds, + oldLimitBounds + ) + }) } _editSlideshow(container) { @@ -1160,23 +1159,7 @@ export default class Umap extends ServerStored { }) } - reset() { - if (this._leafletMap.editTools) this._leafletMap.editTools.stopDrawing() - this.resetProperties() - this.datalayersIndex = [].concat(this._datalayersIndex_bk) - // Iter over all datalayers, including deleted if any. - for (const datalayer of Object.values(this.datalayers)) { - if (datalayer.isDeleted) datalayer.connectToMap() - if (datalayer.isDirty) datalayer.reset() - } - this.ensurePanesOrder() - this._leafletMap.initTileLayers() - this.onDataLayersChanged() - this.isDirty = !this.id - } - async save() { - this.rules.commit() const geojson = { type: 'Feature', geometry: this.geometry(), @@ -1301,16 +1284,6 @@ export default class Umap extends ServerStored { this._leafletMap.fire(name) } - askForReset(e) { - if (this.getProperty('syncEnabled')) return - this.dialog - .confirm(translate('Are you sure you want to cancel your changes?')) - .then(() => { - this.reset() - this.disableEdit() - }) - } - async initSyncEngine() { // this.properties.websocketEnabled is set by the server admin if (this.properties.websocketEnabled === false) return @@ -1324,7 +1297,6 @@ export default class Umap extends ServerStored { getSyncMetadata() { return { - engine: this.sync, subject: 'map', } } @@ -1348,6 +1320,9 @@ export default class Umap extends ServerStored { this.bottomBar.redraw() break case 'data': + if (fields.includes('properties.rules')) { + this.rules.load() + } this.eachVisibleDataLayer((datalayer) => { datalayer.redraw() }) @@ -1522,7 +1497,7 @@ export default class Umap extends ServerStored { const form = builder.build() row.appendChild(form) row.classList.toggle('off', !datalayer.isVisible()) - row.dataset.id = stamp(datalayer) + row.dataset.id = datalayer.id }) const onReorder = (src, dst, initialIndex, finalIndex) => { const movedLayer = this.datalayers[src.dataset.id] @@ -1553,7 +1528,7 @@ export default class Umap extends ServerStored { } getDataLayerByUmapId(id) { - const datalayer = this.findDataLayer((d) => d.id === id) + const datalayer = this.datalayers[id] if (!datalayer) throw new Error(`Can't find datalayer with id ${id}`) return datalayer } @@ -1669,7 +1644,6 @@ export default class Umap extends ServerStored { ) this.render(fields) this._leafletMap._setDefaultCenter() - this.isDirty = true } importUmapFile(file) { @@ -1768,13 +1742,26 @@ export default class Umap extends ServerStored { } _setCenterAndZoom() { + const oldCenter = { ...this.properties.center } + const oldZoom = this.properties.zoom this.properties.center = this._leafletMap.getCenter() this.properties.zoom = this._leafletMap.getZoom() - this.isDirty = true this._defaultExtent = false + this.sync.startBatch() + this.sync.update('properties.center', this.properties.center, oldCenter) + this.sync.update('properties.zoom', this.properties.zoom, oldZoom) + this.sync.commitBatch() } getStaticPathFor(name) { return SCHEMA.iconUrl.default.replace('marker.svg', name) } + + undo() { + this.sync._undoManager.undo() + } + + redo() { + this.sync._undoManager.redo() + } } diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index 4c36abfe..58e3e1dd 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -484,6 +484,27 @@ export const debounce = (callback, wait) => { } } +export function setObjectValue(obj, key, value) { + const parts = key.split('.') + const lastKey = parts.pop() + + // Reduce the current list of attributes, + // to find the object to set the property onto + const objectToSet = parts.reduce((currentObj, part) => { + if (currentObj !== undefined && part in currentObj) return currentObj[part] + }, obj) + + // In case the given path doesn't exist, stop here + if (objectToSet === undefined) return + + // Set the value (or delete it) + if (typeof value === 'undefined') { + delete objectToSet[lastKey] + } else { + objectToSet[lastKey] = value + } +} + export const COLORS = [ 'Black', 'Navy', diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index e97ae5f5..9dbddbac 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -297,6 +297,7 @@ U.TileLayerChooser = L.Control.extend({ el, 'click', () => { + const oldTileLayer = this.map._umap.properties.tilelayer this.map.selectTileLayer(tilelayer) this.map._controls.tilelayers.setLayers() if (options?.edit) { @@ -304,7 +305,8 @@ U.TileLayerChooser = L.Control.extend({ this.map._umap.isDirty = true this.map._umap.sync.update( 'properties.tilelayer', - this.map._umap.properties.tilelayer + this.map._umap.properties.tilelayer, + oldTileLayer ) } }, @@ -606,7 +608,7 @@ U.Editable = L.Editable.extend({ this.on('editable:editing', (event) => { const feature = event.layer.feature feature.isDirty = true - feature.pullGeometry(false) + // feature.pullGeometry(false) }) this.on('editable:vertex:ctrlclick', (event) => { const index = event.vertex.getIndex() @@ -624,18 +626,20 @@ U.Editable = L.Editable.extend({ createPolyline: function (latlngs) { const datalayer = this._umap.defaultEditDataLayer() - const point = new U.LineString(this._umap, datalayer, { + const line = new U.LineString(this._umap, datalayer, { geometry: { type: 'LineString', coordinates: [] }, }) - return point.ui + line._just_married = true + return line.ui }, createPolygon: function (latlngs) { const datalayer = this._umap.defaultEditDataLayer() - const point = new U.Polygon(this._umap, datalayer, { + const poly = new U.Polygon(this._umap, datalayer, { geometry: { type: 'Polygon', coordinates: [] }, }) - return point.ui + poly._just_married = true + return poly.ui }, createMarker: function (latlng) { @@ -643,6 +647,7 @@ U.Editable = L.Editable.extend({ const point = new U.Point(this._umap, datalayer, { geometry: { type: 'Point', coordinates: [latlng.lng, latlng.lat] }, }) + point._just_married = true return point.ui }, @@ -734,6 +739,7 @@ U.Editable = L.Editable.extend({ // Leaflet.Editable will delete the drawn shape if invalid // (eg. line has only one drawn point) // So let's check if the layer has no more shape + event.layer.feature.pullGeometry(false) if (!event.layer.feature.hasGeom()) { event.layer.feature.del() } else { diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css index 998d8d12..64d036cd 100644 --- a/umap/static/umap/map.css +++ b/umap/static/umap/map.css @@ -986,7 +986,7 @@ a.umap-control-caption, .umap-main-edit-toolbox .umap-user span, .leaflet-container .leaflet-control-edit-save span, .leaflet-container .leaflet-control-edit-disable span, - .leaflet-container .leaflet-control-edit-cancel span { + .leaflet-container .edit-cancel span { display: none; } .umap-main-edit-toolbox .umap-help-button { diff --git a/umap/static/umap/unittests/sync.js b/umap/static/umap/unittests/sync.js index c4e34b4a..f1888441 100644 --- a/umap/static/umap/unittests/sync.js +++ b/umap/static/umap/unittests/sync.js @@ -49,63 +49,6 @@ describe('#dispatch', () => { }) }) -describe('Updaters', () => { - describe('BaseUpdater', () => { - let updater - let map - let obj - - beforeEach(() => { - map = {} - updater = new MapUpdater(map) - obj = {} - }) - it('should be able to set object properties', () => { - let obj = {} - updater.updateObjectValue(obj, 'foo', 'foo') - expect(obj).deep.equal({ foo: 'foo' }) - }) - - it('should be able to set object properties recursively on existing objects', () => { - let obj = { foo: {} } - updater.updateObjectValue(obj, 'foo.bar', 'foo') - expect(obj).deep.equal({ foo: { bar: 'foo' } }) - }) - - it('should be able to set object properties recursively on deep objects', () => { - let obj = { foo: { bar: { baz: {} } } } - updater.updateObjectValue(obj, 'foo.bar.baz.test', 'value') - expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } }) - }) - - it('should be able to replace object properties recursively on deep objects', () => { - let obj = { foo: { bar: { baz: { test: 'test' } } } } - updater.updateObjectValue(obj, 'foo.bar.baz.test', 'value') - expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } }) - }) - - it('should not set object properties recursively on non-existing objects', () => { - let obj = { foo: {} } - updater.updateObjectValue(obj, 'bar.bar', 'value') - - expect(obj).deep.equal({ foo: {} }) - }) - - it('should delete keys for undefined values', () => { - let obj = { foo: 'foo' } - updater.updateObjectValue(obj, 'foo', undefined) - - expect(obj).deep.equal({}) - }) - - it('should delete keys for undefined values, recursively', () => { - let obj = { foo: { bar: 'bar' } } - updater.updateObjectValue(obj, 'foo.bar', undefined) - - expect(obj).deep.equal({ foo: {} }) - }) - }) -}) describe('Operations', () => { describe('haveSameContext', () => { diff --git a/umap/static/umap/unittests/utils.js b/umap/static/umap/unittests/utils.js index fb06900b..e3681f2f 100644 --- a/umap/static/umap/unittests/utils.js +++ b/umap/static/umap/unittests/utils.js @@ -862,4 +862,51 @@ describe('Utils', () => { assert.equal(Utils.isObject(''), false) }) }) + + describe('setObjectValue', () => { + it('should be able to set object properties', () => { + let obj = {} + Utils.setObjectValue(obj, 'foo', 'foo') + expect(obj).deep.equal({ foo: 'foo' }) + }) + + it('should be able to set object properties recursively on existing objects', () => { + let obj = { foo: {} } + Utils.setObjectValue(obj, 'foo.bar', 'foo') + expect(obj).deep.equal({ foo: { bar: 'foo' } }) + }) + + it('should be able to set object properties recursively on deep objects', () => { + let obj = { foo: { bar: { baz: {} } } } + Utils.setObjectValue(obj, 'foo.bar.baz.test', 'value') + expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } }) + }) + + it('should be able to replace object properties recursively on deep objects', () => { + let obj = { foo: { bar: { baz: { test: 'test' } } } } + Utils.setObjectValue(obj, 'foo.bar.baz.test', 'value') + expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } }) + }) + + it('should not set object properties recursively on non-existing objects', () => { + let obj = { foo: {} } + Utils.setObjectValue(obj, 'bar.bar', 'value') + + expect(obj).deep.equal({ foo: {} }) + }) + + it('should delete keys for undefined values', () => { + let obj = { foo: 'foo' } + Utils.setObjectValue(obj, 'foo', undefined) + + expect(obj).deep.equal({}) + }) + + it('should delete keys for undefined values, recursively', () => { + let obj = { foo: { bar: 'bar' } } + Utils.setObjectValue(obj, 'foo.bar', undefined) + + expect(obj).deep.equal({ foo: {} }) + }) + }) }) diff --git a/umap/sync/payloads.py b/umap/sync/payloads.py index f5e167e4..319eb2e2 100644 --- a/umap/sync/payloads.py +++ b/umap/sync/payloads.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Union +from typing import List, Literal, Optional, Union from pydantic import BaseModel, Field, RootModel @@ -14,10 +14,11 @@ class OperationMessage(BaseModel): """Message sent from one peer to all the others""" kind: Literal["OperationMessage"] = "OperationMessage" - verb: Literal["upsert", "update", "delete"] + verb: Literal["upsert", "update", "delete", "batch"] subject: Literal["map", "datalayer", "feature"] metadata: Optional[dict] = None key: Optional[str] = None + operations: Optional[List] = None class PeerMessage(BaseModel): diff --git a/umap/tests/base.py b/umap/tests/base.py index 5e382981..59e64a76 100644 --- a/umap/tests/base.py +++ b/umap/tests/base.py @@ -18,6 +18,7 @@ DATALAYER_DATA = { "features": [ { "type": "Feature", + "id": "ExNTQ", "geometry": { "type": "Point", "coordinates": [14.68896484375, 48.55297816440071], @@ -41,7 +42,7 @@ class LicenceFactory(factory.django.DjangoModelFactory): class TileLayerFactory(factory.django.DjangoModelFactory): - name = "Test zoom layer" + name = "Test tilelayer" url_template = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" attribution = "Test layer attribution" diff --git a/umap/tests/integration/test_browser.py b/umap/tests/integration/test_browser.py index d6bd507c..cee5b27e 100644 --- a/umap/tests/integration/test_browser.py +++ b/umap/tests/integration/test_browser.py @@ -350,8 +350,7 @@ def test_should_redraw_list_on_feature_delete(live_server, openmap, page, bootst buttons.first.click() page.locator("dialog").get_by_role("button", name="OK").click() expect(buttons).to_have_count(2) - page.get_by_role("button", name="Cancel edits").click() - page.locator("dialog").get_by_role("button", name="OK").click() + page.get_by_role("button", name="Undo").click() expect(buttons).to_have_count(3) diff --git a/umap/tests/integration/test_conditional_rules.py b/umap/tests/integration/test_conditional_rules.py index e31a6480..37dabaff 100644 --- a/umap/tests/integration/test_conditional_rules.py +++ b/umap/tests/integration/test_conditional_rules.py @@ -261,6 +261,9 @@ def test_can_create_new_rule(live_server, page, openmap): page.get_by_title("AliceBlue").first.click() colors = getColors(markers) assert colors.count("rgb(240, 248, 255)") == 3 + page.get_by_role("button", name="Undo").click() + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 0 def test_can_deactive_rule_from_list(live_server, page, openmap): diff --git a/umap/tests/integration/test_edit_datalayer.py b/umap/tests/integration/test_edit_datalayer.py index e01c0ace..bd06ca87 100644 --- a/umap/tests/integration/test_edit_datalayer.py +++ b/umap/tests/integration/test_edit_datalayer.py @@ -64,8 +64,7 @@ def test_cancel_deleting_datalayer_should_restore( page.get_by_role("button", name="OK").click() expect(markers).to_have_count(0) expect(page.get_by_text("test datalayer")).to_be_hidden() - page.get_by_role("button", name="Cancel edits").click() - page.locator("dialog").get_by_role("button", name="OK").click() + page.get_by_role("button", name="Undo").click() expect(markers).to_have_count(1) expect(page.locator(".umap-browser").get_by_text("test datalayer")).to_be_visible() @@ -160,7 +159,6 @@ def test_can_create_new_datalayer(live_server, openmap, page, datalayer): page.locator('input[name="name"]').click() page.locator('input[name="name"]').fill("Layer A with a new name") expect(page.get_by_text("Layer A with a new name")).to_be_visible() - page.get_by_role("button", name="Save").click() with page.expect_response(re.compile(".*/datalayer/update/.*")): page.get_by_role("button", name="Save").click() assert DataLayer.objects.count() == 2 diff --git a/umap/tests/integration/test_edit_polygon.py b/umap/tests/integration/test_edit_polygon.py index a711a79b..4a8ddac8 100644 --- a/umap/tests/integration/test_edit_polygon.py +++ b/umap/tests/integration/test_edit_polygon.py @@ -117,8 +117,7 @@ def test_should_reset_style_on_cancel(live_server, openmap, page, bootstrap): expect(page.locator(".leaflet-overlay-pane path[fill='GoldenRod']")).to_have_count( 1 ) - page.get_by_role("button", name="Cancel edits").click() - page.locator("dialog").get_by_role("button", name="OK").click() + page.get_by_role("button", name="Undo").click() expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1) diff --git a/umap/tests/integration/test_optimistic_merge.py b/umap/tests/integration/test_optimistic_merge.py index 997b71fa..fc7c0bd0 100644 --- a/umap/tests/integration/test_optimistic_merge.py +++ b/umap/tests/integration/test_optimistic_merge.py @@ -292,9 +292,10 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm # Change name on page two and save page_two.locator(".leaflet-marker-icon").click(modifiers=["Shift"]) page_two.locator('input[name="name"]').fill("name from page two") + page_two.wait_for_timeout(300) # Time for the input debounce. # Map should be in dirty status - expect(page_two.get_by_text("Cancel edits")).to_be_visible() + expect(page_two.get_by_text("Save", exact=True)).to_be_enabled() with page_two.expect_response(re.compile(r".*/datalayer/update/.*")): page_two.get_by_role("button", name="Save").click() @@ -306,7 +307,7 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm # We should have an alert with some actions expect(page_two.get_by_text("Whoops! Other contributor(s) changed")).to_be_visible() # Map should still be in dirty status - expect(page_two.get_by_text("Cancel edits")).to_be_visible() + expect(page_two.get_by_text("Save", exact=True)).to_be_enabled() # Override data from page two with page_two.expect_response(re.compile(r".*/datalayer/update/.*")): @@ -317,4 +318,4 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm data = json.loads(Path(saved.geojson.path).read_text()) assert data["features"][0]["properties"]["name"] == "name from page two" # Map should not be in dirty status anymore - expect(page_two.get_by_text("Cancel edits")).to_be_hidden() + expect(page_two.get_by_text("Save", exact=True)).to_be_disabled() diff --git a/umap/tests/integration/test_save.py b/umap/tests/integration/test_save.py index bab3aa8e..c885d094 100644 --- a/umap/tests/integration/test_save.py +++ b/umap/tests/integration/test_save.py @@ -16,17 +16,15 @@ def test_reseting_map_would_remove_from_save_queue( page.on("request", register_request) page.locator('input[name="name"]').click() page.locator('input[name="name"]').fill("new name") - page.get_by_role("button", name="Cancel edits").click() - page.get_by_role("button", name="OK").click() + page.get_by_role("button", name="Undo").click() page.wait_for_timeout(500) - page.get_by_role("button", name="Edit").click() page.get_by_role("button", name="Manage layers").click() page.get_by_role("button", name="Edit", exact=True).click() page.locator('input[name="name"]').click() page.locator('input[name="name"]').fill("new datalayer name") page.wait_for_timeout(300) # Time of the Input debounce with page.expect_response(re.compile(".*/datalayer/update/.*")): - page.get_by_role("button", name="Save").click() + page.get_by_role("button", name="Save", exact=True).click() assert len(requests) == 1 assert requests == [ ( diff --git a/umap/tests/integration/test_undo_redo.py b/umap/tests/integration/test_undo_redo.py new file mode 100644 index 00000000..bbe738eb --- /dev/null +++ b/umap/tests/integration/test_undo_redo.py @@ -0,0 +1,267 @@ +import re +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +from umap.models import Map, TileLayer + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + +DATALAYER_DATA = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "name": "name poly", + }, + "id": "gyNzM", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [11.25, 53.585984], + [10.151367, 52.975108], + [12.689209, 52.167194], + [14.084473, 53.199452], + [12.634277, 53.618579], + [11.25, 53.585984], + [11.25, 53.585984], + ], + ], + }, + }, + ], +} + + +@pytest.fixture +def map_with_polygon(map, live_server): + map.settings["properties"]["zoom"] = 6 + map.settings["geometry"] = { + "type": "Point", + "coordinates": [8.429, 53.239], + } + map.edit_status = Map.ANONYMOUS + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA) + return map + + +def test_can_undo_redo_map_name_change(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + expect(page.locator(".edit-undo")).to_be_disabled() + expect(page.locator(".edit-redo")).to_be_disabled() + page.get_by_title("Edit map name and caption").click() + name_input = page.locator('.map-metadata input[name="name"]') + expect(name_input).to_be_visible() + name_input.click() + name_input.press("Control+a") + name_input.fill("New map name") + expect(page.locator(".edit-undo")).to_be_enabled() + expect(page.locator(".edit-redo")).to_be_disabled() + map_name = page.locator(".umap-main-edit-toolbox .map-name") + expect(map_name).to_have_text("New map name") + name_input.fill("New name again") + expect(map_name).to_have_text("New name again") + + page.locator(".edit-undo").click() + expect(map_name).to_have_text("New map name") + expect(page.locator(".edit-undo")).to_be_enabled() + expect(page.locator(".edit-redo")).to_be_enabled() + + page.locator(".edit-redo").click() + expect(map_name).to_have_text("New name again") + expect(page.locator(".edit-undo")).to_be_enabled() + expect(page.locator(".edit-redo")).to_be_disabled() + + page.locator(".edit-undo").click() + expect(map_name).to_have_text("New map name") + expect(page.locator(".edit-undo")).to_be_enabled() + expect(page.locator(".edit-redo")).to_be_enabled() + + +def test_can_undo_redo_layer_color_change( + page, map_with_polygon, live_server, tilelayer +): + page.goto(f"{live_server.url}{map_with_polygon.get_absolute_url()}?edit") + + expect(page.locator(".edit-undo")).to_be_disabled() + expect(page.locator(".edit-redo")).to_be_disabled() + page.get_by_role("button", name="Manage layers").click() + page.locator(".panel").get_by_title("Edit", exact=True).click() + page.get_by_text("Shape properties").click() + page.locator(".umap-field-color .define").click() + expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1) + page.get_by_title("DarkRed").first.click() + expect(page.locator(".leaflet-overlay-pane path[fill='DarkRed']")).to_have_count(1) + expect(page.locator(".edit-undo")).to_be_enabled() + expect(page.locator(".edit-redo")).to_be_disabled() + + page.locator(".edit-undo").click() + expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1) + expect(page.locator(".leaflet-overlay-pane path[fill='DarkRed']")).to_have_count(0) + expect(page.locator(".edit-undo")).to_be_disabled() + expect(page.locator(".edit-redo")).to_be_enabled() + + page.locator(".edit-redo").click() + expect(page.locator(".leaflet-overlay-pane path[fill='DarkRed']")).to_have_count(1) + expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(0) + expect(page.locator(".edit-undo")).to_be_enabled() + expect(page.locator(".edit-redo")).to_be_disabled() + + +def test_can_undo_redo_tilelayer_change(live_server, page, openmap, tilelayer): + TileLayer.objects.create( + url_template="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + attribution="OSM/Carto", + name="Black Tiles", + ) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + old_pattern = re.compile( + r"https://[abc]{1}.tile.openstreetmap.fr/osmfr/\d+/\d+/\d+.png" + ) + tiles = page.locator(".leaflet-tile-pane img") + expect(tiles.first).to_have_attribute("src", old_pattern) + + new_pattern = re.compile( + r"https://[abcd]{1}.basemaps.cartocdn.com/dark_all/\d+/\d+/\d+.png" + ) + page.get_by_role("button", name="Change tilelayers").click() + page.locator("li").filter(has_text="Black Tiles").get_by_role("img").click() + + tiles = page.locator(".leaflet-tile-pane img") + expect(tiles.first).to_have_attribute("src", new_pattern) + + page.locator(".edit-undo").click() + tiles = page.locator(".leaflet-tile-pane img") + expect(tiles.first).to_have_attribute("src", old_pattern) + + page.locator(".edit-redo").click() + tiles = page.locator(".leaflet-tile-pane img") + expect(tiles.first).to_have_attribute("src", new_pattern) + + +def test_can_undo_redo_marker_drag(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new") + + marker = page.locator(".leaflet-marker-icon") + map = page.locator("#map") + + # Create a marker + page.get_by_title("Draw a marker").click() + map.click(position={"x": 225, "y": 225}) + expect(marker).to_have_count(1) + + # Drag marker + old_bbox = marker.bounding_box() + marker.first.drag_to(map, target_position={"x": 250, "y": 250}) + assert marker.bounding_box() != old_bbox + + # Undo + page.locator(".edit-undo").click() + assert marker.bounding_box() == old_bbox + + # Redo + page.locator(".edit-redo").click() + assert marker.bounding_box() != old_bbox + + +def test_can_undo_redo_polygon_geometry_change(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new") + + # Click on the Draw a polygon button on a new map. + page.get_by_title("Draw a polygon").click() + + polygon = page.locator("path[fill='DarkBlue']") + expect(polygon).to_have_count(0) + + # Click on the map, it will create a polygon. + map = page.locator("#map") + map.click(position={"x": 200, "y": 200}) + map.click(position={"x": 100, "y": 200}) + map.click(position={"x": 100, "y": 100}) + map.click(position={"x": 100, "y": 100}) + + # It is created on peerA, and should be on peerB + expect(polygon).to_have_count(1) + old_bbox = polygon.bounding_box() + + edited_vertex = page.locator(".leaflet-middle-icon:nth-child(3)").first + edited_vertex.drag_to(map, target_position={"x": 250, "y": 250}) + page.keyboard.press("Escape") + + assert polygon.bounding_box() != old_bbox + + page.locator(".edit-undo").click() + assert polygon.bounding_box() == old_bbox + + page.locator(".edit-redo").click() + assert polygon.bounding_box() != old_bbox + + +def test_can_undo_redo_marker_create(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new") + + page.get_by_title("Open Browser").click() + marker = page.locator(".leaflet-marker-icon") + map = page.locator("#map") + + # Create a marker + page.get_by_title("Draw a marker").click() + map.click(position={"x": 600, "y": 100}) + expect(marker).to_have_count(1) + expect(page.locator(".panel .datalayer")).to_have_count(1) + + page.locator(".edit-undo").click() + expect(marker).to_have_count(0) + # Layer still exists + expect(page.locator(".panel .datalayer")).to_have_count(1) + + page.locator(".edit-undo").click() + expect(page.locator(".panel .datalayer")).to_have_count(0) + + page.locator(".edit-redo").click() + expect(page.locator(".panel .datalayer")).to_have_count(1) + + page.locator(".edit-redo").click() + expect(marker).to_have_count(1) + + +def test_undo_redo_import(live_server, page, tilelayer): + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("Open Browser").click() + + page.get_by_title("Import data").click() + file_input = page.locator("input[type='file']") + with page.expect_file_chooser() as fc_info: + file_input.click() + file_chooser = fc_info.value + path = Path(__file__).parent.parent / "fixtures/test_upload_data.json" + file_chooser.set_files(path) + page.get_by_role("button", name="Import data", exact=True).click() + # Close the import panel + page.keyboard.press("Escape") + + layers = page.locator(".umap-browser .datalayer") + expect(layers).to_have_count(1) + + features_count = page.locator(".umap-browser .datalayer-counter") + expect(features_count).to_have_text("(5)") + + page.locator(".edit-undo").click() + expect(features_count).to_be_hidden() + expect(layers).to_have_count(1) + + page.locator(".edit-undo").click() + expect(layers).to_have_count(0) + + page.locator(".edit-redo").click() + expect(layers).to_have_count(1) + + page.locator(".edit-redo").click() + expect(features_count).to_have_text("(5)") diff --git a/umap/tests/integration/test_websocket_sync.py b/umap/tests/integration/test_websocket_sync.py index 7c3c3f34..bae3c167 100644 --- a/umap/tests/integration/test_websocket_sync.py +++ b/umap/tests/integration/test_websocket_sync.py @@ -659,3 +659,73 @@ def test_should_sync_line_on_escape(new_page, asgi_live_server, tilelayer): expect(peerA.locator("path")).to_have_count(1) expect(peerB.locator("path")).to_have_count(1) + + +@pytest.mark.xdist_group(name="websockets") +def test_should_sync_datalayer_clear( + new_page, asgi_live_server, tilelayer, map, datalayer +): + map.settings["properties"]["syncEnabled"] = True + map.edit_status = Map.ANONYMOUS + map.save() + + # Create two tabs + peerA = new_page("Page A") + peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit") + peerB = new_page("Page B") + peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit") + expect(peerA.locator(".leaflet-marker-icon")).to_have_count(1) + expect(peerB.locator(".leaflet-marker-icon")).to_have_count(1) + + # Clear layer in peer A + peerA.get_by_role("button", name="Manage layers").click() + peerA.get_by_role("button", name="Edit", exact=True).click() + peerA.locator("summary").filter(has_text="Advanced actions").click() + peerA.get_by_role("button", name="Empty").click() + expect(peerA.locator(".leaflet-marker-icon")).to_have_count(0) + expect(peerB.locator(".leaflet-marker-icon")).to_have_count(0) + + # Undo in peer A + peerA.get_by_role("button", name="Undo").click() + expect(peerA.locator(".leaflet-marker-icon")).to_have_count(1) + expect(peerB.locator(".leaflet-marker-icon")).to_have_count(1) + + +@pytest.mark.xdist_group(name="websockets") +def test_should_save_remote_dirty_datalayers(new_page, asgi_live_server, tilelayer): + map = MapFactory(name="sync", edit_status=Map.ANONYMOUS) + map.settings["properties"]["syncEnabled"] = True + map.save() + + assert not DataLayer.objects.count() + + # Create two tabs + peerA = new_page("Page A") + peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit") + peerB = new_page("Page B") + peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit") + + # Create a new layer from peerA + peerA.get_by_role("button", name="Manage layers").click() + peerA.get_by_role("button", name="Add a layer").click() + + # Create a new layer from peerB + peerB.get_by_role("button", name="Manage layers").click() + peerB.get_by_role("button", name="Add a layer").click() + + # Save from peerA to the server + counter = 0 + + def on_response(response): + nonlocal counter + if "/datalayer/create/" in response.url: + counter += 1 + # Wait for the two datalayer saves + if counter == 2: + return True + return False + + with peerA.expect_response(on_response): + peerA.get_by_role("button", name="Save").click() + + assert DataLayer.objects.count() == 2 diff --git a/umap/tests/test_datalayer_s3.py b/umap/tests/test_datalayer_s3.py index fdebb214..f4d337e9 100644 --- a/umap/tests/test_datalayer_s3.py +++ b/umap/tests/test_datalayer_s3.py @@ -103,6 +103,7 @@ def test_get_version(map, datalayer): ], "type": "Point", }, + "id": "ExNTQ", "properties": { "_umap_options": { "color": "DarkCyan", diff --git a/umap/tests/test_map_views.py b/umap/tests/test_map_views.py index 24be6b80..d12045bc 100644 --- a/umap/tests/test_map_views.py +++ b/umap/tests/test_map_views.py @@ -694,6 +694,7 @@ def test_download(client, map, datalayer): "coordinates": [14.68896484375, 48.55297816440071], "type": "Point", }, + "id": "ExNTQ", "properties": { "_umap_options": {"color": "DarkCyan", "iconClass": "Ball"}, "description": "Da place anonymous again 755",