diff --git a/umap/static/umap/js/modules/global.js b/umap/static/umap/js/modules/global.js index 51ba37d0..049a18ed 100644 --- a/umap/static/umap/js/modules/global.js +++ b/umap/static/umap/js/modules/global.js @@ -1,8 +1,9 @@ import * as L from '../../vendors/leaflet/leaflet-src.esm.js' import URLs from './urls.js' +import { Request, ServerRequest } from './request.js' // Import modules and export them to the global scope. // For the not yet module-compatible JS out there. // Copy the leaflet module, it's expected by leaflet plugins to be writeable. window.L = { ...L } -window.umap = { URLs } +window.umap = { URLs, Request, ServerRequest } diff --git a/umap/static/umap/js/modules/request.js b/umap/static/umap/js/modules/request.js new file mode 100644 index 00000000..df8c4033 --- /dev/null +++ b/umap/static/umap/js/modules/request.js @@ -0,0 +1,146 @@ +// Uses `L._`` from Leaflet.i18n which we cannot import as a module yet +import { Evented, DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' + +const BaseRequest = Evented.extend({ + _fetch: async function (method, uri, headers, data) { + const id = Math.random() + this.fire('dataloading', { id: id }) + let response + + try { + response = await fetch(uri, { + method: method, + mode: 'cors', + headers: headers, + body: data, + }) + } catch (error) { + this._onError(error) + this.fire('dataload', { id: id }) + return + } + if (!response.ok) { + this.onNok(response.status, await response.text()) + } + // TODO + // - error handling + // - UI connection / events + // - preflight mode in CORS ? + + this.fire('dataload', { id: id }) + return response + }, + + get: async function (uri, headers) { + return await this._fetch('GET', uri, headers) + }, + + post: async function (uri, headers, data) { + return await this._fetch('POST', uri, headers, data) + }, + + _onError: function (error) { + console.error(error) + this.onError(error) + }, + onError: function (error) {}, + onNok: function (status) {}, +}) + +export const Request = BaseRequest.extend({ + initialize: function (ui) { + this.ui = ui + }, + onError: function (error) { + console.error(error) + this.ui.alert({ content: L._('Problem in the response'), level: 'error' }) + }, + onNok: function (status, message) { + this.onError(message) + }, +}) + +// Adds uMap specifics to requests handling +// like logging, CSRF, etc. +export const ServerRequest = Request.extend({ + post: async function (uri, headers, data) { + const token = document.cookie.replace( + /(?:(?:^|.*;\s*)csrftoken\s*\=\s*([^;]*).*$)|^.*$/, + '$1' + ) + if (token) { + headers = headers || {} + headers['X-CSRFToken'] = token + } + const response = await Request.prototype.post.call(this, uri, headers, data) + return this._handle_json_response(response) + }, + + get: async function (uri, headers) { + const response = await Request.prototype.get.call(this, uri, headers) + return this._handle_json_response(response) + }, + + _handle_json_response: async function (response) { + try { + const data = await response.json() + this._handle_server_instructions(data) + return [data, response] + } catch (error) { + this._onError(error) + } + }, + + _handle_server_instructions: function (data) { + // In some case, the response contains instructions + if (data.redirect) { + const newPath = data.redirect + if (window.location.pathname == newPath) { + window.location.reload() // Keep the hash, so the current view + } else { + window.location = newPath + } + } else if (data.info) { + this.ui.alert({ content: data.info, level: 'info' }) + this.ui.closePanel() + } else if (data.error) { + this.ui.alert({ content: data.error, level: 'error' }) + } else if (data.html) { + const ui_options = { data } + let listen_options + this.ui.openPanel(ui_options) + } + }, + + onNok: function (status, message) { + if (status === 403) { + this.ui.alert({ + content: message || L._('Action not allowed :('), + level: 'error', + }) + } else if (status === 412) { + const msg = L._( + 'Woops! Someone else seems to have edited the data. You can save anyway, but this will erase the changes made by others.' + ) + const actions = [ + { + label: L._('Save anyway'), + callback: function () { + // TODO + delete settings.headers['If-Match'] + this._fetch(settings) + }, + }, + { + label: L._('Cancel'), + }, + ] + this.ui.alert({ + content: msg, + level: 'error', + duration: 100000, + actions: actions, + }) + } + }, +}) diff --git a/umap/static/umap/js/umap.autocomplete.js b/umap/static/umap/js/umap.autocomplete.js index 1f9b65cb..cbc43153 100644 --- a/umap/static/umap/js/umap.autocomplete.js +++ b/umap/static/umap/js/umap.autocomplete.js @@ -13,7 +13,7 @@ L.U.AutoComplete = L.Class.extend({ initialize: function (el, options) { this.el = el const ui = new L.U.UI(document.querySelector('header')) - this.xhr = new L.U.Xhr(ui) + this.server = new window.umap.ServerRequest(ui) L.setOptions(this, options) let CURRENT = null try { @@ -158,21 +158,19 @@ L.U.AutoComplete = L.Class.extend({ } }, - search: function () { - const val = this.input.value + search: async function () { + let val = this.input.value if (val.length < this.options.minChar) { this.clear() return } if (`${val}` === `${this.CACHE}`) return else this.CACHE = val - this._do_search( - val, - function (data) { - this.handleResults(data.data) - }, - this + val = val.toLowerCase() + const [{ data }, response] = await this.server.get( + `/agnocomplete/AutocompleteUser/?q=${encodeURIComponent(val)}` ) + this.handleResults(data) }, createResult: function (item) { @@ -272,14 +270,6 @@ L.U.AutoComplete.Ajax = L.U.AutoComplete.extend({ label: option.innerHTML, } }, - - _do_search: function (val, callback, context) { - val = val.toLowerCase() - this.xhr.get(`/agnocomplete/AutocompleteUser/?q=${encodeURIComponent(val)}`, { - callback: callback, - context: context || this, - }) - }, }) L.U.AutoComplete.Ajax.SelectMultiple = L.U.AutoComplete.Ajax.extend({ diff --git a/umap/static/umap/js/umap.datalayer.permissions.js b/umap/static/umap/js/umap.datalayer.permissions.js index f77e086f..f4a79e92 100644 --- a/umap/static/umap/js/umap.datalayer.permissions.js +++ b/umap/static/umap/js/umap.datalayer.permissions.js @@ -51,19 +51,14 @@ L.U.DataLayerPermissions = L.Class.extend({ pk: this.datalayer.umap_id, }) }, - save: function () { + save: async function () { if (!this.isDirty) return this.datalayer.map.continueSaving() const formData = new FormData() formData.append('edit_status', this.options.edit_status) - this.datalayer.map.post(this.getUrl(), { - data: formData, - context: this, - callback: function (data) { - this.commit() - this.isDirty = false - this.datalayer.map.continueSaving() - }, - }) + await this.datalayer.map.server.post(this.getUrl(), {}, formData) + this.commit() + this.isDirty = false + this.datalayer.map.continueSaving() }, commit: function () { diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index fabf2424..3f36b184 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -685,7 +685,7 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({ return !this.value() || this.value() === this.obj.getMap().options.default_iconUrl }, - showSymbolsTab: function () { + showSymbolsTab: async function () { this.openTab('symbols') this.searchInput = L.DomUtil.create('input', '', this.body) this.searchInput.type = 'search' @@ -695,13 +695,11 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({ if (this.pictogram_list) { this.buildSymbolsList() } else { - this.builder.map.get(this.builder.map.options.urls.pictogram_list_json, { - callback: (data) => { - this.pictogram_list = data.pictogram_list - this.buildSymbolsList() - }, - context: this, - }) + const [{ pictogram_list }, response] = await this.builder.map.server.get( + this.builder.map.options.urls.pictogram_list_json + ) + this.pictogram_list = pictogram_list + this.buildSymbolsList() } }, diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index bf0f5f91..f142f249 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -98,9 +98,12 @@ L.U.Map.include({ this.urls = new window.umap.URLs(this.options.urls) this.ui = new L.U.UI(this._container) - this.xhr = new L.U.Xhr(this.ui) - this.xhr.on('dataloading', (e) => this.fire('dataloading', e)) - this.xhr.on('dataload', (e) => this.fire('dataload', e)) + this.server = new window.umap.ServerRequest(this.ui) + this.server.on('dataloading', (e) => this.fire('dataloading', e)) + this.server.on('dataload', (e) => this.fire('dataload', e)) + this.request = new window.umap.Request(this.ui) + this.request.on('dataloading', (e) => this.fire('dataloading', e)) + this.request.on('dataload', (e) => this.fire('dataload', e)) this.initLoader() this.name = this.options.name @@ -1083,7 +1086,7 @@ L.U.Map.include({ return properties }, - saveSelf: function () { + saveSelf: async function () { const geojson = { type: 'Feature', geometry: this.geometry(), @@ -1093,64 +1096,60 @@ L.U.Map.include({ formData.append('name', this.options.name) formData.append('center', JSON.stringify(this.geometry())) formData.append('settings', JSON.stringify(geojson)) - this.post(this.urls.get('map_save', { map_id: this.options.umap_id }), { - data: formData, - context: this, - callback: function (data) { - let duration = 3000, - alert = { content: L._('Map has been saved!'), level: 'info' } - if (!this.options.umap_id) { - alert.content = L._('Congratulations, your map has been created!') - this.options.umap_id = data.id - this.permissions.setOptions(data.permissions) - this.permissions.commit() - if ( - data.permissions && - data.permissions.anonymous_edit_url && - this.options.urls.map_send_edit_link - ) { - alert.duration = Infinity - alert.content = - L._( - 'Your map has been created! As you are not logged in, here is your secret link to edit the map, please keep it safe:' - ) + `
${data.permissions.anonymous_edit_url}` + const uri = this.urls.get('map_save', { map_id: this.options.umap_id }) + const [data, response] = await this.server.post(uri, {}, formData) + let duration = 3000, + alert = { content: L._('Map has been saved!'), level: 'info' } + if (!this.options.umap_id) { + alert.content = L._('Congratulations, your map has been created!') + this.options.umap_id = data.id + this.permissions.setOptions(data.permissions) + this.permissions.commit() + if ( + data.permissions && + data.permissions.anonymous_edit_url && + this.options.urls.map_send_edit_link + ) { + alert.duration = Infinity + alert.content = + L._( + 'Your map has been created! As you are not logged in, here is your secret link to edit the map, please keep it safe:' + ) + `
${data.permissions.anonymous_edit_url}` - alert.actions = [ - { - label: L._('Send me the link'), - input: L._('Email'), - callback: this.sendEditLink, - callbackContext: this, - }, - { - label: L._('Copy link'), - callback: () => { - L.Util.copyToClipboard(data.permissions.anonymous_edit_url) - this.ui.alert({ - content: L._('Secret edit link copied to clipboard!'), - level: 'info', - }) - }, - callbackContext: this, - }, - ] - } - } else if (!this.permissions.isDirty) { - // Do not override local changes to permissions, - // but update in case some other editors changed them in the meantime. - this.permissions.setOptions(data.permissions) - this.permissions.commit() - } - // Update URL in case the name has changed. - if (history && history.pushState) - history.pushState({}, this.options.name, data.url) - else window.location = data.url - alert.content = data.info || alert.content - this.once('saved', () => this.ui.alert(alert)) - this.ui.closePanel() - this.permissions.save() - }, - }) + alert.actions = [ + { + label: L._('Send me the link'), + input: L._('Email'), + callback: this.sendEditLink, + callbackContext: this, + }, + { + label: L._('Copy link'), + callback: () => { + L.Util.copyToClipboard(data.permissions.anonymous_edit_url) + this.ui.alert({ + content: L._('Secret edit link copied to clipboard!'), + level: 'info', + }) + }, + callbackContext: this, + }, + ] + } + } else if (!this.permissions.isDirty) { + // Do not override local changes to permissions, + // but update in case some other editors changed them in the meantime. + this.permissions.setOptions(data.permissions) + this.permissions.commit() + } + // Update URL in case the name has changed. + if (history && history.pushState) + history.pushState({}, this.options.name, data.url) + else window.location = data.url + alert.content = data.info || alert.content + this.once('saved', () => this.ui.alert(alert)) + this.ui.closePanel() + this.permissions.save() }, save: function () { @@ -1820,23 +1819,6 @@ L.U.Map.include({ this.loader.onAdd(this) }, - post: function (url, options) { - options = options || {} - options.listener = this - this.xhr.post(url, options) - }, - - get: function (url, options) { - options = options || {} - options.listener = this - this.xhr.get(url, options) - }, - - ajax: function (options) { - options.listener = this - this.xhr._ajax(options) - }, - initContextMenu: function () { this.contextmenu = new L.U.ContextMenu(this) this.contextmenu.enable() diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 7fafaea1..61218122 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -670,30 +670,26 @@ L.U.DataLayer = L.Evented.extend({ return this }, - fetchData: function () { + fetchData: async function () { if (!this.umap_id) return if (this._loading) return this._loading = true - this.map.get(this._dataUrl(), { - callback: function (geojson, response) { - this._last_modified = response.getResponseHeader('Last-Modified') - // FIXME: for now this property is set dynamically from backend - // And thus it's not in the geojson file in the server - // So do not let all options to be reset - // Fix is a proper migration so all datalayers settings are - // in DB, and we remove it from geojson flat files. - if (geojson._umap_options) { - geojson._umap_options.editMode = this.options.editMode - } - // In case of maps pre 1.0 still around - if (geojson._storage) geojson._storage.editMode = this.options.editMode - this.fromUmapGeoJSON(geojson) - this.backupOptions() - this.fire('loaded') - this._loading = false - }, - context: this, - }) + const [geojson, response] = await this.map.server.get(this._dataUrl()) + this._last_modified = response.headers['Last-Modified'] + // FIXME: for now this property is set dynamically from backend + // And thus it's not in the geojson file in the server + // So do not let all options to be reset + // Fix is a proper migration so all datalayers settings are + // in DB, and we remove it from geojson flat files. + if (geojson._umap_options) { + geojson._umap_options.editMode = this.options.editMode + } + // In case of maps pre 1.0 still around + if (geojson._storage) geojson._storage.editMode = this.options.editMode + this.fromUmapGeoJSON(geojson) + this.backupOptions() + this.fire('loaded') + this._loading = false }, fromGeoJSON: function (geojson) { @@ -1062,13 +1058,10 @@ L.U.DataLayer = L.Evented.extend({ reader.onload = (e) => this.importRaw(e.target.result, type) }, - importFromUrl: function (url, type) { - url = this.map.localizeUrl(url) - this.map.xhr._ajax({ - verb: 'GET', - uri: url, - callback: (data) => this.importRaw(data, type), - }) + importFromUrl: async function (uri, type) { + uri = this.map.localizeUrl(uri) + const response = await this.map.request.get(uri) + this.importRaw(await response.text(), type) }, getColor: function () { @@ -1385,8 +1378,8 @@ L.U.DataLayer = L.Evented.extend({ } }, - buildVersionsFieldset: function (container) { - const appendVersion = function (data) { + buildVersionsFieldset: async function (container) { + const appendVersion = (data) => { const date = new Date(parseInt(data.at, 10)) const content = `${date.toLocaleString(L.lang)} (${parseInt(data.size) / 1000}Kb)` const el = L.DomUtil.create('div', 'umap-datalayer-version', versionsContainer) @@ -1402,34 +1395,24 @@ L.U.DataLayer = L.Evented.extend({ } const versionsContainer = L.DomUtil.createFieldset(container, L._('Versions'), { - callback: function () { - this.map.xhr.get(this.getVersionsUrl(), { - callback: function (data) { - for (let i = 0; i < data.versions.length; i++) { - appendVersion.call(this, data.versions[i]) - } - }, - context: this, - }) + callback: async function () { + const [{versions}, response] = await this.map.server.get(this.getVersionsUrl()) + versions.forEach(appendVersion) }, context: this, }) }, - restore: function (version) { + restore: async function (version) { if (!this.map.editEnabled) return if (!confirm(L._('Are you sure you want to restore this version?'))) return - this.map.xhr.get(this.getVersionUrl(version), { - callback: function (geojson) { - if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat. - if (geojson._umap_options) this.setOptions(geojson._umap_options) - this.empty() - if (this.isRemoteLayer()) this.fetchRemoteData() - else this.addData(geojson) - this.isDirty = true - }, - context: this, - }) + const [geojson, response] = await this.map.server.get(this.getVersionUrl(version)) + if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat. + if (geojson._umap_options) this.setOptions(geojson._umap_options) + this.empty() + if (this.isRemoteLayer()) this.fetchRemoteData() + else this.addData(geojson) + this.isDirty = true }, featuresToGeoJSON: function () { @@ -1548,7 +1531,7 @@ L.U.DataLayer = L.Evented.extend({ return this.isReadOnly() || this.isRemoteLayer() }, - save: function () { + save: async function () { if (this.isDeleted) return this.saveDelete() if (!this.isLoaded()) { return @@ -1566,44 +1549,35 @@ L.U.DataLayer = L.Evented.extend({ map_id: this.map.options.umap_id, pk: this.umap_id, }) - this.map.post(saveUrl, { - data: formData, - callback: function (data, response) { - // Response contains geojson only if save has conflicted and conflicts have - // been resolved. So we need to reload to get extra data (saved from someone else) - if (data.geojson) { - this.clear() - this.fromGeoJSON(data.geojson) - delete data.geojson - } - this._geojson = geojson - this._last_modified = response.getResponseHeader('Last-Modified') - this.setUmapId(data.id) - this.updateOptions(data) - this.backupOptions() - this.connectToMap() - this._loaded = true - this.redraw() // Needed for reordering features - this.isDirty = false - this.permissions.save() - }, - context: this, - headers: this._last_modified - ? { 'If-Unmodified-Since': this._last_modified } - : {}, - }) + const headers = this._last_modified + ? { 'If-Unmodified-Since': this._last_modified } + : {} + const [data, response] = await this.map.server.post(saveUrl, headers, formData) + // Response contains geojson only if save has conflicted and conflicts have + // been resolved. So we need to reload to get extra data (saved from someone else) + if (data.geojson) { + this.clear() + this.fromGeoJSON(data.geojson) + delete data.geojson + } + this._geojson = geojson + this._last_modified = response.headers['Last-Modified'] + this.setUmapId(data.id) + this.updateOptions(data) + this.backupOptions() + this.connectToMap() + this._loaded = true + this.redraw() // Needed for reordering features + this.isDirty = false + this.permissions.save() }, - saveDelete: function () { - const callback = function () { - this.isDirty = false - this.map.continueSaving() + saveDelete: async function () { + if (this.umap_id) { + await this.map.server.post(this.getDeleteUrl()) } - if (!this.umap_id) return callback.call(this) - this.map.xhr.post(this.getDeleteUrl(), { - callback: callback, - context: this, - }) + this.isDirty = false + this.map.continueSaving() }, getMap: function () { diff --git a/umap/static/umap/js/umap.permissions.js b/umap/static/umap/js/umap.permissions.js index 8945e18e..087f0c0c 100644 --- a/umap/static/umap/js/umap.permissions.js +++ b/umap/static/umap/js/umap.permissions.js @@ -131,21 +131,17 @@ L.U.MapPermissions = L.Class.extend({ this.map.ui.openPanel({ data: { html: container }, className: 'dark' }) }, - attach: function () { - this.map.post(this.getAttachUrl(), { - callback: function () { - this.options.owner = this.map.options.user - this.map.ui.alert({ - content: L._('Map has been attached to your account'), - level: 'info', - }) - this.map.ui.closePanel() - }, - context: this, + attach: async function () { + await this.map.server.post(this.getAttachUrl()) + this.options.owner = this.map.options.user + this.map.ui.alert({ + content: L._('Map has been attached to your account'), + level: 'info', }) + this.map.ui.closePanel() }, - save: function () { + save: async function () { if (!this.isDirty) return this.map.continueSaving() const formData = new FormData() if (!this.isAnonymousMap() && this.options.editors) { @@ -159,16 +155,11 @@ L.U.MapPermissions = L.Class.extend({ formData.append('owner', this.options.owner && this.options.owner.id) formData.append('share_status', this.options.share_status) } - this.map.post(this.getUrl(), { - data: formData, - context: this, - callback: function (data) { - this.commit() - this.isDirty = false - this.map.continueSaving() - this.map.fire('postsync') - }, - }) + await this.map.server.post(this.getUrl(), {}, formData) + this.commit() + this.isDirty = false + this.map.continueSaving() + this.map.fire('postsync') }, getUrl: function () {