wip: tests pass

Co-authored-by: David Larlet <david@larlet.fr>
This commit is contained in:
Yohan Boniface 2025-03-21 16:17:31 +01:00
parent 5cb7cb2738
commit 093ed061c1
16 changed files with 104 additions and 58 deletions

View file

@ -108,13 +108,11 @@ class Feature {
const oldGeometry = Utils.CopyJSON(this._geometry) const oldGeometry = Utils.CopyJSON(this._geometry)
this.fromLatLngs(this._getLatLngs()) this.fromLatLngs(this._getLatLngs())
if (sync) { if (sync) {
console.log('sync geometry')
this.sync.update('geometry', this.geometry, oldGeometry) this.sync.update('geometry', this.geometry, oldGeometry)
} }
} }
fromLatLngs(latlngs) { fromLatLngs(latlngs) {
console.log('fromLatLngs', latlngs)
this._geometry_bk = Utils.CopyJSON(this._geometry) this._geometry_bk = Utils.CopyJSON(this._geometry)
this._geometry = this.convertLatLngs(latlngs) this._geometry = this.convertLatLngs(latlngs)
} }

View file

@ -49,7 +49,6 @@ export class DataLayer extends ServerStored {
this._leafletMap = leafletMap this._leafletMap = leafletMap
this.parentPane = this._leafletMap.getPane('overlayPane') this.parentPane = this._leafletMap.getPane('overlayPane')
this.pane = this._leafletMap.createPane(`datalayer${stamp(this)}`, this.parentPane) this.pane = this._leafletMap.createPane(`datalayer${stamp(this)}`, this.parentPane)
this.pane.dataset.id = stamp(this)
// FIXME: should be on layer // FIXME: should be on layer
this.renderer = L.svg({ pane: this.pane }) this.renderer = L.svg({ pane: this.pane })
this.defaultOptions = { this.defaultOptions = {
@ -66,6 +65,7 @@ export class DataLayer extends ServerStored {
data.id = data.id || crypto.randomUUID() data.id = data.id || crypto.randomUUID()
this.setOptions(data) this.setOptions(data)
this.pane.dataset.id = this.id
if (!Utils.isObject(this.options.remoteData)) { if (!Utils.isObject(this.options.remoteData)) {
this.options.remoteData = {} this.options.remoteData = {}
@ -366,9 +366,8 @@ export class DataLayer extends ServerStored {
} }
connectToMap() { connectToMap() {
const id = stamp(this) if (!this._umap.datalayers[this.id]) {
if (!this._umap.datalayers[id]) { this._umap.datalayers[this.id] = this
this._umap.datalayers[id] = this
} }
if (!this._umap.datalayersIndex.includes(this)) { if (!this._umap.datalayersIndex.includes(this)) {
this._umap.datalayersIndex.push(this) this._umap.datalayersIndex.push(this)
@ -419,7 +418,6 @@ export class DataLayer extends ServerStored {
const id = stamp(feature) const id = stamp(feature)
if (sync !== false) { if (sync !== false) {
const oldValue = feature.toGeoJSON() const oldValue = feature.toGeoJSON()
console.log('oldValue in removeFeature', oldValue)
feature.sync.delete(oldValue) feature.sync.delete(oldValue)
} }
this.hideFeature(feature) this.hideFeature(feature)
@ -460,13 +458,13 @@ export class DataLayer extends ServerStored {
.sort(Utils.naturalSort) .sort(Utils.naturalSort)
} }
addData(geojson, sync) { addData(geojson, sync, batch = true) {
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.
this.sync.startBatch() if (batch) this.sync.startBatch()
const features = this.makeFeatures(geojson, sync) const features = this.makeFeatures(geojson, sync)
this.sync.commitBatch() if (batch) this.sync.commitBatch()
return features return features
} catch (err) { } catch (err) {
console.debug('Error with DataLayer', this.id) console.debug('Error with DataLayer', this.id)
@ -1187,7 +1185,7 @@ export class DataLayer extends ServerStored {
} }
commitDelete() { commitDelete() {
delete this._umap.datalayers[stamp(this)] delete this._umap.datalayers[this.id]
} }
getName() { getName() {

View file

@ -541,14 +541,14 @@ Fields.DataLayerSwitcher = class extends Fields.Select {
!datalayer.isDataReadOnly() && !datalayer.isDataReadOnly() &&
datalayer.isBrowsable() datalayer.isBrowsable()
) { ) {
options.push([L.stamp(datalayer), datalayer.getName()]) options.push([datalayer.id, datalayer.getName()])
} }
}) })
return options return options
} }
toHTML() { toHTML() {
return L.stamp(this.obj.datalayer) return this.obj.datalayer.id
} }
toJS() { toJS() {

View file

@ -249,7 +249,7 @@ export default class Importer extends Utils.WithTemplate {
tagName: 'option', tagName: 'option',
parent: layerSelect, parent: layerSelect,
textContent: datalayer.options.name, textContent: datalayer.options.name,
value: L.stamp(datalayer), value: datalayer.id,
}) })
} }
}) })

