diff --git a/umap/static/umap/js/modules/autocomplete.js b/umap/static/umap/js/modules/autocomplete.js index 38ac27b7..003c82fb 100644 --- a/umap/static/umap/js/modules/autocomplete.js +++ b/umap/static/umap/js/modules/autocomplete.js @@ -313,7 +313,7 @@ export const SingleMixin = (Base) => DomEvent.on(close, 'click', () => { this.selectedContainer.innerHTML = '' this.input.style.display = 'block' - this.options.on_unselect(result) + this.options.on_unselect?.(result) }) this.hide() } @@ -342,7 +342,7 @@ export const MultipleMixin = (Base) => }) DomEvent.on(close, 'click', () => { this.selectedContainer.removeChild(result_el) - this.options.on_unselect(result) + this.options.on_unselect?.(result) }) this.hide() } diff --git a/umap/static/umap/js/modules/caption.js b/umap/static/umap/js/modules/caption.js index 5496688b..d31dec94 100644 --- a/umap/static/umap/js/modules/caption.js +++ b/umap/static/umap/js/modules/caption.js @@ -19,7 +19,12 @@ export default class Caption { open() { const container = DomUtil.create('div', 'umap-caption') const hgroup = DomUtil.element({ tagName: 'hgroup', parent: container }) - DomUtil.createTitle(hgroup, this.map.options.name, 'icon-caption icon-block') + DomUtil.createTitle( + hgroup, + this.map.getDisplayName(), + 'icon-caption icon-block', + 'map-name' + ) this.map.addAuthorLink('h4', hgroup) if (this.map.options.description) { const description = DomUtil.element({ diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index 5fa8def9..8af00925 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -92,12 +92,12 @@ export class DataLayer { set isDirty(status) { this._isDirty = status if (status) { - this.map.addDirtyDatalayer(this) + this.map.isDirty = true // A layer can be made dirty by indirect action (like dragging layers) // we need to have it loaded before saving it. if (!this.isLoaded()) this.fetchData() } else { - this.map.removeDirtyDatalayer(this) + this.map.checkDirty() this.isDeleted = false } } @@ -339,11 +339,11 @@ export class DataLayer { const id = stamp(this) if (!this.map.datalayers[id]) { this.map.datalayers[id] = this - if (!this.map.datalayers_index.includes(this)) { - this.map.datalayers_index.push(this) - } - this.map.onDataLayersChanged() } + if (!this.map.datalayers_index.includes(this)) { + this.map.datalayers_index.push(this) + } + this.map.onDataLayersChanged() } _dataUrl() { @@ -559,7 +559,6 @@ export class DataLayer { erase() { this.hide() - delete this.map.datalayers[stamp(this)] this.map.datalayers_index.splice(this.getRank(), 1) this.parentPane.removeChild(this.pane) this.map.onDataLayersChanged() @@ -1029,7 +1028,7 @@ export class DataLayer { } async save() { - if (this.isDeleted) return this.saveDelete() + if (this.isDeleted) return await this.saveDelete() if (!this.isLoaded()) { return } @@ -1093,8 +1092,8 @@ export class DataLayer { if (this.umap_id) { await this.map.server.post(this.getDeleteUrl()) } + delete this.map.datalayers[stamp(this)] this.isDirty = false - this.map.continueSaving() } getMap() { diff --git a/umap/static/umap/js/modules/permissions.js b/umap/static/umap/js/modules/permissions.js index f66de593..3300da2a 100644 --- a/umap/static/umap/js/modules/permissions.js +++ b/umap/static/umap/js/modules/permissions.js @@ -42,10 +42,6 @@ export class MapPermissions { return !this.map.options.permissions.owner } - getMap() { - return this.map - } - _editAnonymous(container) { const fields = [] if (this.isOwner()) { @@ -182,7 +178,7 @@ export class MapPermissions { } async save() { - if (!this.isDirty) return this.map.continueSaving() + if (!this.isDirty) return const formData = new FormData() if (!this.isAnonymousMap() && this.options.editors) { const editors = this.options.editors.map((u) => u.id) @@ -205,7 +201,6 @@ export class MapPermissions { if (!error) { this.commit() this.isDirty = false - this.map.continueSaving() this.map.fire('postsync') } } @@ -230,9 +225,11 @@ export class MapPermissions { } getShareStatusDisplay() { - return Object.fromEntries(this.map.options.share_statuses)[ - this.options.share_status - ] + if (this.map.options.share_statuses) { + return Object.fromEntries(this.map.options.share_statuses)[ + this.options.share_status + ] + } } } @@ -258,7 +255,7 @@ export class DataLayerPermissions { return this._isDirty } - getMap() { + get map() { return this.datalayer.map } @@ -271,7 +268,7 @@ export class DataLayerPermissions { label: translate('Who can edit "{layer}"', { layer: this.datalayer.getName(), }), - selectOptions: this.datalayer.map.options.datalayer_edit_statuses, + selectOptions: this.map.options.datalayer_edit_statuses, }, ], ] @@ -283,16 +280,17 @@ export class DataLayerPermissions { } getUrl() { - return Utils.template(this.datalayer.map.options.urls.datalayer_permissions, { - map_id: this.datalayer.map.options.umap_id, + return this.map.urls.get('datalayer_permissions', { + map_id: this.map.options.umap_id, pk: this.datalayer.umap_id, }) } + async save() { - if (!this.isDirty) return this.datalayer.map.continueSaving() + if (!this.isDirty) return const formData = new FormData() formData.append('edit_status', this.options.edit_status) - const [data, response, error] = await this.datalayer.map.server.post( + const [data, response, error] = await this.map.server.post( this.getUrl(), {}, formData @@ -300,7 +298,6 @@ export class DataLayerPermissions { if (!error) { this.commit() this.isDirty = false - this.datalayer.map.continueSaving() } } diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 0a54e66e..869a7211 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -578,11 +578,15 @@ const ControlsMixin = { ], renderEditToolbar: function () { - const container = L.DomUtil.create( - 'div', - 'umap-main-edit-toolbox with-transition dark', - this._controlContainer - ) + const className = 'umap-main-edit-toolbox' + const container = + document.querySelector(`.${className}`) || + L.DomUtil.create( + 'div', + `${className} with-transition dark`, + this._controlContainer + ) + container.innerHTML = '' const leftContainer = L.DomUtil.create('div', 'umap-left-edit-toolbox', container) const rightContainer = L.DomUtil.create('div', 'umap-right-edit-toolbox', container) const logo = L.DomUtil.create('div', 'logo', leftContainer) @@ -623,23 +627,10 @@ const ControlsMixin = { }, this ) - const update = () => { - const status = this.permissions.getShareStatusDisplay() - nameButton.textContent = this.getDisplayName() - // status is not set until map is saved once - if (status) { - shareStatusButton.textContent = L._('Visibility: {status}', { - status: status, - }) - } - } - update() - this.once('saved', L.bind(update, this)) if (this.options.editMode === 'advanced') { L.DomEvent.on(nameButton, 'click', this.editCaption, this) L.DomEvent.on(shareStatusButton, 'click', this.permissions.edit, this.permissions) } - this.on('postsync', L.bind(update, this)) if (this.options.user?.id) { L.DomUtil.createLink( 'umap-user', diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 9b785f86..48c81524 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -141,10 +141,10 @@ L.DomUtil.createButtonIcon = (parent, className, title, callback, size = 16) => return el } -L.DomUtil.createTitle = (parent, text, className, tag = 'h3') => { +L.DomUtil.createTitle = (parent, text, iconClassName, className = '', tag = 'h3') => { const title = L.DomUtil.create(tag, '', parent) - if (className) L.DomUtil.createIcon(title, className) - L.DomUtil.add('span', '', title, text) + if (className) L.DomUtil.createIcon(title, iconClassName) + L.DomUtil.add('span', className, title, text) return title } diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 381e6253..f87d827b 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -110,10 +110,9 @@ U.Map = L.Map.extend({ delete this.options.advancedFilterKey } - // Global storage for retrieving datalayers and features - this.datalayers = {} - this.datalayers_index = [] - this.dirty_datalayers = [] + // Global storage for retrieving datalayers and features. + this.datalayers = {} // All datalayers, including deleted. + this.datalayers_index = [] // Datalayers actually on the map and ordered. this.features_index = {} // Needed for actions labels @@ -200,6 +199,7 @@ U.Map = L.Map.extend({ this.backup() this.on('click', this.closeInplaceToolbar) this.on('contextmenu', this.onContextMenu) + this.propagate() }, initSyncEngine: async function () { @@ -231,6 +231,7 @@ U.Map = L.Map.extend({ this.renderEditToolbar() this.renderControls() this.browser.redraw() + this.propagate() break case 'data': this.redrawVisibleDataLayers() @@ -487,7 +488,7 @@ U.Map = L.Map.extend({ loadDataLayers: async function () { this.datalayersLoaded = true this.fire('datalayersloaded') - for (const datalayer of Object.values(this.datalayers)) { + for (const datalayer of this.datalayers_index) { if (datalayer.showAtLoad()) await datalayer.show() } this.dataloaded = true @@ -933,6 +934,7 @@ U.Map = L.Map.extend({ if (mustReindex) datalayer.reindex() datalayer.redraw() }) + this.propagate() this.fire('postsync') this.isDirty = true }, @@ -996,12 +998,12 @@ U.Map = L.Map.extend({ if (this.editTools) this.editTools.stopDrawing() this.resetOptions() this.datalayers_index = [].concat(this._datalayers_index_bk) - this.dirty_datalayers.slice().forEach((datalayer) => { + // Iter over all datalayers, including deleted if any. + for (const datalayer of Object.values(this.datalayers)) { if (datalayer.isDeleted) datalayer.connectToMap() - datalayer.reset() - }) + if (datalayer.isDirty) datalayer.reset() + } this.ensurePanesOrder() - this.dirty_datalayers = [] this.initTileLayers() this.isDirty = false this.onDataLayersChanged() @@ -1011,25 +1013,6 @@ U.Map = L.Map.extend({ this._container.classList.toggle('umap-is-dirty', this.isDirty) }, - addDirtyDatalayer: function (datalayer) { - if (this.dirty_datalayers.indexOf(datalayer) === -1) { - this.dirty_datalayers.push(datalayer) - this.isDirty = true - } - }, - - removeDirtyDatalayer: function (datalayer) { - if (this.dirty_datalayers.indexOf(datalayer) !== -1) { - this.dirty_datalayers.splice(this.dirty_datalayers.indexOf(datalayer), 1) - this.checkDirty() - } - }, - - continueSaving: function () { - if (this.dirty_datalayers.length) this.dirty_datalayers[0].save() - else this.fire('saved') - }, - exportOptions: function () { const properties = {} for (const option of Object.keys(U.SCHEMA)) { @@ -1059,19 +1042,17 @@ U.Map = L.Map.extend({ return } if (data.login_required) { - window.onLogin = () => this.saveSelf() + window.onLogin = () => this.save() window.open(data.login_required) return } - if (data.user?.id) { - this.options.user = data.user - this.renderEditToolbar() - } + this.options.user = data.user + this.renderEditToolbar() if (!this.options.umap_id) { this.options.umap_id = data.id this.permissions.setOptions(data.permissions) this.permissions.commit() - if (data?.permissions?.anonymous_edit_url) { + if (data.permissions?.anonymous_edit_url) { this.once('saved', () => { U.AlertCreation.info( L._('Your map has been created with an anonymous account!'), @@ -1104,21 +1085,42 @@ U.Map = L.Map.extend({ } else { window.location = data.url } - this.permissions.save() + this.propagate() + return true }, - save: function () { + save: async function () { if (!this.isDirty) return if (this._default_extent) this._setCenterAndZoom() this.backup() - this.once('saved', () => { - this.isDirty = false - }) if (this.options.editMode === 'advanced') { // Only save the map if the user has the rights to do so. - this.saveSelf() - } else { - this.permissions.save() + const ok = await this.saveSelf() + if (!ok) return + } + await this.permissions.save() + // Iter over all datalayers, including deleted. + for (const datalayer of Object.values(this.datalayers)) { + if (datalayer.isDirty) await datalayer.save() + } + this.isDirty = false + this.renderEditToolbar() + this.fire('saved') + }, + + propagate: function () { + let els = document.querySelectorAll('.map-name') + for (const el of els) { + el.textContent = this.getDisplayName() + } + const status = this.permissions.getShareStatusDisplay() + els = document.querySelectorAll('.share-status') + for (const el of els) { + if (status) { + el.textContent = L._('Visibility: {status}', { + status: status, + }) + } } }, @@ -1831,7 +1833,7 @@ U.Map = L.Map.extend({ getFeatureById: function (id) { let feature - for (const datalayer of Object.values(this.datalayers)) { + for (const datalayer of this.datalayers_index) { feature = datalayer.getFeatureById(id) if (feature) return feature } diff --git a/umap/tests/integration/test_anonymous_owned_map.py b/umap/tests/integration/test_anonymous_owned_map.py index c406e189..69d7e1b5 100644 --- a/umap/tests/integration/test_anonymous_owned_map.py +++ b/umap/tests/integration/test_anonymous_owned_map.py @@ -156,6 +156,7 @@ def test_can_change_perms_after_create(tilelayer, live_server, page): ".datalayer-permissions select[name='edit_status'] option:checked" ) expect(option).to_have_text("Inherit") + expect(page.get_by_label("Secret edit link:")).to_be_visible() def test_alert_message_after_create( diff --git a/umap/tests/integration/test_edit_datalayer.py b/umap/tests/integration/test_edit_datalayer.py index 37d234c7..ebd07c2e 100644 --- a/umap/tests/integration/test_edit_datalayer.py +++ b/umap/tests/integration/test_edit_datalayer.py @@ -63,7 +63,6 @@ def test_cancel_deleting_datalayer_should_restore( page.locator(".panel.right").get_by_title("Delete layer").click() page.get_by_role("button", name="OK").click() expect(markers).to_have_count(0) - page.get_by_role("button", name="Open browser").click() 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() diff --git a/umap/tests/integration/test_edit_map.py b/umap/tests/integration/test_edit_map.py index 802c6412..4dfa3704 100644 --- a/umap/tests/integration/test_edit_map.py +++ b/umap/tests/integration/test_edit_map.py @@ -40,7 +40,7 @@ def test_map_name_impacts_ui(live_server, page, tilelayer): name_input.fill("something else") - expect(page.get_by_role("button", name="something else").nth(1)).to_be_visible() + expect(page.get_by_role("button", name="something else").first).to_be_visible() def test_zoomcontrol_impacts_ui(live_server, page, tilelayer): diff --git a/umap/tests/test_map_views.py b/umap/tests/test_map_views.py index 03c1adee..8aa9947b 100644 --- a/umap/tests/test_map_views.py +++ b/umap/tests/test_map_views.py @@ -368,6 +368,7 @@ def test_anonymous_create(cookieclient, post_data): assert ( created_map.get_anonymous_edit_url() in j["permissions"]["anonymous_edit_url"] ) + assert j["user"]["is_owner"] is True assert created_map.name == name key, value = created_map.signed_cookie_elements assert key in cookieclient.cookies diff --git a/umap/views.py b/umap/views.py index a85d4b7a..5726a838 100644 --- a/umap/views.py +++ b/umap/views.py @@ -863,15 +863,17 @@ class MapCreate(FormLessEditMixin, PermissionsMixin, SessionMixin, CreateView): form.instance.owner = self.request.user self.object = form.save() permissions = self.get_permissions() + user_data = self.get_user_data() # User does not have the cookie yet. if not self.object.owner: anonymous_url = self.object.get_anonymous_edit_url() permissions["anonymous_edit_url"] = anonymous_url + user_data["is_owner"] = True response = simple_json_response( id=self.object.pk, url=self.object.get_absolute_url(), permissions=permissions, - user=self.get_user_data(), + user=user_data, ) if not self.request.user.is_authenticated: key, value = self.object.signed_cookie_elements @@ -908,7 +910,7 @@ def get_websocket_auth_token(request, map_id, map_inst): return simple_json_response(token=signed_token) -class MapUpdate(FormLessEditMixin, PermissionsMixin, UpdateView): +class MapUpdate(FormLessEditMixin, PermissionsMixin, SessionMixin, UpdateView): model = Map form_class = MapSettingsForm pk_url_kwarg = "map_id" @@ -920,6 +922,7 @@ class MapUpdate(FormLessEditMixin, PermissionsMixin, UpdateView): id=self.object.pk, url=self.object.get_absolute_url(), permissions=self.get_permissions(), + user=self.get_user_data(), )