diff --git a/docs/config/settings.md b/docs/config/settings.md
index fe7273bd..8d12d55d 100644
--- a/docs/config/settings.md
+++ b/docs/config/settings.md
@@ -211,6 +211,46 @@ Which feed to display on the home page. Three valid values:
- `"highlighted"`, which shows the maps that have been starred by a staff member
- `None`, which does not show any map on the home page
+#### UMAP_IMPORTERS
+
+Activate preset importers to connect uMap with external sources.
+
+Must be a dict in the form: `{"importer_name": {"option1": "value"}}`.
+
+Only the key is mandatory to activate an importer (eg. `{"overpass": {}}`).
+
+Example:
+
+```
+UMAP_IMPORTERS = {
+ "geodatamine": {"name": "my custom name"},
+ "overpass": {"url": "https://overpass-api.de/api/interpreter"},
+ "communesfr": {"name": "Communes françaises"},
+ "datasets": {
+ "choices": [
+ {
+ "label": "Régions",
+ "url": "https://domain.org/path/to/file.geojson",
+ "format": "geojson",
+ },
+ {
+ "label": "Départements",
+ "url": "https://domain.org/path/to/other/file.csv",
+ "format": "csv",
+ },
+ ]
+ },
+}
+
+```
+
+Available importers:
+
+- `overpass`: very lite form to build a URL retrieving data from Overpass API.
+- `geodatamine`: allow to interact with [GéoDataMine](https://geodatamine.fr/) API
+- `communesfr`: download French communes boundaries, from https://geo.api.gouv.fr/
+- `datasets`: define URLs you want to promote to users, with a `name` and a `format`
+
#### UMAP_MAPS_PER_PAGE
How many maps to show in maps list, like search or home page.
diff --git a/umap/settings/base.py b/umap/settings/base.py
index df56ccd7..efad5281 100644
--- a/umap/settings/base.py
+++ b/umap/settings/base.py
@@ -259,6 +259,7 @@ UMAP_DEFAULT_SHARE_STATUS = None
UMAP_DEFAULT_EDIT_STATUS = None
UMAP_DEFAULT_FEATURES_HAVE_OWNERS = False
UMAP_HOME_FEED = "latest"
+UMAP_IMPORTERS = {}
UMAP_READONLY = env("UMAP_READONLY", default=False)
UMAP_GZIP = True
diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css
index a015e3a2..3fd1bac7 100644
--- a/umap/static/umap/base.css
+++ b/umap/static/umap/base.css
@@ -60,6 +60,21 @@ kbd {
display: inline-block;
white-space: nowrap;
}
+h3 {
+ font-size: 1.2rem;
+}
+h4 {
+ font-size: 1.1rem;
+}
+hgroup > * {
+ margin-bottom: 0;
+}
+hgroup {
+ margin-bottom: var(--box-space);
+}
+hgroup > :not(:first-child):last-child {
+ font-weight: normal;
+}
/*
* List
@@ -69,6 +84,9 @@ ul {
list-style-position:inside;
list-style-type:none;
}
+dt {
+ font-weight: bold;
+}
/* ************************************************* */
/* *********************** GRID ******************** */
@@ -129,14 +147,20 @@ ul {
margin-right: auto;
float: none;
}
-
+.grid-container {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+.grid-container > * {
+ text-align: center;
+}
/* *********** */
/* forms */
/* *********** */
input[type="text"], input[type="password"], input[type="date"],
input[type="datetime-local"], input[type="email"], input[type="number"],
-input[type="search"], input[type="tel"], input[type="time"],
+input[type="search"], input[type="tel"], input[type="time"], input[type="file"],
input[type="url"], textarea {
background-color: white;
border: 1px solid #CCCCCC;
@@ -145,9 +169,8 @@ input[type="url"], textarea {
color: rgba(0, 0, 0, 0.75);
display: block;
font-family: inherit;
- font-size: 14px;
- height: 32px;
- margin: 0 0 14px;
+ margin: 0;
+ margin-bottom: var(--box-margin);
padding: 7px;
width: 100%;
}
@@ -169,12 +192,13 @@ input[type="checkbox"]:after {
border: 1px solid var(--color-lightGray);
cursor: pointer;
text-align: center;
- font-size: 1.3rem;
- line-height: 1rem;
+ font-size: 1rem;
+ line-height: 0.8rem;
}
input[type=checkbox]:checked:after {
background-color: var(--color-lightCyan);
content: '✓';
+ color: var(--color-darkGray);
}
label input[type="radio"] {
appearance: none;
@@ -197,8 +221,9 @@ label input[type="radio"]:checked:after {
background-color: var(--color-lightCyan);
content: '•';
font-size: 3rem;
- line-height: 1.1rem;
+ line-height: 0.8rem;
color: var(--color-darkGray);
+ text-indent: -1px;
}
input[data-modified=true] {
@@ -217,6 +242,7 @@ select {
height: 28px;
line-height: 28px;
margin-top: 5px;
+ margin-bottom: var(--box-margin);
}
.dark select {
color: #efefef;
@@ -291,7 +317,6 @@ input + .help-text {
}
.formbox {
min-height: 36px;
- line-height: 28px;
margin-bottom: 14px;
}
.formbox.with-switch {
@@ -300,12 +325,19 @@ input + .help-text {
.formbox select {
width: calc(100% - 14px);
}
+fieldset.formbox {
+ border: none;
+ border-top: 1px solid var(--color-lightGray);
+}
label {
display: block;
font-size: 12px;
line-height: 21px;
width: 100%;
}
+label + label {
+ margin-top: var(--box-space);
+}
.content label {
font-weight: bold;
}
@@ -366,7 +398,7 @@ details summary {
border: 1px solid var(--color-darkGray);
}
fieldset legend {
- font-size: 1.1rem;
+ font-size: 1rem;
padding: 0 5px;
}
@@ -389,6 +421,9 @@ fieldset legend {
border-radius: 50%;
content: attr(data-badge);
}
+[hidden] {
+ display: none!important;
+}
/* Switch */
input.switch:empty {
@@ -475,10 +510,13 @@ input.switch:checked ~ label:after {
.button-bar.half {
grid-template-columns: 1fr 1fr;
}
+.button-bar.by3,
+.button-bar.by5,
.umap-multiplechoice.by3,
.umap-multiplechoice.by5 {
grid-template-columns: 1fr 1fr 1fr;
}
+.button-bar.by4,
.umap-multiplechoice.by4 {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
diff --git a/umap/static/umap/content.css b/umap/static/umap/content.css
index 24d384c1..6996ba86 100644
--- a/umap/static/umap/content.css
+++ b/umap/static/umap/content.css
@@ -248,7 +248,7 @@ input[type="submit"],
ul.umap-autocomplete {
position: absolute;
background-color: white;
- z-index: 1010;
+ z-index: 10100;
box-shadow: 0 4px 9px #999999;
}
.umap-autocomplete li {
@@ -262,6 +262,7 @@ ul.umap-autocomplete {
}
.umap-singleresult {
margin-bottom: 10px;
+ clear: both;
}
.umap-singleresult div,
.umap-multiresult li {
diff --git a/umap/static/umap/css/dialog.css b/umap/static/umap/css/dialog.css
index 328c1ac2..b58015e8 100644
--- a/umap/static/umap/css/dialog.css
+++ b/umap/static/umap/css/dialog.css
@@ -2,7 +2,7 @@
z-index: 10001;
margin: auto;
margin-top: 100px;
- width: 50vw;
+ width: 40vw;
max-width: 100vw;
max-height: 50vh;
padding: 20px;
diff --git a/umap/static/umap/css/importers.css b/umap/static/umap/css/importers.css
new file mode 100644
index 00000000..cfebfc3c
--- /dev/null
+++ b/umap/static/umap/css/importers.css
@@ -0,0 +1,44 @@
+.importers ul [type=button] {
+ background: none;
+ font-size: 1rem;
+ border: none;
+ width: initial;
+ display: inline-block;
+}
+.importer h3:before {
+ background-image: url(../img/importers/random.svg);
+ background-repeat: no-repeat;
+ background-position: center;
+ content: '';
+ width: 36px;
+ height: 36px;
+ display: inline-block;
+ margin-right: 10px;
+ background-size: 100%;
+ vertical-align: -10px;
+}
+.importers ul [type=button]:before {
+ background-image: url(../img/importers/random.svg);
+ background-repeat: no-repeat;
+ background-position: center;
+ content: '';
+ width: 100%;
+ height: 50px;
+ display: block;
+}
+.importer.geodatamine h3:before,
+.importers ul .geodatamine:before {
+ background-image: url(../img/importers/geodatamine.svg);
+}
+.importer.communesfr h3:before,
+.importers ul .communesfr:before {
+ background-image: url(../img/importers/communesfr.svg);
+}
+.importer.overpass h3:before,
+.importers ul .overpass:before {
+ background-image: url(../img/importers/overpass.svg);
+}
+.importer.datasets h3:before,
+.importers ul .datasets:before {
+ background-image: url(../img/importers/datasets.svg);
+}
diff --git a/umap/static/umap/css/panel.css b/umap/static/umap/css/panel.css
index f4ea420d..52312c56 100644
--- a/umap/static/umap/css/panel.css
+++ b/umap/static/umap/css/panel.css
@@ -14,6 +14,7 @@
border: 1px solid var(--color-lightGray);
bottom: calc(var(--current-footer-height) + var(--panel-bottom));
box-sizing: border-box;
+ counter-reset: step;
}
.panel.dark {
border: 1px solid #222;
@@ -44,6 +45,15 @@
.panel h3 {
line-height: 120%;
}
+.panel .counter::before {
+ counter-increment: step;
+ content: counter(step) ". ";
+}
+.panel .counter {
+ display: block;
+ margin-top: var(--panel-gutter);
+ font-weight: bold;
+}
@media all and (orientation:landscape) {
.panel {
top: var(--current-header-height);
diff --git a/umap/static/umap/img/importers/communesfr.svg b/umap/static/umap/img/importers/communesfr.svg
new file mode 100644
index 00000000..d0e6a498
--- /dev/null
+++ b/umap/static/umap/img/importers/communesfr.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/umap/static/umap/img/importers/datasets.svg b/umap/static/umap/img/importers/datasets.svg
new file mode 100644
index 00000000..80ae1436
--- /dev/null
+++ b/umap/static/umap/img/importers/datasets.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/umap/static/umap/img/importers/geodatamine.svg b/umap/static/umap/img/importers/geodatamine.svg
new file mode 100644
index 00000000..ec40a1e2
--- /dev/null
+++ b/umap/static/umap/img/importers/geodatamine.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/umap/static/umap/img/importers/overpass.svg b/umap/static/umap/img/importers/overpass.svg
new file mode 100644
index 00000000..3067edaf
--- /dev/null
+++ b/umap/static/umap/img/importers/overpass.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/umap/static/umap/img/importers/random.svg b/umap/static/umap/img/importers/random.svg
new file mode 100644
index 00000000..45aa3c58
--- /dev/null
+++ b/umap/static/umap/img/importers/random.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/umap/static/umap/img/importers/random1.svg b/umap/static/umap/img/importers/random1.svg
new file mode 100644
index 00000000..5d034851
--- /dev/null
+++ b/umap/static/umap/img/importers/random1.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/umap/static/umap/img/importers/random2.svg b/umap/static/umap/img/importers/random2.svg
new file mode 100644
index 00000000..6819af0e
--- /dev/null
+++ b/umap/static/umap/img/importers/random2.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/umap/static/umap/js/modules/autocomplete.js b/umap/static/umap/js/modules/autocomplete.js
index 4fde8b84..48c0cdaa 100644
--- a/umap/static/umap/js/modules/autocomplete.js
+++ b/umap/static/umap/js/modules/autocomplete.js
@@ -1,6 +1,11 @@
-import { DomUtil, DomEvent, setOptions } from '../../vendors/leaflet/leaflet-src.esm.js'
+import {
+ DomUtil,
+ DomEvent,
+ setOptions,
+ Util,
+} from '../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from './i18n.js'
-import { ServerRequest } from './request.js'
+import { Request, ServerRequest } from './request.js'
export class BaseAutocomplete {
constructor(el, options) {
@@ -216,11 +221,21 @@ export class BaseAutocomplete {
}
}
-class BaseAjax extends BaseAutocomplete {
+export class BaseAjax extends BaseAutocomplete {
constructor(el, options) {
super(el, options)
- this.server = new ServerRequest()
+ this.setUrl()
+ this.initRequest()
}
+
+ setUrl() {
+ this.url = this.options?.url
+ }
+
+ initRequest() {
+ this.request = new Request()
+ }
+
optionToResult(option) {
return {
value: option.value,
@@ -237,71 +252,96 @@ class BaseAjax extends BaseAutocomplete {
if (val === this.cache) return
else this.cache = val
val = val.toLowerCase()
- const [{ data }, response] = await this.server.get(
- `/agnocomplete/AutocompleteUser/?q=${encodeURIComponent(val)}`
- )
- this.handleResults(data)
+ const url = Util.template(this.url, { q: encodeURIComponent(val) })
+ this.handleResults(await this._search(url))
+ }
+
+ async _search(url) {
+ const response = await this.request.get(url)
+ if (response && response.ok) {
+ return await response.json()
+ }
}
}
-export class AjaxAutocompleteMultiple extends BaseAjax {
- initSelectedContainer() {
- return DomUtil.after(
- this.input,
- DomUtil.element({ tagName: 'ul', className: 'umap-multiresult' })
- )
+class BaseServerAjax extends BaseAjax {
+ setUrl() {
+ this.url = '/agnocomplete/AutocompleteUser/?q={q}'
}
- displaySelected(result) {
- const result_el = DomUtil.element({
- tagName: 'li',
- parent: this.selectedContainer,
- })
- result_el.textContent = result.item.label
- const close = DomUtil.element({
- tagName: 'span',
- parent: result_el,
- className: 'close',
- textContent: '×',
- })
- DomEvent.on(close, 'click', () => {
- this.selectedContainer.removeChild(result_el)
- this.options.on_unselect(result)
- })
- this.hide()
+ initRequest() {
+ this.server = new ServerRequest()
+ }
+ async _search(url) {
+ const [{ data }, response] = await this.server.get(url)
+ return data
}
}
-export class AjaxAutocomplete extends BaseAjax {
- initSelectedContainer() {
- return DomUtil.after(
- this.input,
- DomUtil.element({ tagName: 'div', className: 'umap-singleresult' })
- )
+export const SingleMixin = (Base) =>
+ class extends Base {
+ initSelectedContainer() {
+ return DomUtil.after(
+ this.input,
+ DomUtil.element({ tagName: 'div', className: 'umap-singleresult' })
+ )
+ }
+
+ displaySelected(result) {
+ const result_el = DomUtil.element({
+ tagName: 'div',
+ parent: this.selectedContainer,
+ })
+ result_el.textContent = result.item.label
+ const close = DomUtil.element({
+ tagName: 'span',
+ parent: result_el,
+ className: 'close',
+ textContent: '×',
+ })
+ this.input.style.display = 'none'
+ DomEvent.on(
+ close,
+ 'click',
+ function () {
+ this.selectedContainer.innerHTML = ''
+ this.input.style.display = 'block'
+ },
+ this
+ )
+ this.hide()
+ }
}
- displaySelected(result) {
- const result_el = DomUtil.element({
- tagName: 'div',
- parent: this.selectedContainer,
- })
- result_el.textContent = result.item.label
- const close = DomUtil.element({
- tagName: 'span',
- parent: result_el,
- className: 'close',
- textContent: '×',
- })
- this.input.style.display = 'none'
- DomEvent.on(
- close,
- 'click',
- function () {
- this.selectedContainer.innerHTML = ''
- this.input.style.display = 'block'
- },
- this
- )
- this.hide()
+export const MultipleMixin = (Base) =>
+ class extends Base {
+ initSelectedContainer() {
+ return DomUtil.after(
+ this.input,
+ DomUtil.element({ tagName: 'ul', className: 'umap-multiresult' })
+ )
+ }
+
+ displaySelected(result) {
+ const result_el = DomUtil.element({
+ tagName: 'li',
+ parent: this.selectedContainer,
+ })
+ result_el.textContent = result.item.label
+ const close = DomUtil.element({
+ tagName: 'span',
+ parent: result_el,
+ className: 'close',
+ textContent: '×',
+ })
+ DomEvent.on(close, 'click', () => {
+ this.selectedContainer.removeChild(result_el)
+ this.options.on_unselect(result)
+ })
+ this.hide()
+ }
}
-}
+
+export class AjaxAutocompleteMultiple extends MultipleMixin(BaseServerAjax) {}
+
+export class AjaxAutocomplete extends SingleMixin(BaseServerAjax) {}
diff --git a/umap/static/umap/js/modules/caption.js b/umap/static/umap/js/modules/caption.js
index f721286a..90d53867 100644
--- a/umap/static/umap/js/modules/caption.js
+++ b/umap/static/umap/js/modules/caption.js
@@ -18,8 +18,9 @@ export default class Caption {
open() {
const container = DomUtil.create('div', 'umap-caption')
- DomUtil.createTitle(container, this.map.options.name, 'icon-caption icon-block')
- this.map.permissions.addOwnerLink('h5', container)
+ const hgroup = DomUtil.element({tagName: 'hgroup', parent: container})
+ DomUtil.createTitle(hgroup, this.map.options.name, 'icon-caption icon-block')
+ this.map.permissions.addOwnerLink('h4', hgroup)
if (this.map.options.description) {
const description = DomUtil.element({
tagName: 'div',
diff --git a/umap/static/umap/js/modules/help.js b/umap/static/umap/js/modules/help.js
index da2bbe42..74c2b9f2 100644
--- a/umap/static/umap/js/modules/help.js
+++ b/umap/static/umap/js/modules/help.js
@@ -100,30 +100,33 @@ const ENTRIES = {
browsable: translate(
'Set it to false to hide this layer from the slideshow, the data browser, the popup navigation…'
),
+ importMode: translate(
+ 'When providing an URL, uMap can copy the remote data in a layer, or add this URL as remote source of the layer. In that case, data will always be fetched from that URL, and thus be up to date, but it will not be possible to edit it inside uMap.'
+ ),
importFormats: `
-
GeoJSON
-
${translate('All properties are imported.')}
-
GPX
-
${translate('Properties imported:')}name, desc
-
KML
-
${translate('Properties imported:')}name, description
-
CSV
-
${translate('Comma, tab or semi-colon separated values. SRS WGS84 is implied. Only Point geometries are imported. The import will look at the column headers for any mention of «lat» and «lon» at the begining of the header, case insensitive. All other column are imported as properties.')}
-
uMap
-
${translate('Imports all umap data, including layers and settings.')}
+
GeoJSON
+
${translate('All properties are imported.')}
+
GPX
+
${translate('Properties imported:')}name, desc
+
KML
+
${translate('Properties imported:')}name, description
+
CSV
+
${translate('Comma, tab or semi-colon separated values. SRS WGS84 is implied. Only Point geometries are imported. The import will look at the column headers for any mention of «lat» and «lon» at the begining of the header, case insensitive. All other column are imported as properties.')}
+
uMap
+
${translate('Imports all umap data, including layers and settings.')}
`,
dynamicProperties: `
-
${translate('Dynamic properties')}
+
${translate('Dynamic properties')}
${translate('Use placeholders with feature properties between brackets, eg. {name}, they will be dynamically replaced by the corresponding values.')}
`,
textFormatting: `
-
${translate('Text formatting')}
+
${translate('Text formatting')}
${translate('*single star for italic*')}
${translate('**double star for bold**')}
@@ -141,6 +144,22 @@ const ENTRIES = {
`,
+
+ overpassImporter: `
+
+
${translate('Overpass supported expressions')}
+
+ ${translate('key (eg. building)')}
+ ${translate('!key (eg. !name)')}
+ ${translate('key=value (eg. building=yes)')}
+ ${translate('key!=value (eg. building!=yes)')}
+ ${translate('key~value (eg. name~Grisy)')}
+ ${translate('key="value|value2" (eg. name="Paris|Berlin")')}
+
+
+
+
+ `,
}
export default class Help {
@@ -170,6 +189,7 @@ export default class Help {
show(entries) {
const container = DomUtil.add('div')
+ DomUtil.createTitle(container, translate('Help'))
// Special dynamic case. Do we still think this dialog is usefull ?
if (entries == 'edit') {
DomUtil.element({
@@ -209,9 +229,15 @@ export default class Help {
return button
}
+ parse(container) {
+ for (const element of container.querySelectorAll('[data-help]')) {
+ this.button(element, element.dataset.help.split(','))
+ }
+ }
+
_buildEditEntry() {
const container = DomUtil.create('div', '')
- const title = DomUtil.create('h3', '', container)
+ const title = DomUtil.create('h4', '', container)
const actionsContainer = DomUtil.create('ul', 'umap-edit-actions', container)
const addAction = (action) => {
const actionContainer = DomUtil.add('li', '', actionsContainer)
diff --git a/umap/static/umap/js/modules/importer.js b/umap/static/umap/js/modules/importer.js
index 889d7ec3..ef450da6 100644
--- a/umap/static/umap/js/modules/importer.js
+++ b/umap/static/umap/js/modules/importer.js
@@ -1,148 +1,214 @@
import { DomUtil, DomEvent } from '../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from './i18n.js'
import { uMapAlert as Alert } from '../components/alerts/alert.js'
+import Dialog from './ui/dialog.js'
+import { SCHEMA } from './schema.js'
+import * as Utils from './utils.js'
+
+const TEMPLATE = `
+ ${translate('Import data')}
+
+ ${translate('Choose data')}
+
+
+
+
+
${translate('Import helpers:')}
+
+
+
+
+ ${translate('Choose the format')}
+
+
+
+ ${translate('Choose the layer')}
+
+
+
+ ${translate('Replace layer content')}
+
+
+
+
+ ${translate('Choose import mode')}
+
+
+ ${translate('Copy into the layer')}
+
+
+
+ ${translate('Link to the layer as remote data')}
+
+
+
+ `
export default class Importer {
constructor(map) {
this.map = map
- this.presets = map.options.importPresets
this.TYPES = ['geojson', 'csv', 'gpx', 'kml', 'osm', 'georss', 'umap']
+ this.IMPORTERS = []
+ this.loadImporters()
+ this.dialog = new Dialog(this.map._controlContainer)
+ }
+
+ loadImporters() {
+ for (const key of Object.keys(this.map.options.importers || {})) {
+ import(`./importers/${key}.js`).then((mod) => {
+ this.IMPORTERS.push(new mod.Importer(this.map, this.map.options.importers[key]))
+ })
+ }
+ }
+
+ qs(query) {
+ return this.container.querySelector(query)
+ }
+
+ get url() {
+ return this.qs('[type=url]').value
+ }
+
+ set url(value) {
+ this.qs('[type=url]').value = value
+ this.onChange()
+ }
+
+ get format() {
+ return this.qs('[name=format]').value
+ }
+
+ set format(value) {
+ this.qs('[name=format]').value = value
+ this.onChange()
+ }
+
+ get files() {
+ return this.qs('[type=file]').files
+ }
+
+ get raw() {
+ return this.qs('textarea').value
+ }
+
+ get clear() {
+ return Boolean(this.qs('[name=clear]').checked)
+ }
+
+ get action() {
+ return this.qs('[name=action]:checked').value
+ }
+
+ get layerId() {
+ return this.qs('[name=layer-id]').value
+ }
+
+ set layerId(value) {
+ this.qs('[name=layer-id]').value = value
+ }
+
+ get layerName() {
+ return this.qs('[name=layer-name]').value
+ }
+
+ set layerName(name) {
+ this.qs('[name=layer-name]').value = name
+ this.onChange()
+ }
+
+ get layer() {
+ return (
+ this.map.datalayers[this.layerId] ||
+ this.map.createDataLayer({ name: this.layerName })
+ )
}
build() {
this.container = DomUtil.create('div', 'umap-upload')
- this.title = DomUtil.createTitle(
- this.container,
- translate('Import data'),
- 'icon-upload'
+ this.container.innerHTML = TEMPLATE
+ if (this.IMPORTERS.length) {
+ const parent = this.container.querySelector('.importers ul')
+ for (const plugin of this.IMPORTERS.sort((a, b) => (a.id > b.id ? 1 : -1))) {
+ L.DomUtil.createButton(
+ plugin.id,
+ DomUtil.element({tagName: 'li', parent}),
+ plugin.name,
+ () => plugin.open(this)
+ )
+ }
+ this.qs('.importers').toggleAttribute('hidden', false)
+ }
+ for (const type of this.TYPES) {
+ DomUtil.element({
+ tagName: 'option',
+ parent: this.qs('[name=format]'),
+ value: type,
+ textContent: type,
+ })
+ }
+ this.map.help.parse(this.container)
+ DomEvent.on(this.qs('[name=submit]'), 'click', this.submit, this)
+ DomEvent.on(this.qs('[type=file]'), 'change', this.onFileChange, this)
+ for (const element of this.container.querySelectorAll('[onchange]')) {
+ DomEvent.on(element, 'change', this.onChange, this)
+ }
+ }
+
+ onChange() {
+ this.qs('#destination').toggleAttribute('hidden', this.format === 'umap')
+ this.qs('#import-mode').toggleAttribute(
+ 'hidden',
+ this.format === 'umap' || !this.url
)
- this.presetBox = DomUtil.create('div', 'formbox', this.container)
- this.presetSelect = DomUtil.create('select', '', this.presetBox)
- this.fileBox = DomUtil.create('div', 'formbox', this.container)
- this.fileInput = DomUtil.element({
- tagName: 'input',
- type: 'file',
- parent: this.fileBox,
- multiple: 'multiple',
- autofocus: true,
- })
- this.urlInput = DomUtil.element({
- tagName: 'input',
- type: 'text',
- parent: this.container,
- placeholder: translate('Provide an URL here'),
- })
- this.rawInput = DomUtil.element({
- tagName: 'textarea',
- parent: this.container,
- placeholder: translate('Paste your data here'),
- })
- this.typeLabel = DomUtil.add(
- 'label',
- '',
- this.container,
- translate('Choose the format of the data to import')
- )
- this.layerLabel = DomUtil.add(
- 'label',
- '',
- this.container,
- translate('Choose the layer to import in')
- )
- this.clearLabel = DomUtil.element({
- tagName: 'label',
- parent: this.container,
- textContent: translate('Replace layer content'),
- for: 'datalayer-clear-check',
- })
- this.submitInput = DomUtil.element({
- tagName: 'input',
- type: 'button',
- parent: this.container,
- value: translate('Import'),
- className: 'button',
- })
- this.map.help.button(this.typeLabel, 'importFormats')
- this.typeInput = DomUtil.element({
- tagName: 'select',
- name: 'format',
- parent: this.typeLabel,
- })
- this.layerInput = DomUtil.element({
- tagName: 'select',
- name: 'datalayer',
- parent: this.layerLabel,
- })
- this.clearFlag = DomUtil.element({
- tagName: 'input',
- type: 'checkbox',
- name: 'clear',
- id: 'datalayer-clear-check',
- parent: this.clearLabel,
+ this.qs('[name=layer-name]').toggleAttribute('hidden', Boolean(this.layerId))
+ this.qs('#clear').toggleAttribute('hidden', !Boolean(this.layerId))
+ }
+
+ onFileChange(e) {
+ let type = '',
+ newType
+ for (const file of e.target.files) {
+ newType = U.Utils.detectFileType(file)
+ if (!type && newType) type = newType
+ if (type && newType !== type) {
+ type = ''
+ break
+ }
+ }
+ this.format = type
+ }
+
+ onLoad() {
+ this.qs('[type=file]').value = null
+ this.url = null
+ this.format = undefined
+ this.layerName = null
+ const layerSelect = this.qs('[name="layer-id"]')
+ layerSelect.innerHTML = ''
+ this.map.eachDataLayerReverse((datalayer) => {
+ if (datalayer.isLoaded() && !datalayer.isRemoteLayer()) {
+ DomUtil.element({
+ tagName: 'option',
+ parent: layerSelect,
+ textContent: datalayer.options.name,
+ value: L.stamp(datalayer),
+ })
+ }
})
DomUtil.element({
tagName: 'option',
value: '',
- textContent: translate('Choose the data format'),
- parent: this.typeInput,
+ textContent: translate('Import in a new layer'),
+ parent: layerSelect,
+ selected: true,
})
- for (const type of this.TYPES) {
- const option = DomUtil.create('option', '', this.typeInput)
- option.value = option.textContent = type
- }
- if (this.presets.length) {
- const noPreset = DomUtil.create('option', '', this.presetSelect)
- noPreset.value = noPreset.textContent = translate('Choose a preset')
- for (const preset of this.presets) {
- option = DomUtil.create('option', '', presetSelect)
- option.value = preset.url
- option.textContent = preset.label
- }
- } else {
- this.presetBox.style.display = 'none'
- }
- DomEvent.on(this.submitInput, 'click', this.submit, this)
- DomEvent.on(
- this.fileInput,
- 'change',
- (e) => {
- let type = '',
- newType
- for (let i = 0; i < e.target.files.length; i++) {
- newType = U.Utils.detectFileType(e.target.files[i])
- if (!type && newType) type = newType
- if (type && newType !== type) {
- type = ''
- break
- }
- }
- this.typeInput.value = type
- },
- this
- )
}
open() {
if (!this.container) this.build()
const onLoad = this.map.editPanel.open({ content: this.container })
- onLoad.then(() => {
- this.fileInput.value = null
- this.layerInput.innerHTML = ''
- let option
- this.map.eachDataLayerReverse((datalayer) => {
- if (datalayer.isLoaded() && !datalayer.isRemoteLayer()) {
- const id = L.stamp(datalayer)
- option = DomUtil.add('option', '', this.layerInput, datalayer.options.name)
- option.value = id
- }
- })
- DomUtil.element({
- tagName: 'option',
- value: '',
- textContent: translate('Import in a new layer'),
- parent: this.layerInput,
- })
- })
+ onLoad.then(() => this.onLoad())
}
openFiles() {
@@ -151,39 +217,64 @@ export default class Importer {
}
submit() {
- let type = this.typeInput.value
- const layerId = this.layerInput[this.layerInput.selectedIndex].value
- let layer
- if (type === 'umap') {
- this.map.once('postsync', this.map._setDefaultCenter)
- }
- if (layerId) layer = this.map.datalayers[layerId]
- if (layer && this.clearFlag.checked) layer.empty()
- if (this.fileInput.files.length) {
- for (let i = 0, file; (file = this.fileInput.files[i]); i++) {
- this.map.processFileToImport(file, layer, type)
- }
- } else {
- if (!type) {
- return Alert.error(L._('Please choose a format'))
- }
- if (this.rawInput.value && type === 'umap') {
- try {
- this.map.importRaw(this.rawInput.value, type)
- } catch (e) {
- Alert.error(L._('Invalid umap data'))
- console.error(e)
+ if (this.format === 'umap') this.full()
+ else if (!this.url) this.copy()
+ else if (this.action) this[this.action]()
+ }
+
+ full() {
+ this.map.once('postsync', this.map._setDefaultCenter)
+ try {
+ if (this.files.length) {
+ for (const file of this.files) {
+ this.map.processFileToImport(file, null, 'umap')
}
- } else {
- if (!layer) layer = this.map.createDataLayer()
- if (this.rawInput.value) layer.importRaw(this.rawInput.value, type)
- else if (this.urlInput.value) layer.importFromUrl(this.urlInput.value, type)
- else if (this.presetSelect.selectedIndex > 0)
- layer.importFromUrl(
- this.presetSelect[this.presetSelect.selectedIndex].value,
- type
- )
+ } else if (this.raw) {
+ this.map.importRaw(this.raw)
+ } else if (this.url) {
+ this.map.importFromUrl(this.url, this.format)
}
+ } catch (e) {
+ Alert.error(translate('Invalid umap data'))
+ console.error(e)
+ }
+ }
+
+ link() {
+ if (!this.url) return
+ if (!this.format) {
+ Alert.error(translate('Please choose a format'))
+ return
+ }
+ let layer = this.layer
+ layer.options.remoteData = {
+ url: this.url,
+ format: this.format,
+ }
+ if (this.map.options.urls.ajax_proxy) {
+ layer.options.remoteData.proxy = true
+ layer.options.remoteData.ttl = SCHEMA.ttl.default
+ }
+ layer.fetchRemoteData(true)
+ }
+
+ copy() {
+ // Format may be guessed from file later.
+ // Usefull in case of multiple files with different formats.
+ if (!this.format && !this.files.length) {
+ Alert.error(translate('Please choose a format'))
+ return
+ }
+ let layer = this.layer
+ if (this.clear) layer.empty()
+ if (this.files.length) {
+ for (const file of this.files) {
+ this.map.processFileToImport(file, layer, this.format)
+ }
+ } else if (this.raw) {
+ layer.importRaw(this.raw, this.format)
+ } else if (this.url) {
+ layer.importFromUrl(this.url, this.format)
}
}
}
diff --git a/umap/static/umap/js/modules/importers/communesfr.js b/umap/static/umap/js/modules/importers/communesfr.js
new file mode 100644
index 00000000..6a7348cc
--- /dev/null
+++ b/umap/static/umap/js/modules/importers/communesfr.js
@@ -0,0 +1,44 @@
+import { DomUtil } from '../../../vendors/leaflet/leaflet-src.esm.js'
+import { BaseAjax, SingleMixin } from '../autocomplete.js'
+
+class Autocomplete extends SingleMixin(BaseAjax) {
+ createResult(item) {
+ return super.createResult({
+ value: item.code,
+ label: `${item.nom} (${item.code})`,
+ })
+ }
+}
+
+export class Importer {
+ constructor(map, options) {
+ this.name = options.name || 'Communes'
+ this.id = 'communesfr'
+ }
+
+ async open(importer) {
+ const container = DomUtil.create('div')
+ DomUtil.createTitle(container, this.name)
+ DomUtil.element({
+ tagName: 'p',
+ parent: container,
+ textContent: "Importer les contours d'une commune française.",
+ })
+ const options = {
+ placeholder: 'Commune…',
+ url: 'https://geo.api.gouv.fr/communes?nom={q}&limit=5',
+ on_select: (choice) => {
+ importer.url = `https://geo.api.gouv.fr/communes?code=${choice.item.value}&format=geojson&geometry=contour`
+ importer.format = 'geojson'
+ importer.layerName = choice.item.label
+ importer.dialog.close()
+ },
+ }
+ this.autocomplete = new Autocomplete(container, options)
+
+ importer.dialog.open({
+ content: container,
+ className: `${this.id} importer dark`,
+ })
+ }
+}
diff --git a/umap/static/umap/js/modules/importers/datasets.js b/umap/static/umap/js/modules/importers/datasets.js
new file mode 100644
index 00000000..eca162b3
--- /dev/null
+++ b/umap/static/umap/js/modules/importers/datasets.js
@@ -0,0 +1,41 @@
+import { DomUtil } from '../../../vendors/leaflet/leaflet-src.esm.js'
+import { translate } from '../i18n.js'
+
+export class Importer {
+ constructor(map, options) {
+ this.name = options.name || 'Datasets'
+ this.choices = options?.choices
+ this.id = 'datasets'
+ }
+
+ async open(importer) {
+ const container = DomUtil.create('div', 'formbox')
+ DomUtil.element({ tagName: 'h3', textContent: this.name, parent: container })
+ const select = DomUtil.create('select', '', container)
+ const noPreset = DomUtil.element({
+ tagName: 'option',
+ parent: select,
+ value: '',
+ textContent: translate('Choose a dataset'),
+ })
+ for (const dataset of this.choices) {
+ const option = DomUtil.create('option', '', select)
+ option.value = dataset.url
+ option.textContent = dataset.label
+ option.dataset.format = dataset.format || 'geojson'
+ }
+ const confirm = () => {
+ if (select.value) {
+ importer.url = select.value
+ importer.format = select.options[select.selectedIndex].dataset.format
+ }
+ importer.dialog.close()
+ }
+ L.DomUtil.createButton('', container, translate('Choose this dataset'), confirm)
+
+ importer.dialog.open({
+ content: container,
+ className: `${this.id} importer dark`,
+ })
+ }
+}
diff --git a/umap/static/umap/js/modules/importers/geodatamine.js b/umap/static/umap/js/modules/importers/geodatamine.js
new file mode 100644
index 00000000..5c73be00
--- /dev/null
+++ b/umap/static/umap/js/modules/importers/geodatamine.js
@@ -0,0 +1,95 @@
+import { DomUtil, DomEvent } from '../../../vendors/leaflet/leaflet-src.esm.js'
+import { BaseAjax, SingleMixin } from '../autocomplete.js'
+import { translate } from '../i18n.js'
+import * as Utils from '../utils.js'
+import { uMapAlert as Alert } from '../../components/alerts/alert.js'
+
+const BOUNDARY_TYPES = {
+ admin_6: 'département',
+ admin_7: 'pays (loi Voynet)',
+ admin_8: 'commune',
+ admin_9: 'quartier, hameau, arrondissement',
+ political: 'canton',
+ local_authority: 'EPCI',
+}
+
+const TEMPLATE = `
+ GeoDataMine
+ ${translate('GeoDataMine: thematic data from OpenStreetMap')}.
+
+ ${translate('Choose a theme')}
+
+
+
+ ${translate('Symplify all geometries to points')}
+
+
+
+ ${translate('Choose this data')}
+`
+
+class Autocomplete extends SingleMixin(BaseAjax) {
+ createResult(item) {
+ return super.createResult({
+ value: item.id,
+ label: `${item.name} (${BOUNDARY_TYPES[item.type]} — ${item.ref})`,
+ })
+ }
+}
+
+export class Importer {
+ constructor(map, options = {}) {
+ this.map = map
+ this.name = options.name || 'GeoDataMine'
+ this.baseUrl = options?.url || 'https://geodatamine.fr'
+ this.id = 'geodatamine'
+ }
+
+ async open(importer) {
+ let boundary = null
+ let boundaryName = null
+ const container = DomUtil.create('div')
+ container.innerHTML = TEMPLATE
+ const response = await importer.map.request.get(`${this.baseUrl}/themes`)
+ const select = container.querySelector('select')
+ if (response && response.ok) {
+ const { themes } = await response.json()
+ themes.sort((a, b) => Utils.naturalSort(a['name:fr'], b ['name:fr']))
+ for (const theme of themes) {
+ DomUtil.element({
+ tagName: 'option',
+ value: theme.id,
+ textContent: theme['name:fr'],
+ parent: select,
+ })
+ }
+ } else {
+ console.error(response)
+ }
+ const asPoint = container.querySelector('[name=aspoint]')
+ this.autocomplete = new Autocomplete(container.querySelector('#boundary'), {
+ placeholder: translate('Search admin boundary'),
+ url: `${this.baseUrl}/boundaries/search?text={q}`,
+ on_select: (choice) => {
+ boundary = choice.item.value
+ boundaryName = choice.item.label
+ },
+ })
+ const confirm = () => {
+ if (!boundary || !select.value) {
+ Alert.error(translate('Please choose a theme and a boundary first.'))
+ return
+ }
+ importer.url = `${this.baseUrl}/data/${select.value}/${boundary}?format=geojson&aspoint=${asPoint.checked}`
+ importer.format = 'geojson'
+ importer.layerName = `${boundaryName} — ${select.options[select.selectedIndex].textContent}`
+ importer.dialog.close()
+ }
+ DomEvent.on(container.querySelector('button'), 'click', confirm)
+
+ importer.dialog.open({
+ content: container,
+ className: `${this.id} importer dark`,
+ })
+ }
+}
diff --git a/umap/static/umap/js/modules/importers/overpass.js b/umap/static/umap/js/modules/importers/overpass.js
new file mode 100644
index 00000000..8d7d5ac8
--- /dev/null
+++ b/umap/static/umap/js/modules/importers/overpass.js
@@ -0,0 +1,84 @@
+import { DomUtil } from '../../../vendors/leaflet/leaflet-src.esm.js'
+import { BaseAjax, SingleMixin } from '../autocomplete.js'
+import { translate } from '../i18n.js'
+import { uMapAlert as Alert } from '../../components/alerts/alert.js'
+
+const TEMPLATE = `
+ Overpass
+
+ ${translate('Expression')}
+
+
+
+ ${translate('Geometry mode')}
+
+ ${translate('Default')}
+ ${translate('Only geometry centers')}
+
+
+ ${translate('Search area')}
+`
+
+class Autocomplete extends SingleMixin(BaseAjax) {
+ handleResults(data) {
+ return super.handleResults(data.features)
+ }
+
+ createResult(item) {
+ return super.createResult({
+ // Overpass convention to get their id from an osm one.
+ value: item.properties.osm_id + 3600000000,
+ label: `${item.properties.name}`,
+ })
+ }
+}
+
+export class Importer {
+ constructor(map, options) {
+ this.map = map
+ this.name = options.name || 'Overpass'
+ this.baseUrl = options?.url || 'https://overpass-api.de/api/interpreter'
+ this.id = 'overpass'
+ }
+
+ async open(importer) {
+ let boundary = null
+ let boundaryName = null
+ const container = DomUtil.create('div')
+ container.innerHTML = TEMPLATE
+ this.autocomplete = new Autocomplete(container.querySelector('#area'), {
+ url: 'https://photon.komoot.io/api?q={q}&osm_tag=place',
+ placeholder: translate(
+ 'Type area name, or let empty to load data in current map view'
+ ),
+ on_select: (choice) => {
+ boundary = choice.item.value
+ boundaryName = choice.item.label
+ },
+ })
+ this.map.help.parse(container)
+
+ const confirm = () => {
+ let tags = container.querySelector('[name=tags]').value
+ if (!tags) {
+ Alert.error(translate('Please define an expression for the query first'))
+ return
+ }
+ const outMode = container.querySelector('[name=out-mode]').value
+ if (!tags.startsWith('[')) tags = `[${tags}]`
+ let area = '{south},{west},{north},{east}'
+ if (boundary) area = `area:${boundary}`
+ let query = `[out:json];nwr${tags}(${area});out ${outMode};`
+ importer.url = `${this.baseUrl}?data=${query}`
+ if (boundary) importer.layerName = boundaryName
+ importer.format = 'osm'
+ importer.dialog.close()
+ }
+ L.DomUtil.createButton('', container, translate('Choose this data'), confirm)
+
+ importer.dialog.open({
+ content: container,
+ className: `${this.id} importer dark`,
+ })
+ }
+}
diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js
index b6b0061e..a818aec9 100644
--- a/umap/static/umap/js/modules/schema.js
+++ b/umap/static/umap/js/modules/schema.js
@@ -486,8 +486,19 @@ export const SCHEMA = {
label: translate('To zoom'),
helpText: translate('Optional.'),
},
+ ttl: {
+ type: Number,
+ label: translate('Cache proxied request'),
+ choices: [
+ ['', translate('No cache')],
+ ['300', translate('5 min')],
+ ['3600', translate('1 hour')],
+ ['86400', translate('1 day')],
+ ],
+ default: '300',
+ },
type: {
- type: 'String',
+ type: String,
impacts: ['data'],
},
weight: {
diff --git a/umap/static/umap/js/modules/ui/dialog.js b/umap/static/umap/js/modules/ui/dialog.js
index 24aa825a..ae72c2a5 100644
--- a/umap/static/umap/js/modules/ui/dialog.js
+++ b/umap/static/umap/js/modules/ui/dialog.js
@@ -19,12 +19,24 @@ export default class Dialog {
this.container.close()
}
+ currentZIndex() {
+ return Math.max(
+ ...Array.from(document.querySelectorAll('dialog')).map(
+ (el) => window.getComputedStyle(el).getPropertyValue('z-index') || 0
+ )
+ )
+ }
+
open({ className, content, modal } = {}) {
this.container.innerHTML = ''
+ const currentZIndex = this.currentZIndex()
+ if (currentZIndex) this.container.style.zIndex = currentZIndex + 1
if (modal) this.container.showModal()
else this.container.show()
if (className) {
- this.container.classList.add(className)
+ // Reset
+ this.container.className = 'umap-dialog'
+ this.container.classList.add(...className.split(' '))
}
const buttonsContainer = DomUtil.create('ul', 'buttons', this.container)
const closeButton = DomUtil.createButtonIcon(
diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js
index 72fb620e..ef27a685 100644
--- a/umap/static/umap/js/modules/utils.js
+++ b/umap/static/umap/js/modules/utils.js
@@ -90,6 +90,8 @@ export function escapeHTML(s) {
'source',
'br',
'span',
+ 'dt',
+ 'dd',
],
ADD_ATTR: [
'target',
diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js
index 9d23e886..c0aac7c7 100644
--- a/umap/static/umap/js/umap.core.js
+++ b/umap/static/umap/js/umap.core.js
@@ -139,7 +139,7 @@ L.DomUtil.createButtonIcon = (parent, className, title, size = 16) => {
L.DomUtil.createTitle = (parent, text, className, tag = 'h3') => {
const title = L.DomUtil.create(tag, '', parent)
- L.DomUtil.createIcon(title, className)
+ if (className) L.DomUtil.createIcon(title, className)
L.DomUtil.add('span', '', title, text)
return title
}
diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js
index 92c1cdfa..a822cd9d 100644
--- a/umap/static/umap/js/umap.forms.js
+++ b/umap/static/umap/js/umap.forms.js
@@ -340,15 +340,6 @@ L.FormBuilder.TextColorPicker = L.FormBuilder.ColorPicker.extend({
],
})
-L.FormBuilder.ProxyTTLSelect = L.FormBuilder.Select.extend({
- selectOptions: [
- [undefined, L._('No cache')],
- ['300', L._('5 min')],
- ['3600', L._('1 hour')],
- ['86400', L._('1 day')],
- ],
-})
-
L.FormBuilder.LayerTypeChooser = L.FormBuilder.Select.extend({
getOptions: function () {
const layer_classes = [
diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js
index 68e63f99..d37fb559 100644
--- a/umap/static/umap/js/umap.js
+++ b/umap/static/umap/js/umap.js
@@ -20,9 +20,6 @@ L.Map.mergeOptions({
enablePolygonDraw: true,
enablePolylineDraw: true,
limitBounds: {},
- importPresets: [
- // {url: 'http://localhost:8019/en/datalayer/1502/', label: 'Simplified World Countries', format: 'geojson'}
- ],
slideshow: {},
clickable: true,
permissions: {},
@@ -551,6 +548,8 @@ U.Map = L.Map.extend({
if (e.key === 'Escape') {
if (this.dialog.visible) {
this.dialog.close()
+ } else if (this.importer.dialog.visible) {
+ this.importer.dialog.close()
} else if (this.editEnabled && this.editTools.drawing()) {
this.editTools.stopDrawing()
} else if (this.measureTools.enabled()) {
@@ -803,16 +802,14 @@ U.Map = L.Map.extend({
return L.Map.prototype.setMaxBounds.call(this, bounds)
},
- createDataLayer: function (datalayer, sync) {
- datalayer = datalayer || {
- name: `${L._('Layer')} ${this.datalayers_index.length + 1}`,
- }
- const dl = new U.DataLayer(this, datalayer, sync)
+ createDataLayer: function (options = {}, sync) {
+ options.name = options.name || `${L._('Layer')} ${this.datalayers_index.length + 1}`
+ const datalayer = new U.DataLayer(this, options, sync)
if (sync !== false) {
- dl.sync.upsert(dl.options)
+ datalayer.sync.upsert(datalayer.options)
}
- return dl
+ return datalayer
},
newDataLayer: function () {
@@ -896,6 +893,13 @@ U.Map = L.Map.extend({
}
},
+ importFromUrl: async function (uri) {
+ const response = await this.request.get(uri)
+ if (response && response.ok) {
+ this.importRaw(await response.text())
+ }
+ },
+
importRaw: function (rawData) {
const importedData = JSON.parse(rawData)
diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js
index 177f278a..5090ee1c 100644
--- a/umap/static/umap/js/umap.layer.js
+++ b/umap/static/umap/js/umap.layer.js
@@ -1407,10 +1407,7 @@ U.DataLayer = L.Evented.extend({
helpEntries: 'proxyRemoteData',
},
])
- remoteDataFields.push([
- 'options.remoteData.ttl',
- { handler: 'ProxyTTLSelect', label: L._('Cache proxied request') },
- ])
+ remoteDataFields.push('options.remoteData.ttl')
}
const remoteDataContainer = L.DomUtil.createFieldset(container, L._('Remote data'))
diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css
index d36c2cf9..62ead726 100644
--- a/umap/static/umap/map.css
+++ b/umap/static/umap/map.css
@@ -987,9 +987,6 @@ a.umap-control-caption,
.datalayer-name {
cursor: pointer;
}
-.umap-caption h3 {
- margin-bottom: 0;
-}
.umap-caption .umap-map-owner {
padding-left: 31px;
}
diff --git a/umap/static/umap/vars.css b/umap/static/umap/vars.css
index b6d4f44e..e19f753b 100644
--- a/umap/static/umap/vars.css
+++ b/umap/static/umap/vars.css
@@ -34,6 +34,7 @@
--control-size: 36px;
--border-radius: 4px;
--box-padding: 20px;
+ --box-margin: 14px;
}
.dark {
--background-color: var(--color-darkGray);
@@ -43,5 +44,6 @@
@media only screen and (max-width:770px) {
:root {
--box-padding: 10px;
+ --box-margin: 7px;
}
}
diff --git a/umap/templates/umap/css.html b/umap/templates/umap/css.html
index 06ce487e..64a65b58 100644
--- a/umap/templates/umap/css.html
+++ b/umap/templates/umap/css.html
@@ -32,4 +32,5 @@
+
diff --git a/umap/tests/integration/test_import.py b/umap/tests/integration/test_import.py
index 39cdd9c5..5af891ec 100644
--- a/umap/tests/integration/test_import.py
+++ b/umap/tests/integration/test_import.py
@@ -17,20 +17,22 @@ def test_layers_list_is_updated(live_server, tilelayer, page):
modifier = "Cmd" if platform.system() == "Darwin" else "Ctrl"
page.get_by_role("link", name=f"Import data ({modifier}+I)").click()
# Should work
- page.get_by_label("Choose the layer to import").select_option(
- label="Import in a new layer"
- )
+ page.locator("[name=layer-id]").select_option(label="Import in a new layer")
page.get_by_role("link", name="Manage layers").click()
page.get_by_role("button", name="Add a layer").click()
page.locator('input[name="name"]').click()
page.locator('input[name="name"]').fill("foobar")
page.get_by_role("link", name=f"Import data ({modifier}+I)").click()
# Should still work
- page.get_by_label("Choose the layer to import").select_option(
- label="Import in a new layer"
- )
+ page.locator("[name=layer-id]").select_option(label="Import in a new layer")
# Now layer should be visible in the options
- page.get_by_label("Choose the layer to import").select_option(label="foobar")
+ page.locator("[name=layer-id]").select_option(label="foobar")
+ expect(
+ page.get_by_role("button", name="Import full map data", exact=True)
+ ).to_be_hidden()
+ expect(
+ page.get_by_role("button", name="Link to the layer as remote data", exact=True)
+ ).to_be_hidden()
def test_umap_import_from_file(live_server, tilelayer, page):
@@ -42,9 +44,13 @@ def test_umap_import_from_file(live_server, tilelayer, page):
file_chooser = fc_info.value
path = Path(__file__).parent.parent / "fixtures/display_on_load.umap"
file_chooser.set_files(path)
- button = page.get_by_role("button", name="Import", exact=True)
- expect(button).to_be_visible()
- button.click()
+ expect(
+ page.get_by_role("button", name="Copy into the layer", exact=True)
+ ).to_be_hidden()
+ expect(
+ page.get_by_role("button", name="Link to the layer as remote data", exact=True)
+ ).to_be_hidden()
+ page.get_by_role("button", name="Import data", exact=True).click()
assert file_input.input_value()
# Close the import panel
page.keyboard.press("Escape")
@@ -71,7 +77,7 @@ def test_umap_import_from_textarea(live_server, tilelayer, page, settings):
path = Path(__file__).parent.parent / "fixtures/test_upload_data.umap"
textarea.fill(path.read_text())
page.locator('select[name="format"]').select_option("umap")
- page.get_by_role("button", name="Import", exact=True).click()
+ page.get_by_role("button", name="Import data", exact=True).click()
layers = page.locator(".umap-browser .datalayer")
expect(layers).to_have_count(2)
expect(page.locator(".umap-main-edit-toolbox .map-name")).to_have_text(
@@ -106,9 +112,7 @@ def test_import_geojson_from_textarea(tilelayer, live_server, page):
path = Path(__file__).parent.parent / "fixtures/test_upload_data.json"
textarea.fill(path.read_text())
page.locator('select[name="format"]').select_option("geojson")
- button = page.get_by_role("button", name="Import", exact=True)
- expect(button).to_be_visible()
- button.click()
+ page.get_by_role("button", name="Import data", exact=True).click()
# A layer has been created
expect(layers).to_have_count(1)
expect(markers).to_have_count(2)
@@ -131,9 +135,7 @@ def test_import_kml_from_textarea(tilelayer, live_server, page):
path = Path(__file__).parent.parent / "fixtures/test_upload_data.kml"
textarea.fill(path.read_text())
page.locator('select[name="format"]').select_option("kml")
- button = page.get_by_role("button", name="Import", exact=True)
- expect(button).to_be_visible()
- button.click()
+ page.get_by_role("button", name="Import data", exact=True).click()
# A layer has been created
expect(layers).to_have_count(1)
expect(markers).to_have_count(1)
@@ -156,9 +158,7 @@ def test_import_gpx_from_textarea(tilelayer, live_server, page):
path = Path(__file__).parent.parent / "fixtures/test_upload_data.gpx"
textarea.fill(path.read_text())
page.locator('select[name="format"]').select_option("gpx")
- button = page.get_by_role("button", name="Import", exact=True)
- expect(button).to_be_visible()
- button.click()
+ page.get_by_role("button", name="Import data", exact=True).click()
# A layer has been created
expect(layers).to_have_count(1)
expect(markers).to_have_count(1)
@@ -179,7 +179,7 @@ def test_import_osm_from_textarea(tilelayer, live_server, page):
path = Path(__file__).parent.parent / "fixtures/test_upload_data_osm.json"
textarea.fill(path.read_text())
page.locator('select[name="format"]').select_option("osm")
- page.get_by_role("button", name="Import", exact=True).click()
+ page.get_by_role("button", name="Import data", exact=True).click()
# A layer has been created
expect(layers).to_have_count(1)
expect(markers).to_have_count(2)
@@ -199,7 +199,7 @@ def test_import_csv_from_textarea(tilelayer, live_server, page):
path = Path(__file__).parent.parent / "fixtures/test_upload_data.csv"
textarea.fill(path.read_text())
page.locator('select[name="format"]').select_option("csv")
- page.get_by_role("button", name="Import", exact=True).click()
+ page.get_by_role("button", name="Import data", exact=True).click()
# A layer has been created
expect(layers).to_have_count(1)
expect(markers).to_have_count(2)
@@ -218,7 +218,9 @@ def test_can_import_in_existing_datalayer(live_server, datalayer, page, openmap)
path = Path(__file__).parent.parent / "fixtures/test_upload_data.csv"
textarea.fill(path.read_text())
page.locator('select[name="format"]').select_option("csv")
- page.get_by_role("button", name="Import", exact=True).click()
+ page.locator('select[name="layer-id"]').select_option(datalayer.name)
+ expect(page.locator("input[name=layer-name]")).to_be_hidden()
+ page.get_by_role("button", name="Import data", exact=True).click()
# No layer has been created
expect(layers).to_have_count(1)
expect(markers).to_have_count(3)
@@ -237,8 +239,9 @@ def test_can_replace_datalayer_data(live_server, datalayer, page, openmap):
path = Path(__file__).parent.parent / "fixtures/test_upload_data.csv"
textarea.fill(path.read_text())
page.locator('select[name="format"]').select_option("csv")
+ page.locator('select[name="layer-id"]').select_option(datalayer.name)
page.get_by_label("Replace layer content").check()
- page.get_by_role("button", name="Import", exact=True).click()
+ page.get_by_role("button", name="Import data", exact=True).click()
# No layer has been created
expect(layers).to_have_count(1)
expect(markers).to_have_count(2)
@@ -256,14 +259,14 @@ def test_can_import_in_new_datalayer(live_server, datalayer, page, openmap):
textarea = page.locator(".umap-upload textarea")
path = Path(__file__).parent.parent / "fixtures/test_upload_data.csv"
textarea.fill(path.read_text())
- page.locator('select[name="format"]').select_option("csv")
- page.get_by_label("Choose the layer to import").select_option(
- label="Import in a new layer"
- )
- page.get_by_role("button", name="Import", exact=True).click()
+ page.locator("select[name=format]").select_option("csv")
+ page.locator("[name=layer-id]").select_option(label="Import in a new layer")
+ page.locator("[name=layer-name]").fill("My new layer name")
+ page.get_by_role("button", name="Import data", exact=True).click()
# A new layer has been created
expect(layers).to_have_count(2)
expect(markers).to_have_count(3)
+ expect(page.get_by_text("My new layer name")).to_be_visible()
def test_should_remove_dot_in_property_names(live_server, page, settings, tilelayer):
@@ -302,7 +305,7 @@ def test_should_remove_dot_in_property_names(live_server, page, settings, tilela
textarea = page.locator(".umap-upload textarea")
textarea.fill(json.dumps(data))
page.locator('select[name="format"]').select_option("geojson")
- page.get_by_role("button", name="Import", exact=True).click()
+ page.get_by_role("button", name="Import data", exact=True).click()
with page.expect_response(re.compile(r".*/datalayer/create/.*")):
page.get_by_role("button", name="Save").click()
datalayer = DataLayer.objects.last()
@@ -363,7 +366,7 @@ def test_import_geometry_collection(live_server, page, tilelayer):
textarea = page.locator(".umap-upload textarea")
textarea.fill(json.dumps(data))
page.locator('select[name="format"]').select_option("geojson")
- page.get_by_role("button", name="Import", exact=True).click()
+ page.get_by_role("button", name="Import data", exact=True).click()
# A layer has been created
expect(layers).to_have_count(1)
expect(markers).to_have_count(1)
@@ -397,7 +400,7 @@ def test_import_multipolygon(live_server, page, tilelayer):
textarea = page.locator(".umap-upload textarea")
textarea.fill(json.dumps(data))
page.locator('select[name="format"]').select_option("geojson")
- page.get_by_role("button", name="Import", exact=True).click()
+ page.get_by_role("button", name="Import data", exact=True).click()
# A layer has been created
expect(layers).to_have_count(1)
expect(paths).to_have_count(1)
@@ -429,7 +432,7 @@ def test_import_multipolyline(live_server, page, tilelayer):
textarea = page.locator(".umap-upload textarea")
textarea.fill(json.dumps(data))
page.locator('select[name="format"]').select_option("geojson")
- page.get_by_role("button", name="Import", exact=True).click()
+ page.get_by_role("button", name="Import data", exact=True).click()
# A layer has been created
expect(layers).to_have_count(1)
expect(paths).to_have_count(1)
@@ -444,8 +447,94 @@ def test_import_csv_without_valid_latlon_headers(tilelayer, live_server, page):
textarea = page.locator(".umap-upload textarea")
textarea.fill("a,b,c\n12.23,48.34,mypoint\n12.23,48.34,mypoint2")
page.locator('select[name="format"]').select_option("csv")
- page.get_by_role("button", name="Import", exact=True).click()
+ page.get_by_role("button", name="Import data", exact=True).click()
# FIXME do not create a layer
expect(layers).to_have_count(1)
expect(markers).to_have_count(0)
expect(page.locator('umap-alert div[data-level="error"]')).to_be_visible()
+
+
+def test_create_remote_data(page, live_server, tilelayer):
+ def handle(route):
+ route.fulfill(
+ json={
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {},
+ "geometry": {
+ "type": "Point",
+ "coordinates": [4.3375, 51.2707],
+ },
+ }
+ ],
+ }
+ )
+
+ # Intercept the route to the proxy
+ page.route("*/**/ajax-proxy/**", handle)
+ page.goto(f"{live_server.url}/map/new/")
+ expect(page.locator(".leaflet-marker-icon")).to_be_hidden()
+ page.get_by_role("link", name="Import data (Ctrl+I)").click()
+ page.get_by_placeholder("Provide an URL here").click()
+ page.get_by_placeholder("Provide an URL here").fill("https://remote.org/data.json")
+ page.locator("[name=format]").select_option("geojson")
+ page.get_by_role("radio", name="Link to the layer as remote data").click()
+ page.get_by_role("button", name="Import data", exact=True).click()
+ expect(page.locator(".leaflet-marker-icon")).to_be_visible()
+ page.get_by_role("link", name="Manage layers").click()
+ page.get_by_role("button", name="Edit", exact=True).click()
+ page.locator("summary").filter(has_text="Remote data").click()
+ expect(page.locator('.panel input[name="url"]')).to_have_value(
+ "https://remote.org/data.json"
+ )
+
+
+def test_import_geojson_from_url(page, live_server, tilelayer):
+ def handle(route):
+ route.fulfill(
+ json={
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {},
+ "geometry": {
+ "type": "Point",
+ "coordinates": [4.3375, 51.2707],
+ },
+ }
+ ],
+ }
+ )
+
+ # Intercept the route
+ page.route("https://remote.org/data.json", handle)
+ page.goto(f"{live_server.url}/map/new/")
+ expect(page.locator(".leaflet-marker-icon")).to_be_hidden()
+ page.get_by_role("link", name="Import data (Ctrl+I)").click()
+ page.get_by_placeholder("Provide an URL here").click()
+ page.get_by_placeholder("Provide an URL here").fill("https://remote.org/data.json")
+ page.locator("[name=format]").select_option("geojson")
+ page.get_by_role("radio", name="Copy into the layer").click()
+ page.get_by_role("button", name="Import data", exact=True).click()
+ expect(page.locator(".leaflet-marker-icon")).to_be_visible()
+ page.get_by_role("link", name="Manage layers").click()
+ page.get_by_role("button", name="Edit", exact=True).click()
+ page.locator("summary").filter(has_text="Remote data").click()
+ expect(page.locator('.panel input[name="url"]')).to_have_value("")
+
+
+def test_overpass_import_with_bbox(page, live_server, tilelayer, settings):
+ settings.UMAP_IMPORTERS = {
+ "overpass": {"url": "https://my.overpass.io/interpreter"}
+ }
+ page.goto(f"{live_server.url}/map/new/")
+ page.get_by_role("link", name="Import data (Ctrl+I)").click()
+ page.get_by_role("button", name="Overpass").click()
+ page.get_by_placeholder("amenity=drinking_water").fill("building")
+ page.get_by_role("button", name="Choose this data").click()
+ expect(page.get_by_placeholder("Provide an URL here")).to_have_value(
+ "https://my.overpass.io/interpreter?data=[out:json];nwr[building]({south},{west},{north},{east});out geom;"
+ )
diff --git a/umap/views.py b/umap/views.py
index e53b382c..349e66d5 100644
--- a/umap/views.py
+++ b/umap/views.py
@@ -505,6 +505,7 @@ class MapDetailMixin:
"featuresHaveOwner": settings.UMAP_DEFAULT_FEATURES_HAVE_OWNERS,
"websocketEnabled": settings.WEBSOCKET_ENABLED,
"websocketURI": settings.WEBSOCKET_FRONT_URI,
+ "importers": settings.UMAP_IMPORTERS,
}
created = bool(getattr(self, "object", None))
if (created and self.object.owner) or (not created and not user.is_anonymous):