From cc2625bfacdb20937af77a79eefc4b57da73b4bd Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 13 Mar 2025 11:39:49 +0100 Subject: [PATCH 01/23] wip: undo redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alexis Métaireau --- umap/static/umap/css/bar.css | 12 +- umap/static/umap/css/icon.css | 6 +- umap/static/umap/img/16-white.svg | 12 +- umap/static/umap/img/16.svg | 2 +- umap/static/umap/img/source/16-white.svg | 14 +- umap/static/umap/img/source/16.svg | 2 +- umap/static/umap/js/modules/data/features.js | 16 +- umap/static/umap/js/modules/data/layer.js | 9 +- umap/static/umap/js/modules/form/builder.js | 3 +- umap/static/umap/js/modules/rendering/ui.js | 14 +- umap/static/umap/js/modules/sync/engine.js | 32 ++- umap/static/umap/js/modules/sync/undo.js | 71 ++++++ umap/static/umap/js/modules/sync/updaters.js | 16 +- umap/static/umap/js/modules/ui/bar.js | 19 +- umap/static/umap/js/modules/umap.js | 9 + umap/static/umap/js/umap.controls.js | 15 +- umap/static/umap/map.css | 2 +- umap/tests/base.py | 2 +- umap/tests/integration/test_undo_redo.py | 231 +++++++++++++++++++ 19 files changed, 447 insertions(+), 40 deletions(-) create mode 100644 umap/static/umap/js/modules/sync/undo.js create mode 100644 umap/tests/integration/test_undo_redo.py diff --git a/umap/static/umap/css/bar.css b/umap/static/umap/css/bar.css index cf71e2c1..07356939 100644 --- a/umap/static/umap/css/bar.css +++ b/umap/static/umap/css/bar.css @@ -14,7 +14,8 @@ 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 { @@ -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,14 +78,16 @@ background: rgba(66, 236, 230, 0.10); } .leaflet-container .edit-save, -.leaflet-container .edit-cancel, +.leaflet-container .edit-undo, +.leaflet-container .edit-redo, .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.umap-is-dirty .edit-undo, +.umap-edit-enabled.umap-is-dirty .edit-redo { display: inline-block; } .umap-is-dirty .edit-disable { 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..8278d498 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,17 @@ class Feature { } pullGeometry(sync = true) { + const oldGeometry = Utils.CopyJSON(this._geometry) this.fromLatLngs(this._getLatLngs()) if (sync) { - this.sync.update('geometry', this.geometry) + console.log('sync geometry') + this.sync.update('geometry', this.geometry, oldGeometry) } } fromLatLngs(latlngs) { + console.log('fromLatLngs', latlngs) + this._geometry_bk = Utils.CopyJSON(this._geometry) this._geometry = this.convertLatLngs(latlngs) } @@ -145,8 +150,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..a645e11b 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -417,7 +417,11 @@ 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() + console.log('oldValue in removeFeature', oldValue) + feature.sync.delete(oldValue) + } this.hideFeature(feature) delete this._umap.featuresIndex[feature.getSlug()] feature.disconnectFromDataLayer(this) @@ -596,10 +600,11 @@ 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) } } diff --git a/umap/static/umap/js/modules/form/builder.js b/umap/static/umap/js/modules/form/builder.js index b505c817..d20d1770 100644 --- a/umap/static/umap/js/modules/form/builder.js +++ b/umap/static/umap/js/modules/form/builder.js @@ -190,13 +190,14 @@ export class MutatingForm extends Form { } setter(field, value) { + const oldValue = this.getter(field) super.setter(field, value) this.obj.isDirty = true 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/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/sync/engine.js b/umap/static/umap/js/modules/sync/engine.js index 63af120e..3e524b67 100644 --- a/umap/static/umap/js/modules/sync/engine.js +++ b/umap/static/umap/js/modules/sync/engine.js @@ -1,6 +1,7 @@ import * as SaveManager from '../saving.js' import * as Utils from '../utils.js' import { HybridLogicalClock } from './hlc.js' +import { UndoManager } from './undo.js' import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' import { WebSocketTransport } from './websocket.js' @@ -64,6 +65,7 @@ export class SyncEngine { this.websocketConnected = false this.closeRequested = false this.peerId = Utils.generateId() + this._undoManager = new UndoManager(this.updaters, this) } get isOpen() { @@ -122,16 +124,38 @@ export class SyncEngine { await this.authenticate() }, this._reconnectDelay) } - upsert(subject, metadata, value) { + upsert(subject, metadata, value, oldValue) { + this._undoManager.add({ + verb: 'upsert', + subject, + metadata, + oldValue: oldValue, + newValue: value, + }) this._send({ verb: 'upsert', subject, metadata, value }) } - update(subject, metadata, key, value) { + update(subject, metadata, key, value, oldValue) { + this._undoManager.add({ + verb: 'update', + subject, + metadata, + key, + oldValue: oldValue, + newValue: value, + }) this._send({ verb: 'update', subject, metadata, key, value }) } - delete(subject, metadata, key) { - this._send({ verb: 'delete', subject, metadata, key }) + delete(subject, metadata, oldValue) { + console.log('oldValue', oldValue) + this._undoManager.add({ + verb: 'delete', + subject, + metadata, + oldValue: oldValue, + }) + this._send({ verb: 'delete', subject, metadata }) } saved() { 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..52c61e06 --- /dev/null +++ b/umap/static/umap/js/modules/sync/undo.js @@ -0,0 +1,71 @@ +import * as Utils from '../utils.js' +import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' + +export class UndoManager { + constructor(updaters, syncEngine) { + this._syncEngine = syncEngine + this.updaters = updaters + this._undoStack = [] + this._redoStack = [] + } + + toggleState() { + document.querySelector('.edit-undo').disabled = !this._undoStack.length + document.querySelector('.edit-redo').disabled = !this._redoStack.length + } + + add(operation) { + console.debug('New entry in undo stack', operation) + this._redoStack = [] + this._undoStack.push(operation) + this.toggleState() + } + + undo(redo = false) { + const fromStack = redo ? this._redoStack : this._undoStack + const toStack = redo ? this._undoStack : this._redoStack + const operation = fromStack.pop() + if (!operation) return + const syncOperation = Utils.CopyJSON(operation) + console.log('old/new', syncOperation.oldValue, syncOperation.newValue) + delete syncOperation.oldValue + delete syncOperation.newValue + syncOperation.value = redo ? operation.newValue : operation.oldValue + this.applyOperation(syncOperation) + toStack.push(operation) + this.toggleState() + } + + redo() { + this.undo(true) + } + + applyOperation(syncOperation) { + const updater = this._getUpdater(syncOperation.subject, syncOperation.metadata) + switch (syncOperation.verb) { + case 'update': + updater.update(syncOperation) + this._syncEngine._send(syncOperation) + break + case 'delete': + case 'upsert': + console.log('undo upsert/delete', syncOperation.value) + if (syncOperation.value === null || syncOperation.value === undefined) { + console.log('case delete') + updater.delete(syncOperation) + } else { + console.log('case upsert') + updater.upsert(syncOperation) + } + this._syncEngine._send(syncOperation) + break + } + } + + _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..0ea66d2e 100644 --- a/umap/static/umap/js/modules/sync/updaters.js +++ b/umap/static/umap/js/modules/sync/updaters.js @@ -43,6 +43,7 @@ class BaseUpdater { export class MapUpdater extends BaseUpdater { update({ key, value }) { + console.log('updating', key, value) if (fieldInSchema(key)) { this.updateObjectValue(this._umap, key, value) } @@ -56,9 +57,17 @@ export class DataLayerUpdater extends BaseUpdater { upsert({ value }) { // Upsert only happens when a new datalayer is created. try { - this.getDataLayerFromID(value.id) + console.log( + 'found datalayer with id', + value.id, + this.getDataLayerFromID(value.id) + ) } catch { - this._umap.createDataLayer(value, false) + console.log('we are the fucking catch', value) + const datalayer = this._umap.createDataLayer(value._umap_options || value, false) + if (value.features) { + datalayer.addData(value) + } } } @@ -92,11 +101,14 @@ export class FeatureUpdater extends BaseUpdater { // Create or update an object at a specific position upsert({ metadata, value }) { + console.log('updater.upsert for', metadata, value) const { id, layerId } = metadata const datalayer = this.getDataLayerFromID(layerId) const feature = this.getFeatureFromMetadata(metadata) + console.log('feature', feature) if (feature) { + console.log('changing feature geometry') feature.geometry = value.geometry } else { datalayer.makeFeature(value, false) diff --git a/umap/static/umap/js/modules/ui/bar.js b/umap/static/umap/js/modules/ui/bar.js index f38616fa..484197fc 100644 --- a/umap/static/umap/js/modules/ui/bar.js +++ b/umap/static/umap/js/modules/ui/bar.js @@ -22,9 +22,13 @@ const TOP_BAR_TEMPLATE = ` - + - @@ -159,7 +159,7 @@ 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() } diff --git a/umap/static/umap/js/modules/umap.js b/umap/static/umap/js/modules/umap.js index a52e793a..6e9ca6d8 100644 --- a/umap/static/umap/js/modules/umap.js +++ b/umap/static/umap/js/modules/umap.js @@ -670,10 +670,10 @@ export default class Umap extends ServerStored { } async saveAll() { - if (!SAVEMANAGER.isDirty) return + // if (!SAVEMANAGER.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 @@ -684,7 +684,6 @@ export default class Umap extends ServerStored { Alert.success(translate('Map has been saved!')) }) } - this.sync.saved() this.fire('saved') } From 5cb7cb27381d45891c504636993cffbcef23a9fb Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 20 Mar 2025 17:09:44 +0100 Subject: [PATCH 06/23] fixup: make sure to toggle remote client state at save too Co-authored-by: David Larlet --- umap/static/umap/js/modules/sync/engine.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/umap/static/umap/js/modules/sync/engine.js b/umap/static/umap/js/modules/sync/engine.js index 8c1c2501..2e098efc 100644 --- a/umap/static/umap/js/modules/sync/engine.js +++ b/umap/static/umap/js/modules/sync/engine.js @@ -399,6 +399,7 @@ export class SyncEngine { onSavedMessage({ sender, lastKnownHLC }) { debug(`received saved message from peer ${sender}`, lastKnownHLC) this._operations.saved(lastKnownHLC) + this._undoManager.toggleState() } /** @@ -471,7 +472,7 @@ export class Operations { } saved(hlc) { - for (const operation of this.getOperationsSince(hlc)) { + for (const operation of this.getOperationsBefore(hlc)) { operation.dirty = false } } @@ -543,6 +544,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. */ From 093ed061c15e6f3d02585c5468a29fcc432f9197 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 21 Mar 2025 16:17:31 +0100 Subject: [PATCH 07/23] wip: tests pass Co-authored-by: David Larlet --- umap/static/umap/js/modules/data/features.js | 2 - umap/static/umap/js/modules/data/layer.js | 16 ++++---- umap/static/umap/js/modules/form/fields.js | 4 +- umap/static/umap/js/modules/importer.js | 2 +- umap/static/umap/js/modules/permissions.js | 16 ++++++++ umap/static/umap/js/modules/sync/engine.js | 39 ++++++++++++++----- umap/static/umap/js/modules/sync/undo.js | 9 +++-- umap/static/umap/js/modules/sync/updaters.js | 34 ++++++++++++---- umap/static/umap/js/modules/ui/bar.js | 7 +--- umap/static/umap/js/modules/umap.js | 15 ++++--- umap/static/umap/js/umap.controls.js | 1 - umap/sync/payloads.py | 2 +- umap/tests/integration/test_edit_datalayer.py | 1 - .../integration/test_optimistic_merge.py | 7 ++-- umap/tests/integration/test_save.py | 3 +- umap/tests/integration/test_undo_redo.py | 4 +- 16 files changed, 104 insertions(+), 58 deletions(-) diff --git a/umap/static/umap/js/modules/data/features.js b/umap/static/umap/js/modules/data/features.js index 8278d498..cd04dba3 100644 --- a/umap/static/umap/js/modules/data/features.js +++ b/umap/static/umap/js/modules/data/features.js @@ -108,13 +108,11 @@ class Feature { const oldGeometry = Utils.CopyJSON(this._geometry) this.fromLatLngs(this._getLatLngs()) if (sync) { - console.log('sync geometry') this.sync.update('geometry', this.geometry, oldGeometry) } } fromLatLngs(latlngs) { - console.log('fromLatLngs', latlngs) this._geometry_bk = Utils.CopyJSON(this._geometry) this._geometry = this.convertLatLngs(latlngs) } diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index 26a767ef..793e5c78 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -49,7 +49,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 +65,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 = {} @@ -366,9 +366,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) @@ -419,7 +418,6 @@ export class DataLayer extends ServerStored { const id = stamp(feature) if (sync !== false) { const oldValue = feature.toGeoJSON() - console.log('oldValue in removeFeature', oldValue) feature.sync.delete(oldValue) } this.hideFeature(feature) @@ -460,13 +458,13 @@ export class DataLayer extends ServerStored { .sort(Utils.naturalSort) } - addData(geojson, sync) { + addData(geojson, sync, batch = true) { try { // Do not fail if remote data is somehow invalid, // otherwise the layer becomes uneditable. - this.sync.startBatch() + if (batch) this.sync.startBatch() const features = this.makeFeatures(geojson, sync) - this.sync.commitBatch() + if (batch) this.sync.commitBatch() return features } catch (err) { console.debug('Error with DataLayer', this.id) @@ -1187,7 +1185,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/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..d4eca1e0 100644 --- a/umap/static/umap/js/modules/permissions.js +++ b/umap/static/umap/js/modules/permissions.js @@ -13,6 +13,7 @@ export class MapPermissions extends ServerStored { this.setProperties(umap.properties.permissions) this._umap = umap this._isDirty = false + this.sync = umap.syncEngine.proxy(this) } setProperties(properties) { @@ -28,6 +29,13 @@ export class MapPermissions extends ServerStored { ) } + getSyncMetadata() { + return { + subject: 'mappermissions', + metadata: {}, + } + } + render() { this._umap.render(['properties.permissions']) } @@ -259,6 +267,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) { diff --git a/umap/static/umap/js/modules/sync/engine.js b/umap/static/umap/js/modules/sync/engine.js index 2e098efc..a12c01a5 100644 --- a/umap/static/umap/js/modules/sync/engine.js +++ b/umap/static/umap/js/modules/sync/engine.js @@ -2,7 +2,13 @@ import * as SaveManager from '../saving.js' import * as Utils from '../utils.js' import { HybridLogicalClock } from './hlc.js' import { UndoManager } from './undo.js' -import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.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 @@ -56,6 +62,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() @@ -129,17 +137,15 @@ export class SyncEngine { this._batch = [] } - commitBatch() { + commitBatch(subject, metadata) { if (!this._batch.length) { this._batch = null return } - this._undoManager.add({ - verb: 'batch', - operations: this._batch, - }) const operations = this._batch.map((stage) => stage.operation) - this._send({ verb: 'batch', operations: operations, subject: 'batch' }) + const operation = { verb: 'batch', operations, subject, metadata } + this._undoManager.add({ operation, stages: this._batch }) + this._send(operation) this._batch = null } @@ -204,6 +210,10 @@ export class SyncEngine { async save() { const needSave = new Map() + if (!this._umap.id) { + // There is no operation for fist map save + needSave.set(this._umap, []) + } for (const operation of this._operations.sorted()) { if (operation.dirty) { const updater = this._getUpdater(operation.subject) @@ -215,7 +225,8 @@ export class SyncEngine { } } for (const [obj, operations] of needSave.entries()) { - await obj.save() + const ok = await obj.save() + if (!ok) break for (const operation of operations) { operation.dirty = false } @@ -243,7 +254,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] } @@ -256,6 +271,10 @@ export class SyncEngine { return } const updater = this._getUpdater(operation.subject, operation.metadata) + if (!updater) { + debug('No updater for', operation) + return + } updater.applyMessage(operation) } @@ -449,7 +468,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 diff --git a/umap/static/umap/js/modules/sync/undo.js b/umap/static/umap/js/modules/sync/undo.js index a9e7af2e..8ee57890 100644 --- a/umap/static/umap/js/modules/sync/undo.js +++ b/umap/static/umap/js/modules/sync/undo.js @@ -19,6 +19,9 @@ export class UndoManager { 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() { @@ -33,7 +36,7 @@ export class UndoManager { add(stage) { // FIXME make it more generic - if (stage.operation.key !== '_referenceVersion') { + if (!stage.operation || stage.operation.key !== '_referenceVersion') { stage.operation.dirty = true this._redoStack = [] this._undoStack.push(stage) @@ -54,8 +57,8 @@ export class UndoManager { if (!stage) return stage.operation.dirty = !stage.operation.dirty if (stage.operation.verb === 'batch') { - for (const op of stage.operations) { - this.applyOperation(this.copyOperation(op, redo)) + for (const st of stage.stages) { + this.applyOperation(this.copyOperation(st, redo)) } } else { this.applyOperation(this.copyOperation(stage, redo)) diff --git a/umap/static/umap/js/modules/sync/updaters.js b/umap/static/umap/js/modules/sync/updaters.js index 082901bf..606ea63f 100644 --- a/umap/static/umap/js/modules/sync/updaters.js +++ b/umap/static/umap/js/modules/sync/updaters.js @@ -43,7 +43,6 @@ class BaseUpdater { export class MapUpdater extends BaseUpdater { update({ key, value }) { - console.log('updating', key, value) if (fieldInSchema(key)) { this.updateObjectValue(this._umap, key, value) } @@ -61,16 +60,11 @@ export class DataLayerUpdater extends BaseUpdater { upsert({ value }) { // Upsert only happens when a new datalayer is created. try { - console.log( - 'found datalayer with id', - value.id, - this.getDataLayerFromID(value.id) - ) + this.getDataLayerFromID(value.id) } catch { - console.log('we are the fucking catch', value) const datalayer = this._umap.createDataLayer(value._umap_options || value, false) if (value.features) { - datalayer.addData(value) + datalayer.addData(value, true, false) } } } @@ -149,3 +143,27 @@ export class FeatureUpdater extends BaseUpdater { return this.getDataLayerFromID(metadata.layerId) } } + +export class MapPermissionsUpdater extends BaseUpdater { + update({ key, value }) { + this.updateObjectValue(this._umap.permissions, key, value) + // if (fieldInSchema(key)) { + // } + } + + getStoredObject(metadata) { + return this._umap.permissions + } +} + +export class DataLayerPermissionsUpdater extends BaseUpdater { + update({ key, value, metadata }) { + this.updateObjectValue(this.getDataLayerFromID(metadata.id), key, value) + // if (fieldInSchema(key)) { + // } + } + + 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 28eb3677..d54ea866 100644 --- a/umap/static/umap/js/modules/ui/bar.js +++ b/umap/static/umap/js/modules/ui/bar.js @@ -34,7 +34,7 @@ const TOP_BAR_TEMPLATE = ` ${translate('View')} - + +
- - - + +
@@ -27,12 +27,12 @@ const TOP_BAR_TEMPLATE = `