wip: move xhr management to a module and refactor

It sort of work for common cases, but edge cases needs work,
specifically the login flow.
This commit is contained in:
Yohan Boniface 2024-01-23 18:49:17 +01:00
parent 1d80645eda
commit 084bc3d518
8 changed files with 300 additions and 223 deletions

View file

@ -1,8 +1,9 @@
import * as L from '../../vendors/leaflet/leaflet-src.esm.js' import * as L from '../../vendors/leaflet/leaflet-src.esm.js'
import URLs from './urls.js' import URLs from './urls.js'
import { Request, ServerRequest } from './request.js'
// Import modules and export them to the global scope. // Import modules and export them to the global scope.
// For the not yet module-compatible JS out there. // For the not yet module-compatible JS out there.
// Copy the leaflet module, it's expected by leaflet plugins to be writeable. // Copy the leaflet module, it's expected by leaflet plugins to be writeable.
window.L = { ...L } window.L = { ...L }
window.umap = { URLs } window.umap = { URLs, Request, ServerRequest }

View file

@ -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,
})
}
},
})

View file

@ -13,7 +13,7 @@ L.U.AutoComplete = L.Class.extend({
initialize: function (el, options) { initialize: function (el, options) {
this.el = el this.el = el
const ui = new L.U.UI(document.querySelector('header')) 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) L.setOptions(this, options)
let CURRENT = null let CURRENT = null
try { try {
@ -158,21 +158,19 @@ L.U.AutoComplete = L.Class.extend({
} }
}, },
search: function () { search: async function () {
const val = this.input.value let val = this.input.value
if (val.length < this.options.minChar) { if (val.length < this.options.minChar) {
this.clear() this.clear()
return return
} }
if (`${val}` === `${this.CACHE}`) return if (`${val}` === `${this.CACHE}`) return
else this.CACHE = val else this.CACHE = val
this._do_search( val = val.toLowerCase()
val, const [{ data }, response] = await this.server.get(
function (data) { `/agnocomplete/AutocompleteUser/?q=${encodeURIComponent(val)}`
this.handleResults(data.data)
},
this
) )
this.handleResults(data)
}, },
createResult: function (item) { createResult: function (item) {
@ -272,14 +270,6 @@ L.U.AutoComplete.Ajax = L.U.AutoComplete.extend({
label: option.innerHTML, 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({ L.U.AutoComplete.Ajax.SelectMultiple = L.U.AutoComplete.Ajax.extend({

View file

@ -51,19 +51,14 @@ L.U.DataLayerPermissions = L.Class.extend({
pk: this.datalayer.umap_id, pk: this.datalayer.umap_id,
}) })
}, },
save: function () { save: async function () {
if (!this.isDirty) return this.datalayer.map.continueSaving() if (!this.isDirty) return this.datalayer.map.continueSaving()
const formData = new FormData() const formData = new FormData()
formData.append('edit_status', this.options.edit_status) formData.append('edit_status', this.options.edit_status)
this.datalayer.map.post(this.getUrl(), { await this.datalayer.map.server.post(this.getUrl(), {}, formData)
data: formData, this.commit()
context: this, this.isDirty = false
callback: function (data) { this.datalayer.map.continueSaving()
this.commit()
this.isDirty = false
this.datalayer.map.continueSaving()
},
})
}, },
commit: function () { commit: function () {

View file

@ -685,7 +685,7 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({
return !this.value() || this.value() === this.obj.getMap().options.default_iconUrl return !this.value() || this.value() === this.obj.getMap().options.default_iconUrl
}, },
showSymbolsTab: function () { showSymbolsTab: async function () {
this.openTab('symbols') this.openTab('symbols')
this.searchInput = L.DomUtil.create('input', '', this.body) this.searchInput = L.DomUtil.create('input', '', this.body)
this.searchInput.type = 'search' this.searchInput.type = 'search'
@ -695,13 +695,11 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({
if (this.pictogram_list) { if (this.pictogram_list) {
this.buildSymbolsList() this.buildSymbolsList()
} else { } else {
this.builder.map.get(this.builder.map.options.urls.pictogram_list_json, { const [{ pictogram_list }, response] = await this.builder.map.server.get(
callback: (data) => { this.builder.map.options.urls.pictogram_list_json
this.pictogram_list = data.pictogram_list )
this.buildSymbolsList() this.pictogram_list = pictogram_list
}, this.buildSymbolsList()
context: this,
})
} }
}, },

View file

@ -98,9 +98,12 @@ L.U.Map.include({
this.urls = new window.umap.URLs(this.options.urls) this.urls = new window.umap.URLs(this.options.urls)
this.ui = new L.U.UI(this._container) this.ui = new L.U.UI(this._container)
this.xhr = new L.U.Xhr(this.ui) this.server = new window.umap.ServerRequest(this.ui)
this.xhr.on('dataloading', (e) => this.fire('dataloading', e)) this.server.on('dataloading', (e) => this.fire('dataloading', e))
this.xhr.on('dataload', (e) => this.fire('dataload', 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.initLoader()
this.name = this.options.name this.name = this.options.name
@ -1083,7 +1086,7 @@ L.U.Map.include({
return properties return properties
}, },
saveSelf: function () { saveSelf: async function () {
const geojson = { const geojson = {
type: 'Feature', type: 'Feature',
geometry: this.geometry(), geometry: this.geometry(),
@ -1093,64 +1096,60 @@ L.U.Map.include({
formData.append('name', this.options.name) formData.append('name', this.options.name)
formData.append('center', JSON.stringify(this.geometry())) formData.append('center', JSON.stringify(this.geometry()))
formData.append('settings', JSON.stringify(geojson)) formData.append('settings', JSON.stringify(geojson))
this.post(this.urls.get('map_save', { map_id: this.options.umap_id }), { const uri = this.urls.get('map_save', { map_id: this.options.umap_id })
data: formData, const [data, response] = await this.server.post(uri, {}, formData)
context: this, let duration = 3000,
callback: function (data) { alert = { content: L._('Map has been saved!'), level: 'info' }
let duration = 3000, if (!this.options.umap_id) {
alert = { content: L._('Map has been saved!'), level: 'info' } alert.content = L._('Congratulations, your map has been created!')
if (!this.options.umap_id) { this.options.umap_id = data.id
alert.content = L._('Congratulations, your map has been created!') this.permissions.setOptions(data.permissions)
this.options.umap_id = data.id this.permissions.commit()
this.permissions.setOptions(data.permissions) if (
this.permissions.commit() data.permissions &&
if ( data.permissions.anonymous_edit_url &&
data.permissions && this.options.urls.map_send_edit_link
data.permissions.anonymous_edit_url && ) {
this.options.urls.map_send_edit_link alert.duration = Infinity
) { alert.content =
alert.duration = Infinity L._(
alert.content = 'Your map has been created! As you are not logged in, here is your secret link to edit the map, please keep it safe:'
L._( ) + `<br>${data.permissions.anonymous_edit_url}`
'Your map has been created! As you are not logged in, here is your secret link to edit the map, please keep it safe:'
) + `<br>${data.permissions.anonymous_edit_url}`
alert.actions = [ alert.actions = [
{ {
label: L._('Send me the link'), label: L._('Send me the link'),
input: L._('Email'), input: L._('Email'),
callback: this.sendEditLink, callback: this.sendEditLink,
callbackContext: this, callbackContext: this,
}, },
{ {
label: L._('Copy link'), label: L._('Copy link'),
callback: () => { callback: () => {
L.Util.copyToClipboard(data.permissions.anonymous_edit_url) L.Util.copyToClipboard(data.permissions.anonymous_edit_url)
this.ui.alert({ this.ui.alert({
content: L._('Secret edit link copied to clipboard!'), content: L._('Secret edit link copied to clipboard!'),
level: 'info', level: 'info',
}) })
}, },
callbackContext: this, callbackContext: this,
}, },
] ]
} }
} else if (!this.permissions.isDirty) { } else if (!this.permissions.isDirty) {
// Do not override local changes to permissions, // Do not override local changes to permissions,
// but update in case some other editors changed them in the meantime. // but update in case some other editors changed them in the meantime.
this.permissions.setOptions(data.permissions) this.permissions.setOptions(data.permissions)
this.permissions.commit() this.permissions.commit()
} }
// Update URL in case the name has changed. // Update URL in case the name has changed.
if (history && history.pushState) if (history && history.pushState)
history.pushState({}, this.options.name, data.url) history.pushState({}, this.options.name, data.url)
else window.location = data.url else window.location = data.url
alert.content = data.info || alert.content alert.content = data.info || alert.content
this.once('saved', () => this.ui.alert(alert)) this.once('saved', () => this.ui.alert(alert))
this.ui.closePanel() this.ui.closePanel()
this.permissions.save() this.permissions.save()
},
})
}, },
save: function () { save: function () {
@ -1820,23 +1819,6 @@ L.U.Map.include({
this.loader.onAdd(this) 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 () { initContextMenu: function () {
this.contextmenu = new L.U.ContextMenu(this) this.contextmenu = new L.U.ContextMenu(this)
this.contextmenu.enable() this.contextmenu.enable()

View file

@ -670,30 +670,26 @@ L.U.DataLayer = L.Evented.extend({
return this return this
}, },
fetchData: function () { fetchData: async function () {
if (!this.umap_id) return if (!this.umap_id) return
if (this._loading) return if (this._loading) return
this._loading = true this._loading = true
this.map.get(this._dataUrl(), { const [geojson, response] = await this.map.server.get(this._dataUrl())
callback: function (geojson, response) { this._last_modified = response.headers['Last-Modified']
this._last_modified = response.getResponseHeader('Last-Modified') // FIXME: for now this property is set dynamically from backend
// FIXME: for now this property is set dynamically from backend // And thus it's not in the geojson file in the server
// And thus it's not in the geojson file in the server // So do not let all options to be reset
// So do not let all options to be reset // Fix is a proper migration so all datalayers settings are
// Fix is a proper migration so all datalayers settings are // in DB, and we remove it from geojson flat files.
// in DB, and we remove it from geojson flat files. if (geojson._umap_options) {
if (geojson._umap_options) { geojson._umap_options.editMode = this.options.editMode
geojson._umap_options.editMode = this.options.editMode }
} // In case of maps pre 1.0 still around
// In case of maps pre 1.0 still around if (geojson._storage) geojson._storage.editMode = this.options.editMode
if (geojson._storage) geojson._storage.editMode = this.options.editMode this.fromUmapGeoJSON(geojson)
this.fromUmapGeoJSON(geojson) this.backupOptions()
this.backupOptions() this.fire('loaded')
this.fire('loaded') this._loading = false
this._loading = false
},
context: this,
})
}, },
fromGeoJSON: function (geojson) { fromGeoJSON: function (geojson) {
@ -1062,13 +1058,10 @@ L.U.DataLayer = L.Evented.extend({
reader.onload = (e) => this.importRaw(e.target.result, type) reader.onload = (e) => this.importRaw(e.target.result, type)
}, },
importFromUrl: function (url, type) { importFromUrl: async function (uri, type) {
url = this.map.localizeUrl(url) uri = this.map.localizeUrl(uri)
this.map.xhr._ajax({ const response = await this.map.request.get(uri)
verb: 'GET', this.importRaw(await response.text(), type)
uri: url,
callback: (data) => this.importRaw(data, type),
})
}, },
getColor: function () { getColor: function () {
@ -1385,8 +1378,8 @@ L.U.DataLayer = L.Evented.extend({
} }
}, },
buildVersionsFieldset: function (container) { buildVersionsFieldset: async function (container) {
const appendVersion = function (data) { const appendVersion = (data) => {
const date = new Date(parseInt(data.at, 10)) const date = new Date(parseInt(data.at, 10))
const content = `${date.toLocaleString(L.lang)} (${parseInt(data.size) / 1000}Kb)` const content = `${date.toLocaleString(L.lang)} (${parseInt(data.size) / 1000}Kb)`
const el = L.DomUtil.create('div', 'umap-datalayer-version', versionsContainer) 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'), { const versionsContainer = L.DomUtil.createFieldset(container, L._('Versions'), {
callback: function () { callback: async function () {
this.map.xhr.get(this.getVersionsUrl(), { const [{versions}, response] = await this.map.server.get(this.getVersionsUrl())
callback: function (data) { versions.forEach(appendVersion)
for (let i = 0; i < data.versions.length; i++) {
appendVersion.call(this, data.versions[i])
}
},
context: this,
})
}, },
context: this, context: this,
}) })
}, },
restore: function (version) { restore: async function (version) {
if (!this.map.editEnabled) return if (!this.map.editEnabled) return
if (!confirm(L._('Are you sure you want to restore this version?'))) return if (!confirm(L._('Are you sure you want to restore this version?'))) return
this.map.xhr.get(this.getVersionUrl(version), { const [geojson, response] = await this.map.server.get(this.getVersionUrl(version))
callback: function (geojson) { if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat.
if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat. if (geojson._umap_options) this.setOptions(geojson._umap_options)
if (geojson._umap_options) this.setOptions(geojson._umap_options) this.empty()
this.empty() if (this.isRemoteLayer()) this.fetchRemoteData()
if (this.isRemoteLayer()) this.fetchRemoteData() else this.addData(geojson)
else this.addData(geojson) this.isDirty = true
this.isDirty = true
},
context: this,
})
}, },
featuresToGeoJSON: function () { featuresToGeoJSON: function () {
@ -1548,7 +1531,7 @@ L.U.DataLayer = L.Evented.extend({
return this.isReadOnly() || this.isRemoteLayer() return this.isReadOnly() || this.isRemoteLayer()
}, },
save: function () { save: async function () {
if (this.isDeleted) return this.saveDelete() if (this.isDeleted) return this.saveDelete()
if (!this.isLoaded()) { if (!this.isLoaded()) {
return return
@ -1566,44 +1549,35 @@ L.U.DataLayer = L.Evented.extend({
map_id: this.map.options.umap_id, map_id: this.map.options.umap_id,
pk: this.umap_id, pk: this.umap_id,
}) })
this.map.post(saveUrl, { const headers = this._last_modified
data: formData, ? { 'If-Unmodified-Since': this._last_modified }
callback: function (data, response) { : {}
// Response contains geojson only if save has conflicted and conflicts have const [data, response] = await this.map.server.post(saveUrl, headers, formData)
// been resolved. So we need to reload to get extra data (saved from someone else) // Response contains geojson only if save has conflicted and conflicts have
if (data.geojson) { // been resolved. So we need to reload to get extra data (saved from someone else)
this.clear() if (data.geojson) {
this.fromGeoJSON(data.geojson) this.clear()
delete data.geojson this.fromGeoJSON(data.geojson)
} delete data.geojson
this._geojson = geojson }
this._last_modified = response.getResponseHeader('Last-Modified') this._geojson = geojson
this.setUmapId(data.id) this._last_modified = response.headers['Last-Modified']
this.updateOptions(data) this.setUmapId(data.id)
this.backupOptions() this.updateOptions(data)
this.connectToMap() this.backupOptions()
this._loaded = true this.connectToMap()
this.redraw() // Needed for reordering features this._loaded = true
this.isDirty = false this.redraw() // Needed for reordering features
this.permissions.save() this.isDirty = false
}, this.permissions.save()
context: this,
headers: this._last_modified
? { 'If-Unmodified-Since': this._last_modified }
: {},
})
}, },
saveDelete: function () { saveDelete: async function () {
const callback = function () { if (this.umap_id) {
this.isDirty = false await this.map.server.post(this.getDeleteUrl())
this.map.continueSaving()
} }
if (!this.umap_id) return callback.call(this) this.isDirty = false
this.map.xhr.post(this.getDeleteUrl(), { this.map.continueSaving()
callback: callback,
context: this,
})
}, },
getMap: function () { getMap: function () {

View file

@ -131,21 +131,17 @@ L.U.MapPermissions = L.Class.extend({
this.map.ui.openPanel({ data: { html: container }, className: 'dark' }) this.map.ui.openPanel({ data: { html: container }, className: 'dark' })
}, },
attach: function () { attach: async function () {
this.map.post(this.getAttachUrl(), { await this.map.server.post(this.getAttachUrl())
callback: function () { this.options.owner = this.map.options.user
this.options.owner = this.map.options.user this.map.ui.alert({
this.map.ui.alert({ content: L._('Map has been attached to your account'),
content: L._('Map has been attached to your account'), level: 'info',
level: 'info',
})
this.map.ui.closePanel()
},
context: this,
}) })
this.map.ui.closePanel()
}, },
save: function () { save: async function () {
if (!this.isDirty) return this.map.continueSaving() if (!this.isDirty) return this.map.continueSaving()
const formData = new FormData() const formData = new FormData()
if (!this.isAnonymousMap() && this.options.editors) { 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('owner', this.options.owner && this.options.owner.id)
formData.append('share_status', this.options.share_status) formData.append('share_status', this.options.share_status)
} }
this.map.post(this.getUrl(), { await this.map.server.post(this.getUrl(), {}, formData)
data: formData, this.commit()
context: this, this.isDirty = false
callback: function (data) { this.map.continueSaving()
this.commit() this.map.fire('postsync')
this.isDirty = false
this.map.continueSaving()
this.map.fire('postsync')
},
})
}, },
getUrl: function () { getUrl: function () {