View file

@ -13,6 +13,7 @@ export class MapPermissions extends ServerStored {
this.setProperties(umap.properties.permissions) this.setProperties(umap.properties.permissions)
this._umap = umap this._umap = umap
this._isDirty = false this._isDirty = false
this.sync = umap.syncEngine.proxy(this)
} }
setProperties(properties) { setProperties(properties) {
@ -28,6 +29,13 @@ export class MapPermissions extends ServerStored {
) )
} }
getSyncMetadata() {
return {
subject: 'mappermissions',
metadata: {},
}
}
render() { render() {
this._umap.render(['properties.permissions']) this._umap.render(['properties.permissions'])
} }
@ -259,6 +267,14 @@ export class DataLayerPermissions extends ServerStored {
) )
this.datalayer = datalayer this.datalayer = datalayer
this.sync = umap.syncEngine.proxy(this)
}
getSyncMetadata() {
return {
subject: 'datalayerpermissions',
metadata: { id: this.datalayer.id },
}
} }
edit(container) { edit(container) {

View file

@ -2,7 +2,13 @@ import * as SaveManager from '../saving.js'
import * as Utils from '../utils.js' import * as Utils from '../utils.js'
import { HybridLogicalClock } from './hlc.js' import { HybridLogicalClock } from './hlc.js'
import { UndoManager } from './undo.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' import { WebSocketTransport } from './websocket.js'
// Start reconnecting after 2 seconds, then double the delay each time // Start reconnecting after 2 seconds, then double the delay each time
@ -56,6 +62,8 @@ export class SyncEngine {
map: new MapUpdater(umap), map: new MapUpdater(umap),
feature: new FeatureUpdater(umap), feature: new FeatureUpdater(umap),
datalayer: new DataLayerUpdater(umap), datalayer: new DataLayerUpdater(umap),
mappermissions: new MapPermissionsUpdater(umap),
datalayerpermissions: new DataLayerPermissionsUpdater(umap),
} }
this.transport = undefined this.transport = undefined
this._operations = new Operations() this._operations = new Operations()
@ -129,17 +137,15 @@ export class SyncEngine {
this._batch = [] this._batch = []
} }
commitBatch() { commitBatch(subject, metadata) {
if (!this._batch.length) { if (!this._batch.length) {
this._batch = null this._batch = null
return return
} }
this._undoManager.add({
verb: 'batch',
operations: this._batch,
})
const operations = this._batch.map((stage) => stage.operation) 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 this._batch = null
} }
@ -204,6 +210,10 @@ export class SyncEngine {
async save() { async save() {
const needSave = new Map() 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()) { for (const operation of this._operations.sorted()) {
if (operation.dirty) { if (operation.dirty) {
const updater = this._getUpdater(operation.subject) const updater = this._getUpdater(operation.subject)
@ -215,7 +225,8 @@ export class SyncEngine {
} }
} }
for (const [obj, operations] of needSave.entries()) { for (const [obj, operations] of needSave.entries()) {
await obj.save() const ok = await obj.save()
if (!ok) break
for (const operation of operations) { for (const operation of operations) {
operation.dirty = false 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)) { if (Object.keys(this.updaters).includes(subject)) {
return this.updaters[subject] return this.updaters[subject]
} }
@ -256,6 +271,10 @@ export class SyncEngine {
return return
} }
const updater = this._getUpdater(operation.subject, operation.metadata) const updater = this._getUpdater(operation.subject, operation.metadata)
if (!updater) {
debug('No updater for', operation)
return
}
updater.applyMessage(operation) updater.applyMessage(operation)
} }
@ -449,7 +468,7 @@ export class SyncEngine {
const handler = { const handler = {
get(target, prop) { get(target, prop) {
// Only proxy these methods // Only proxy these methods
if (['upsert', 'update', 'delete'].includes(prop)) { if (['upsert', 'update', 'delete', 'commitBatch'].includes(prop)) {
const { subject, metadata } = object.getSyncMetadata() const { subject, metadata } = object.getSyncMetadata()
// Reflect.get is calling the original method. // Reflect.get is calling the original method.
// .bind is adding the parameters automatically // .bind is adding the parameters automatically

View file

@ -19,6 +19,9 @@ export class UndoManager {
for (const button of document.querySelectorAll('.disabled-on-dirty')) { for (const button of document.querySelectorAll('.disabled-on-dirty')) {
button.disabled = dirty button.disabled = dirty
} }
for (const button of document.querySelectorAll('.enabled-on-dirty')) {
button.disabled = !dirty
}
} }
isDirty() { isDirty() {
@ -33,7 +36,7 @@ export class UndoManager {
add(stage) { add(stage) {
// FIXME make it more generic // FIXME make it more generic
if (stage.operation.key !== '_referenceVersion') { if (!stage.operation || stage.operation.key !== '_referenceVersion') {
stage.operation.dirty = true stage.operation.dirty = true
this._redoStack = [] this._redoStack = []
this._undoStack.push(stage) this._undoStack.push(stage)
@ -54,8 +57,8 @@ export class UndoManager {
if (!stage) return if (!stage) return
stage.operation.dirty = !stage.operation.dirty stage.operation.dirty = !stage.operation.dirty
if (stage.operation.verb === 'batch') { if (stage.operation.verb === 'batch') {
for (const op of stage.operations) { for (const st of stage.stages) {
this.applyOperation(this.copyOperation(op, redo)) this.applyOperation(this.copyOperation(st, redo))
} }
} else { } else {
this.applyOperation(this.copyOperation(stage, redo)) this.applyOperation(this.copyOperation(stage, redo))

View file

@ -43,7 +43,6 @@ class BaseUpdater {
export class MapUpdater extends BaseUpdater { export class MapUpdater extends BaseUpdater {
update({ key, value }) { update({ key, value }) {
console.log('updating', key, value)
if (fieldInSchema(key)) { if (fieldInSchema(key)) {
this.updateObjectValue(this._umap, key, value) this.updateObjectValue(this._umap, key, value)
} }
@ -61,16 +60,11 @@ export class DataLayerUpdater extends BaseUpdater {
upsert({ value }) { upsert({ value }) {
// Upsert only happens when a new datalayer is created. // Upsert only happens when a new datalayer is created.
try { try {
console.log(
'found datalayer with id',
value.id,
this.getDataLayerFromID(value.id) this.getDataLayerFromID(value.id)
)
} catch { } catch {
console.log('we are the fucking catch', value)
const datalayer = this._umap.createDataLayer(value._umap_options || value, false) const datalayer = this._umap.createDataLayer(value._umap_options || value, false)
if (value.features) { 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) 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
}
}

View file

@ -34,7 +34,7 @@ const TOP_BAR_TEMPLATE = `
<i class="icon icon-16 icon-eye"></i> <i class="icon icon-16 icon-eye"></i>
<span class="">${translate('View')}</span> <span class="">${translate('View')}</span>
</button> </button>
<button class="edit-save button round" type="button" data-ref="save"> <button class="edit-save button round enabled-on-dirty" type="button" data-ref="save">
<i class="icon icon-16 icon-save"></i> <i class="icon icon-16 icon-save"></i>
<i class="icon icon-16 icon-save-disabled"></i> <i class="icon icon-16 icon-save-disabled"></i>
<span hidden data-ref="saveLabel">${translate('Save')}</span> <span hidden data-ref="saveLabel">${translate('Save')}</span>
@ -275,10 +275,7 @@ export class EditBar extends WithTemplate {
DomEvent.disableClickPropagation(this.element) DomEvent.disableClickPropagation(this.element)
this._onClick('marker', () => this._leafletMap.editTools.startMarker()) this._onClick('marker', () => this._leafletMap.editTools.startMarker())
this._onClick('polyline', () => this._leafletMap.editTools.startPolyline()) this._onClick('polyline', () => this._leafletMap.editTools.startPolyline())
this._onClick('multiline', () => { this._onClick('multiline', () => this._umap.editedFeature.ui.editor.newShape())
console.log('click click')
this._umap.editedFeature.ui.editor.newShape()
})
this._onClick('polygon', () => this._leafletMap.editTools.startPolygon()) this._onClick('polygon', () => this._leafletMap.editTools.startPolygon())
this._onClick('multipolygon', () => this._umap.editedFeature.ui.editor.newShape()) this._onClick('multipolygon', () => this._umap.editedFeature.ui.editor.newShape())
this._onClick('caption', () => this._umap.editCaption()) this._onClick('caption', () => this._umap.editCaption())

View file

@ -96,6 +96,10 @@ export default class Umap extends ServerStored {
this._leafletMap.latLng(center) this._leafletMap.latLng(center)
} }
// Needed for permissions
this.syncEngine = new SyncEngine(this)
this.sync = this.syncEngine.proxy(this)
// Needed to render controls // Needed to render controls
this.permissions = new MapPermissions(this) this.permissions = new MapPermissions(this)
this.urls = new URLs(this.properties.urls) this.urls = new URLs(this.properties.urls)
@ -130,9 +134,6 @@ export default class Umap extends ServerStored {
this.share = new Share(this) this.share = new Share(this)
this.rules = new Rules(this) this.rules = new Rules(this)
this.syncEngine = new SyncEngine(this)
this.sync = this.syncEngine.proxy(this)
if (this.hasEditMode()) { if (this.hasEditMode()) {
this.editPanel = new EditPanel(this, this._leafletMap) this.editPanel = new EditPanel(this, this._leafletMap)
this.fullPanel = new FullPanel(this, this._leafletMap) this.fullPanel = new FullPanel(this, this._leafletMap)
@ -606,7 +607,6 @@ export default class Umap extends ServerStored {
} }
createDataLayer(options = {}, sync = true) { createDataLayer(options = {}, sync = true) {
console.log('createDatalayer', options)
options.name = options.name =
options.name || `${translate('Layer')} ${this.datalayersIndex.length + 1}` options.name || `${translate('Layer')} ${this.datalayersIndex.length + 1}`
const datalayer = new DataLayer(this, this._leafletMap, options) const datalayer = new DataLayer(this, this._leafletMap, options)
@ -1269,7 +1269,7 @@ export default class Umap extends ServerStored {
} }
disableEdit() { disableEdit() {
if (this.isDirty) return // if (this.isDirty) return
this.drop.disable() this.drop.disable()
document.body.classList.remove('umap-edit-enabled') document.body.classList.remove('umap-edit-enabled')
this.editedFeature = null this.editedFeature = null
@ -1297,7 +1297,6 @@ export default class Umap extends ServerStored {
getSyncMetadata() { getSyncMetadata() {
return { return {
engine: this.sync,
subject: 'map', subject: 'map',
} }
} }
@ -1495,7 +1494,7 @@ export default class Umap extends ServerStored {
const form = builder.build() const form = builder.build()
row.appendChild(form) row.appendChild(form)
row.classList.toggle('off', !datalayer.isVisible()) row.classList.toggle('off', !datalayer.isVisible())
row.dataset.id = stamp(datalayer) row.dataset.id = datalayer.id
}) })
const onReorder = (src, dst, initialIndex, finalIndex) => { const onReorder = (src, dst, initialIndex, finalIndex) => {
const movedLayer = this.datalayers[src.dataset.id] const movedLayer = this.datalayers[src.dataset.id]
@ -1526,7 +1525,7 @@ export default class Umap extends ServerStored {
} }
getDataLayerByUmapId(id) { 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}`) if (!datalayer) throw new Error(`Can't find datalayer with id ${id}`)
return datalayer return datalayer
} }

View file

@ -740,7 +740,6 @@ U.Editable = L.Editable.extend({
// (eg. line has only one drawn point) // (eg. line has only one drawn point)
// So let's check if the layer has no more shape // So let's check if the layer has no more shape
event.layer.feature.pullGeometry(false) event.layer.feature.pullGeometry(false)
console.log('onEscape', event.layer.feature.hasGeom())
if (!event.layer.feature.hasGeom()) { if (!event.layer.feature.hasGeom()) {
event.layer.feature.del() event.layer.feature.del()
} else { } else {

View file

@ -15,7 +15,7 @@ class OperationMessage(BaseModel):
kind: Literal["OperationMessage"] = "OperationMessage" kind: Literal["OperationMessage"] = "OperationMessage"
verb: Literal["upsert", "update", "delete", "batch"] verb: Literal["upsert", "update", "delete", "batch"]
subject: Literal["map", "datalayer", "feature", "batch"] subject: Literal["map", "datalayer", "feature"]
metadata: Optional[dict] = None metadata: Optional[dict] = None
key: Optional[str] = None key: Optional[str] = None
operations: Optional[List] = None operations: Optional[List] = None

View file

@ -159,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"]').click()
page.locator('input[name="name"]').fill("Layer A with a new name") 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() 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/.*")): with page.expect_response(re.compile(".*/datalayer/update/.*")):
page.get_by_role("button", name="Save").click() page.get_by_role("button", name="Save").click()
assert DataLayer.objects.count() == 2 assert DataLayer.objects.count() == 2

View file

@ -292,9 +292,10 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm
# Change name on page two and save # Change name on page two and save
page_two.locator(".leaflet-marker-icon").click(modifiers=["Shift"]) page_two.locator(".leaflet-marker-icon").click(modifiers=["Shift"])
page_two.locator('input[name="name"]').fill("name from page two") 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 # Map should be in dirty status
expect(page_two.get_by_text("Undo")).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/.*")): with page_two.expect_response(re.compile(r".*/datalayer/update/.*")):
page_two.get_by_role("button", name="Save").click() 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 # We should have an alert with some actions
expect(page_two.get_by_text("Whoops! Other contributor(s) changed")).to_be_visible() expect(page_two.get_by_text("Whoops! Other contributor(s) changed")).to_be_visible()
# Map should still be in dirty status # Map should still be in dirty status
expect(page_two.get_by_text("Undo")).to_be_visible() expect(page_two.get_by_text("Save", exact=True)).to_be_enabled()
# Override data from page two # Override data from page two
with page_two.expect_response(re.compile(r".*/datalayer/update/.*")): 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()) data = json.loads(Path(saved.geojson.path).read_text())
assert data["features"][0]["properties"]["name"] == "name from page two" assert data["features"][0]["properties"]["name"] == "name from page two"
# Map should not be in dirty status anymore # Map should not be in dirty status anymore
expect(page_two.get_by_text("Undo")).to_be_hidden() expect(page_two.get_by_text("Save", exact=True)).to_be_disabled()

View file

@ -18,14 +18,13 @@ def test_reseting_map_would_remove_from_save_queue(
page.locator('input[name="name"]').fill("new name") page.locator('input[name="name"]').fill("new name")
page.get_by_role("button", name="Undo").click() page.get_by_role("button", name="Undo").click()
page.wait_for_timeout(500) 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="Manage layers").click()
page.get_by_role("button", name="Edit", exact=True).click() page.get_by_role("button", name="Edit", exact=True).click()
page.locator('input[name="name"]').click() page.locator('input[name="name"]').click()
page.locator('input[name="name"]').fill("new datalayer name") page.locator('input[name="name"]').fill("new datalayer name")
page.wait_for_timeout(300) # Time of the Input debounce page.wait_for_timeout(300) # Time of the Input debounce
with page.expect_response(re.compile(".*/datalayer/update/.*")): 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 len(requests) == 1
assert requests == [ assert requests == [
( (

View file

@ -90,8 +90,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_hidden() expect(page.locator(".edit-undo")).to_be_disabled()
expect(page.locator(".edit-redo")).to_be_hidden() expect(page.locator(".edit-redo")).to_be_disabled()
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()