Batch operations

Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
Co-authored-by: David Larlet <david@larlet.fr>
This commit is contained in:
Yohan Boniface 2025-03-13 19:00:05 +01:00
parent cc2625bfac
commit cb46a5f875
5 changed files with 90 additions and 30 deletions

View file

@ -464,7 +464,10 @@ export class DataLayer extends ServerStored {
try { try {
// Do not fail if remote data is somehow invalid, // Do not fail if remote data is somehow invalid,
// otherwise the layer becomes uneditable. // 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) { } catch (err) {
console.debug('Error with DataLayer', this.id) console.debug('Error with DataLayer', this.id)
console.error(err) console.error(err)
@ -522,7 +525,7 @@ export class DataLayer extends ServerStored {
} }
if (feature && !feature.isEmpty()) { if (feature && !feature.isEmpty()) {
this.addFeature(feature) this.addFeature(feature)
if (sync) feature.onCommit() if (sync) feature.sync.upsert(feature.toGeoJSON(), null)
return feature return feature
} }
} }

View file

@ -124,38 +124,83 @@ export class SyncEngine {
await this.authenticate() await this.authenticate()
}, this._reconnectDelay) }, this._reconnectDelay)
} }
upsert(subject, metadata, value, oldValue) {
startBatch() {
this._batch = []
}
commitBatch() {
if (!this._batch.length) {
this._batch = null
return
}
this._undoManager.add({ this._undoManager.add({
verb: 'batch',
operations: this._batch,
})
const syncOperations = this._batch.map((operation) =>
this.convertToSyncOperation(operation)
)
this._send({ verb: 'batch', operations: syncOperations, subject: 'batch' })
this._batch = null
}
convertToSyncOperation(undoOperation) {
const syncOperation = { ...undoOperation, value: undoOperation.newValue }
delete syncOperation.oldValue
delete syncOperation.newValue
return syncOperation
}
upsert(subject, metadata, value, oldValue) {
const undoOperation = {
verb: 'upsert', verb: 'upsert',
subject, subject,
metadata, metadata,
oldValue: oldValue,
newValue: value, newValue: value,
}) oldValue: oldValue,
this._send({ verb: 'upsert', subject, metadata, value }) }
if (this._batch) {
this._batch.push(undoOperation)
return
}
this._undoManager.add(undoOperation)
const syncOperation = this.convertToSyncOperation(undoOperation)
this._send(syncOperation)
} }
update(subject, metadata, key, value, oldValue) { update(subject, metadata, key, value, oldValue) {
this._undoManager.add({ const undoOperation = {
verb: 'update', verb: 'update',
subject, subject,
metadata, metadata,
key, key,
oldValue: oldValue, oldValue: oldValue,
newValue: value, newValue: value,
}) }
this._send({ verb: 'update', subject, metadata, key, value }) if (this._batch) {
this._batch.push(undoOperation)
return
}
this._undoManager.add(undoOperation)
const syncOperation = this.convertToSyncOperation(undoOperation)
this._send(syncOperation)
} }
delete(subject, metadata, oldValue) { delete(subject, metadata, oldValue) {
console.log('oldValue', oldValue) const undoOperation = {
this._undoManager.add({
verb: 'delete', verb: 'delete',
subject, subject,
metadata, metadata,
oldValue: oldValue, oldValue: oldValue,
}) }
this._send({ verb: 'delete', subject, metadata }) if (this._batch) {
this._batch.push(undoOperation)
return
}
this._undoManager.add(undoOperation)
const syncOperation = this.convertToSyncOperation(undoOperation)
this._send(syncOperation)
} }
saved() { saved() {
@ -185,6 +230,10 @@ export class SyncEngine {
} }
_applyOperation(operation) { _applyOperation(operation) {
if (operation.verb === 'batch') {
operation.operations.map((op) => this._applyOperation(op))
return
}
const updater = this._getUpdater(operation.subject, operation.metadata) const updater = this._getUpdater(operation.subject, operation.metadata)
updater.applyMessage(operation) updater.applyMessage(operation)
} }

View file

@ -10,28 +10,38 @@ export class UndoManager {
} }
toggleState() { toggleState() {
document.querySelector('.edit-undo').disabled = !this._undoStack.length const undoButton = document.querySelector('.edit-undo')
document.querySelector('.edit-redo').disabled = !this._redoStack.length const redoButton = document.querySelector('.edit-redo')
if (undoButton) undoButton.disabled = !this._undoStack.length
if (redoButton) redoButton.disabled = !this._redoStack.length
} }
add(operation) { add(operation) {
console.debug('New entry in undo stack', operation)
this._redoStack = [] this._redoStack = []
this._undoStack.push(operation) this._undoStack.push(operation)
this.toggleState() this.toggleState()
} }
cleanOperation(operation, redo) {
const syncOperation = Utils.CopyJSON(operation)
delete syncOperation.oldValue
delete syncOperation.newValue
syncOperation.value = redo ? operation.newValue : operation.oldValue
return syncOperation
}
undo(redo = false) { undo(redo = false) {
const fromStack = redo ? this._redoStack : this._undoStack const fromStack = redo ? this._redoStack : this._undoStack
const toStack = redo ? this._undoStack : this._redoStack const toStack = redo ? this._undoStack : this._redoStack
const operation = fromStack.pop() const operation = fromStack.pop()
if (!operation) return if (!operation) return
const syncOperation = Utils.CopyJSON(operation) if (operation.verb === 'batch') {
console.log('old/new', syncOperation.oldValue, syncOperation.newValue) for (const op of operation.operations) {
delete syncOperation.oldValue this.applyOperation(this.cleanOperation(op, redo))
delete syncOperation.newValue }
syncOperation.value = redo ? operation.newValue : operation.oldValue } else {
this.applyOperation(syncOperation) this.applyOperation(this.cleanOperation(operation, redo))
}
toStack.push(operation) toStack.push(operation)
this.toggleState() this.toggleState()
} }
@ -49,12 +59,9 @@ export class UndoManager {
break break
case 'delete': case 'delete':
case 'upsert': case 'upsert':
console.log('undo upsert/delete', syncOperation.value)
if (syncOperation.value === null || syncOperation.value === undefined) { if (syncOperation.value === null || syncOperation.value === undefined) {
console.log('case delete')
updater.delete(syncOperation) updater.delete(syncOperation)
} else { } else {
console.log('case upsert')
updater.upsert(syncOperation) updater.upsert(syncOperation)
} }
this._syncEngine._send(syncOperation) this._syncEngine._send(syncOperation)

View file

@ -1,4 +1,4 @@
from typing import Literal, Optional, Union from typing import List, Literal, Optional, Union
from pydantic import BaseModel, Field, RootModel from pydantic import BaseModel, Field, RootModel
@ -14,10 +14,11 @@ class OperationMessage(BaseModel):
"""Message sent from one peer to all the others""" """Message sent from one peer to all the others"""
kind: Literal["OperationMessage"] = "OperationMessage" kind: Literal["OperationMessage"] = "OperationMessage"
verb: Literal["upsert", "update", "delete"] verb: Literal["upsert", "update", "delete", "batch"]
subject: Literal["map", "datalayer", "feature"] subject: Literal["map", "datalayer", "feature", "batch"]
metadata: Optional[dict] = None metadata: Optional[dict] = None
key: Optional[str] = None key: Optional[str] = None
operations: Optional[List] = None
class PeerMessage(BaseModel): class PeerMessage(BaseModel):

View file

@ -89,8 +89,8 @@ def test_can_undo_redo_layer_color_change(
): ):
page.goto(f"{live_server.url}{map_with_polygon.get_absolute_url()}?edit") 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-undo")).to_be_hidden()
expect(page.locator(".edit-redo")).to_be_disabled() expect(page.locator(".edit-redo")).to_be_hidden()
page.get_by_role("button", name="Manage layers").click() page.get_by_role("button", name="Manage layers").click()
page.locator(".panel").get_by_title("Edit", exact=True).click() page.locator(".panel").get_by_title("Edit", exact=True).click()
page.get_by_text("Shape properties").click() page.get_by_text("Shape properties").click()