@@ -210,5 +210,9 @@
+
+
+
+
diff --git a/umap/static/umap/js/modules/browser.js b/umap/static/umap/js/modules/browser.js
index a5ef3c36..6afdf92d 100644
--- a/umap/static/umap/js/modules/browser.js
+++ b/umap/static/umap/js/modules/browser.js
@@ -107,6 +107,7 @@ export default class Browser {
this.map.eachBrowsableDataLayer((datalayer) => {
datalayer.resetLayer(true)
this.updateDatalayer(datalayer)
+ if (this.map.fullPanel?.isOpen()) datalayer.tableEdit()
})
this.toggleBadge()
}
@@ -149,7 +150,7 @@ export default class Browser {
DomEvent.disableClickPropagation(container)
DomUtil.createTitle(container, translate('Data browser'), 'icon-layers')
- const formContainer = DomUtil.createFieldset(container, L._('Filters'), {
+ this.formContainer = DomUtil.createFieldset(container, L._('Filters'), {
on: this.mode === 'filters',
className: 'filters',
icon: 'icon-filters',
@@ -169,7 +170,7 @@ export default class Browser {
callback: () => this.onFormChange(),
})
let filtersBuilder
- formContainer.appendChild(builder.build())
+ this.formContainer.appendChild(builder.build())
DomEvent.on(builder.form, 'reset', () => {
window.setTimeout(builder.syncAll.bind(builder))
})
@@ -181,12 +182,11 @@ export default class Browser {
DomEvent.on(filtersBuilder.form, 'reset', () => {
window.setTimeout(filtersBuilder.syncAll.bind(filtersBuilder))
})
- formContainer.appendChild(filtersBuilder.build())
+ this.formContainer.appendChild(filtersBuilder.build())
}
- const reset = DomUtil.createButton('flat', formContainer, '', () => {
- builder.form.reset()
- if (filtersBuilder) filtersBuilder.form.reset()
- })
+ const reset = DomUtil.createButton('flat', this.formContainer, '', () =>
+ this.resetFilters()
+ )
DomUtil.createIcon(reset, 'icon-restore')
DomUtil.element({
tagName: 'span',
@@ -202,6 +202,12 @@ export default class Browser {
this.update()
}
+ resetFilters() {
+ for (const form of this.formContainer?.querySelectorAll('form') || []) {
+ form.reset()
+ }
+ }
+
static backButton(map) {
const button = DomUtil.createButtonIcon(
DomUtil.create('li', '', undefined),
diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js
index 6677b5f6..d595bab7 100644
--- a/umap/static/umap/js/modules/facets.js
+++ b/umap/static/umap/js/modules/facets.js
@@ -12,8 +12,8 @@ export default class Facets {
const properties = {}
let selected
- names.forEach((name) => {
- const type = defined[name].type
+ for (const name of names) {
+ const type = defined.get(name).type
properties[name] = { type: type }
selected = this.selected[name] || {}
selected.type = type
@@ -22,13 +22,13 @@ export default class Facets {
selected.choices = selected.choices || []
}
this.selected[name] = selected
- })
+ }
this.map.eachBrowsableDataLayer((datalayer) => {
datalayer.eachFeature((feature) => {
- names.forEach((name) => {
+ for (const name of names) {
let value = feature.properties[name]
- const type = defined[name].type
+ const type = defined.get(name).type
const parser = this.getParser(type)
value = parser(value)
switch (type) {
@@ -56,7 +56,7 @@ export default class Facets {
properties[name].choices.push(value)
}
}
- })
+ }
})
})
return properties
@@ -73,7 +73,7 @@ export default class Facets {
build() {
const defined = this.getDefined()
- const names = Object.keys(defined)
+ const names = [...defined.keys()]
const facetProperties = this.compute(names, defined)
const fields = names.map((name) => {
@@ -90,7 +90,7 @@ export default class Facets {
handler = 'FacetSearchDateTime'
break
}
- const label = defined[name].label
+ const label = defined.get(name).label
return [
`selected.${name}`,
{
@@ -107,12 +107,14 @@ export default class Facets {
getDefined() {
const defaultType = 'checkbox'
const allowedTypes = [defaultType, 'radio', 'number', 'date', 'datetime']
+ const defined = new Map()
+ if (!this.map.options.facetKey) return defined
return (this.map.options.facetKey || '').split(',').reduce((acc, curr) => {
let [name, label, type] = curr.split('|')
type = allowedTypes.includes(type) ? type : defaultType
- acc[name] = { label: label || name, type: type }
+ acc.set(name, { label: label || name, type: type })
return acc
- }, {})
+ }, defined)
}
getParser(type) {
@@ -127,4 +129,32 @@ export default class Facets {
return (v) => String(v || '')
}
}
+
+ dumps(parsed) {
+ const dumped = []
+ for (const [property, { label, type }] of parsed) {
+ dumped.push([property, label, type].filter(Boolean).join('|'))
+ }
+ return dumped.join(',')
+ }
+
+ has(property) {
+ return this.getDefined().has(property)
+ }
+
+ add(property, label, type) {
+ const defined = this.getDefined()
+ if (!defined.has(property)) {
+ defined.set(property, { label, type })
+ this.map.options.facetKey = this.dumps(defined)
+ this.map.isDirty = true
+ }
+ }
+
+ remove(property) {
+ const defined = this.getDefined()
+ defined.delete(property)
+ this.map.options.facetKey = this.dumps(defined)
+ this.map.isDirty = true
+ }
}
diff --git a/umap/static/umap/js/modules/global.js b/umap/static/umap/js/modules/global.js
index af96433e..492a625d 100644
--- a/umap/static/umap/js/modules/global.js
+++ b/umap/static/umap/js/modules/global.js
@@ -19,6 +19,7 @@ import Slideshow from './slideshow.js'
import { SyncEngine } from './sync/engine.js'
import Dialog from './ui/dialog.js'
import { EditPanel, FullPanel, Panel } from './ui/panel.js'
+import TableEditor from './tableeditor.js'
import Tooltip from './ui/tooltip.js'
import URLs from './urls.js'
import * as Utils from './utils.js'
@@ -55,6 +56,7 @@ window.U = {
Share,
Slideshow,
SyncEngine,
+ TableEditor,
Tooltip,
URLs,
Utils,
diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js
new file mode 100644
index 00000000..c4019dd2
--- /dev/null
+++ b/umap/static/umap/js/modules/tableeditor.js
@@ -0,0 +1,329 @@
+import { DomEvent, DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
+import { translate } from './i18n.js'
+import ContextMenu from './ui/contextmenu.js'
+import { WithTemplate, loadTemplate } from './utils.js'
+
+const TEMPLATE = `
+
+`
+
+export default class TableEditor extends WithTemplate {
+ constructor(datalayer) {
+ super()
+ this.datalayer = datalayer
+ this.map = this.datalayer.map
+ this.contextmenu = new ContextMenu({ className: 'dark' })
+ this.table = this.loadTemplate(TEMPLATE)
+ if (!this.datalayer.isRemoteLayer()) {
+ this.elements.body.addEventListener('dblclick', (event) => {
+ if (event.target.closest('[data-property]')) this.editCell(event.target)
+ })
+ }
+ this.elements.body.addEventListener('click', (event) => this.setFocus(event.target))
+ this.elements.body.addEventListener('keydown', (event) => this.onKeyDown(event))
+ this.elements.header.addEventListener('click', (event) => {
+ const property = event.target.dataset.property
+ if (property) this.openHeaderMenu(property)
+ })
+ }
+
+ openHeaderMenu(property) {
+ const actions = []
+ let filterItem
+ if (this.map.facets.has(property)) {
+ filterItem = {
+ label: translate('Remove filter for this column'),
+ action: () => {
+ this.map.facets.remove(property)
+ this.map.browser.open('filters')
+ },
+ }
+ } else {
+ filterItem = {
+ label: translate('Add filter for this column'),
+ action: () => {
+ this.map.facets.add(property)
+ this.map.browser.open('filters')
+ },
+ }
+ }
+ actions.push(filterItem)
+ if (!this.datalayer.isRemoteLayer()) {
+ actions.push({
+ label: translate('Rename this column'),
+ action: () => this.renameProperty(property),
+ })
+ actions.push({
+ label: translate('Delete this column'),
+ action: () => this.deleteProperty(property),
+ })
+ }
+ this.contextmenu.open([event.clientX, event.clientY], actions)
+ }
+
+ renderHeaders() {
+ this.elements.header.innerHTML = ''
+ const th = loadTemplate(' | ')
+ const checkbox = th.firstChild
+ this.elements.header.appendChild(th)
+ for (const property of this.properties) {
+ this.elements.header.appendChild(
+ loadTemplate(
+ `${property} | `
+ )
+ )
+ }
+ checkbox.addEventListener('change', (event) => {
+ if (checkbox.checked) this.checkAll()
+ else this.checkAll(false)
+ })
+ }
+
+ renderBody() {
+ const bounds = this.map.getBounds()
+ const inBbox = this.map.browser.options.inBbox
+ let html = ''
+ for (const feature of Object.values(this.datalayer._layers)) {
+ if (feature.isFiltered()) continue
+ if (inBbox && !feature.isOnScreen(bounds)) continue
+ const tds = this.properties.map(
+ (prop) =>
+ `${feature.properties[prop] || ''} | `
+ )
+ html += ` | ${tds.join('')}
`
+ }
+ this.elements.body.innerHTML = html
+ }
+
+ resetProperties() {
+ this.properties = this.datalayer._propertiesIndex
+ if (this.properties.length === 0) {
+ this.properties = ['name', 'description']
+ }
+ }
+
+ validateName(name) {
+ if (name.includes('.')) {
+ U.Alert.error(translate('Name “{name}” should not contain a dot.', { name }))
+ return false
+ }
+ if (this.properties.includes(name)) {
+ U.Alert.error(translate('This name already exists: “{name}”', { name }))
+ return false
+ }
+ return true
+ }
+
+ renameProperty(property) {
+ this.map.dialog
+ .prompt(translate('Please enter the new name of this property'))
+ .then(({ prompt }) => {
+ if (!prompt || !this.validateName(prompt)) return
+ this.datalayer.eachLayer((feature) => {
+ feature.renameProperty(property, prompt)
+ })
+ this.datalayer.deindexProperty(property)
+ this.datalayer.indexProperty(prompt)
+ this.open()
+ })
+ }
+
+ deleteProperty(property) {
+ this.map.dialog
+ .confirm(
+ translate('Are you sure you want to delete this property on all the features?')
+ )
+ .then(() => {
+ this.datalayer.eachLayer((feature) => {
+ feature.deleteProperty(property)
+ })
+ this.datalayer.deindexProperty(property)
+ this.resetProperties()
+ this.open()
+ })
+ }
+
+ addProperty() {
+ this.map.dialog
+ .prompt(translate('Please enter the name of the property'))
+ .then(({ prompt }) => {
+ if (!prompt || !this.validateName(prompt)) return
+ this.datalayer.indexProperty(prompt)
+ this.open()
+ })
+ }
+
+ open() {
+ const id = 'tableeditor:edit'
+ this.resetProperties()
+ this.renderHeaders()
+ this.elements.body.innerHTML = ''
+ this.renderBody()
+
+ const actions = []
+ if (!this.datalayer.isRemoteLayer()) {
+ const addButton = loadTemplate(`
+ `)
+ addButton.addEventListener('click', () => this.addProperty())
+ actions.push(addButton)
+
+ const deleteButton = loadTemplate(`
+ `)
+ deleteButton.addEventListener('click', () => this.deleteRows())
+ actions.push(deleteButton)
+ }
+
+ const filterButton = loadTemplate(`
+ `)
+ filterButton.addEventListener('click', () => this.map.browser.open('filters'))
+ actions.push(filterButton)
+
+ this.map.fullPanel.open({
+ content: this.table,
+ className: 'umap-table-editor',
+ actions: actions,
+ })
+ }
+
+ editCell(cell) {
+ if (this.datalayer.isRemoteLayer()) return
+ const property = cell.dataset.property
+ const field = `properties.${property}`
+ const tr = event.target.closest('tr')
+ const feature = this.datalayer.getFeatureById(tr.dataset.feature)
+ const handler = property === 'description' ? 'Textarea' : 'Input'
+ const builder = new U.FormBuilder(feature, [[field, { handler }]], {
+ id: `umap-feature-properties_${L.stamp(feature)}`,
+ })
+ cell.innerHTML = ''
+ cell.appendChild(builder.build())
+ const input = builder.helpers[field].input
+ input.focus()
+ input.addEventListener('blur', () => {
+ cell.innerHTML = feature.properties[property] || ''
+ cell.focus()
+ })
+ input.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ builder.restoreField(field)
+ cell.innerHTML = feature.properties[property] || ''
+ cell.focus()
+ event.stopPropagation()
+ }
+ })
+ }
+
+ onKeyDown(event) {
+ // Only on data , not inputs or anything else
+ if (!event.target.dataset.property) return
+ const key = event.key
+ const actions = {
+ Enter: () => this.editCurrent(),
+ ArrowRight: () => this.moveRight(),
+ ArrowLeft: () => this.moveLeft(),
+ ArrowUp: () => this.moveUp(),
+ ArrowDown: () => this.moveDown(),
+ }
+ if (key in actions) {
+ actions[key]()
+ event.preventDefault()
+ }
+ }
+
+ editCurrent() {
+ const current = this.getFocus()
+ if (current) {
+ this.editCell(current)
+ }
+ }
+
+ moveRight() {
+ const cell = this.getFocus()
+ if (cell.nextSibling) cell.nextSibling.focus()
+ }
+
+ moveLeft() {
+ const cell = this.getFocus()
+ if (cell.previousSibling) cell.previousSibling.focus()
+ }
+
+ moveDown() {
+ const cell = this.getFocus()
+ const tr = cell.closest('tr')
+ const property = cell.dataset.property
+ const nextTr = tr.nextSibling
+ if (nextTr) {
+ nextTr.querySelector(`td[data-property="${property}"`).focus()
+ }
+ }
+
+ moveUp() {
+ const cell = this.getFocus()
+ const tr = cell.closest('tr')
+ const property = cell.dataset.property
+ const previousTr = tr.previousSibling
+ if (previousTr) {
+ previousTr.querySelector(`td[data-property="${property}"`).focus()
+ }
+ }
+
+ checkAll(status = true) {
+ for (const checkbox of this.elements.body.querySelectorAll(
+ 'input[type=checkbox]'
+ )) {
+ checkbox.checked = status
+ }
+ }
+
+ getSelectedRows() {
+ return Array.from(
+ this.elements.body.querySelectorAll('input[type=checkbox]:checked')
+ ).map((checkbox) => checkbox.parentNode.parentNode)
+ }
+
+ getFocus() {
+ return this.elements.body.querySelector(':focus')
+ }
+
+ setFocus(cell) {
+ cell.focus({ focusVisible: true })
+ }
+
+ deleteRows() {
+ const selectedRows = this.getSelectedRows()
+ if (!selectedRows.length) return
+ this.map.dialog
+ .confirm(
+ translate('Found {count} rows. Are you sure you want to delete all?', {
+ count: selectedRows.length,
+ })
+ )
+ .then(() => {
+ this.datalayer.hide()
+ for (const row of selectedRows) {
+ const id = row.dataset.feature
+ const feature = this.datalayer.getFeatureById(id)
+ feature.del()
+ }
+ this.datalayer.show()
+ this.datalayer.fire('datachanged')
+ this.renderBody()
+ if (this.map.browser.isOpen()) {
+ this.map.browser.resetFilters()
+ this.map.browser.open('filters')
+ }
+ })
+ }
+}
diff --git a/umap/static/umap/js/modules/ui/base.js b/umap/static/umap/js/modules/ui/base.js
new file mode 100644
index 00000000..50b55487
--- /dev/null
+++ b/umap/static/umap/js/modules/ui/base.js
@@ -0,0 +1,93 @@
+export class Positioned {
+ openAt({ anchor, position }) {
+ if (anchor && position === 'top') {
+ this.anchorTop(anchor)
+ } else if (anchor && position === 'left') {
+ this.anchorLeft(anchor)
+ } else if (anchor && position === 'bottom') {
+ this.anchorBottom(anchor)
+ } else {
+ this.anchorAbsolute()
+ }
+ }
+
+ anchorAbsolute() {
+ this.container.className = ''
+ const left =
+ this.parent.offsetLeft +
+ this.parent.clientWidth / 2 -
+ this.container.clientWidth / 2
+ const top = this.parent.offsetTop + 75
+ this.setPosition({ top: top, left: left })
+ }
+
+ anchorTop(el) {
+ this.container.className = 'tooltip-top'
+ const coords = this.getPosition(el)
+ this.setPosition({
+ left: coords.left - 10,
+ bottom: this.getDocHeight() - coords.top + 11,
+ })
+ }
+
+ anchorBottom(el) {
+ this.container.className = 'tooltip-bottom'
+ const coords = this.getPosition(el)
+ this.setPosition({
+ left: coords.left,
+ top: coords.bottom + 11,
+ })
+ }
+
+ anchorLeft(el) {
+ this.container.className = 'tooltip-left'
+ const coords = this.getPosition(el)
+ this.setPosition({
+ top: coords.top,
+ right: document.documentElement.offsetWidth - coords.left + 11,
+ })
+ }
+
+ getPosition(el) {
+ return el.getBoundingClientRect()
+ }
+
+ setPosition(coords) {
+ if (coords.left) this.container.style.left = `${coords.left}px`
+ else this.container.style.left = 'initial'
+ if (coords.right) this.container.style.right = `${coords.right}px`
+ else this.container.style.right = 'initial'
+ if (coords.top) this.container.style.top = `${coords.top}px`
+ else this.container.style.top = 'initial'
+ if (coords.bottom) this.container.style.bottom = `${coords.bottom}px`
+ else this.container.style.bottom = 'initial'
+ }
+
+ computePosition([x, y]) {
+ let left
+ let top
+ if (x < window.innerWidth / 2) {
+ left = x
+ } else {
+ left = x - this.container.offsetWidth
+ }
+ if (y < window.innerHeight / 2) {
+ top = y
+ } else {
+ top = y - this.container.offsetHeight
+ }
+ this.setPosition({ left, top })
+ }
+
+ getDocHeight() {
+ const D = document
+ return Math.max(
+ D.body.scrollHeight,
+ D.documentElement.scrollHeight,
+ D.body.offsetHeight,
+ D.documentElement.offsetHeight,
+ D.body.clientHeight,
+ D.documentElement.clientHeight
+ )
+ }
+}
diff --git a/umap/static/umap/js/modules/ui/contextmenu.js b/umap/static/umap/js/modules/ui/contextmenu.js
new file mode 100644
index 00000000..1ff33a5d
--- /dev/null
+++ b/umap/static/umap/js/modules/ui/contextmenu.js
@@ -0,0 +1,50 @@
+import { loadTemplate } from '../utils.js'
+import { Positioned } from './base.js'
+
+export default class ContextMenu extends Positioned {
+ constructor(options = {}) {
+ super()
+ this.options = options
+ this.container = document.createElement('ul')
+ this.container.className = 'umap-contextmenu'
+ if (options.className) {
+ this.container.classList.add(options.className)
+ }
+ this.container.addEventListener('focusout', (event) => {
+ if (!this.container.contains(event.relatedTarget)) this.close()
+ })
+ }
+
+ open([x, y], items) {
+ this.container.innerHTML = ''
+ for (const item of items) {
+ const li = loadTemplate(
+ ``
+ )
+ li.addEventListener('click', () => {
+ this.close()
+ item.action()
+ })
+ this.container.appendChild(li)
+ }
+ document.body.appendChild(this.container)
+ this.computePosition([x, y])
+ this.container.querySelector('button').focus()
+ this.container.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ event.stopPropagation()
+ this.close()
+ }
+ })
+ }
+
+ close() {
+ try {
+ this.container.remove()
+ } catch {
+ // Race condition in Chrome: the focusout close has "half" removed the node
+ // So it's still visible in the DOM, but we calling .remove on it (or parentNode.removeChild)
+ // will crash.
+ }
+ }
+}
diff --git a/umap/static/umap/js/modules/ui/panel.js b/umap/static/umap/js/modules/ui/panel.js
index f84a92b3..6fa77e60 100644
--- a/umap/static/umap/js/modules/ui/panel.js
+++ b/umap/static/umap/js/modules/ui/panel.js
@@ -9,7 +9,7 @@ export class Panel {
// This will be set once according to the panel configurated at load
// or by using panels as popups
this.mode = null
- this.classname = 'left'
+ this.className = 'left'
DomEvent.disableClickPropagation(this.container)
DomEvent.on(this.container, 'contextmenu', DomEvent.stopPropagation) // Do not activate our custom context menu.
DomEvent.on(this.container, 'wheel', DomEvent.stopPropagation)
@@ -25,9 +25,10 @@ export class Panel {
}
open({ content, className, actions = [] } = {}) {
- this.container.className = `with-transition panel window ${this.classname} ${
+ this.container.className = `with-transition panel window ${this.className} ${
this.mode || ''
}`
+ document.body.classList.add(`panel-${this.className.split(' ')[0]}-on`)
this.container.innerHTML = ''
const actionsContainer = DomUtil.create('ul', 'buttons', this.container)
const body = DomUtil.create('div', 'body', this.container)
@@ -69,6 +70,7 @@ export class Panel {
}
close() {
+ document.body.classList.remove(`panel-${this.className.split(' ')[0]}-on`)
if (DomUtil.hasClass(this.container, 'on')) {
DomUtil.removeClass(this.container, 'on')
this.map.invalidateSize({ pan: false })
@@ -80,14 +82,14 @@ export class Panel {
export class EditPanel extends Panel {
constructor(map) {
super(map)
- this.classname = 'right dark'
+ this.className = 'right dark'
}
}
export class FullPanel extends Panel {
constructor(map) {
super(map)
- this.classname = 'full dark'
+ this.className = 'full dark'
this.mode = 'expanded'
}
}
diff --git a/umap/static/umap/js/modules/ui/tooltip.js b/umap/static/umap/js/modules/ui/tooltip.js
index feed1b5e..88a859f9 100644
--- a/umap/static/umap/js/modules/ui/tooltip.js
+++ b/umap/static/umap/js/modules/ui/tooltip.js
@@ -1,8 +1,10 @@
import { DomEvent, DomUtil } from '../../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from '../i18n.js'
+import { Positioned } from './base.js'
-export default class Tooltip {
+export default class Tooltip extends Positioned {
constructor(parent) {
+ super()
this.parent = parent
this.container = DomUtil.create('div', 'with-transition', this.parent)
this.container.id = 'umap-tooltip-container'
@@ -13,16 +15,8 @@ export default class Tooltip {
}
open(opts) {
- function showIt() {
- if (opts.anchor && opts.position === 'top') {
- this.anchorTop(opts.anchor)
- } else if (opts.anchor && opts.position === 'left') {
- this.anchorLeft(opts.anchor)
- } else if (opts.anchor && opts.position === 'bottom') {
- this.anchorBottom(opts.anchor)
- } else {
- this.anchorAbsolute()
- }
+ const showIt = () => {
+ this.openAt(opts)
L.DomUtil.addClass(this.parent, 'umap-tooltip')
this.container.innerHTML = U.Utils.escapeHTML(opts.content)
}
@@ -39,43 +33,6 @@ export default class Tooltip {
}
}
- anchorAbsolute() {
- this.container.className = ''
- const left =
- this.parent.offsetLeft +
- this.parent.clientWidth / 2 -
- this.container.clientWidth / 2
- const top = this.parent.offsetTop + 75
- this.setPosition({ top: top, left: left })
- }
-
- anchorTop(el) {
- this.container.className = 'tooltip-top'
- const coords = this.getPosition(el)
- this.setPosition({
- left: coords.left - 10,
- bottom: this.getDocHeight() - coords.top + 11,
- })
- }
-
- anchorBottom(el) {
- this.container.className = 'tooltip-bottom'
- const coords = this.getPosition(el)
- this.setPosition({
- left: coords.left,
- top: coords.bottom + 11,
- })
- }
-
- anchorLeft(el) {
- this.container.className = 'tooltip-left'
- const coords = this.getPosition(el)
- this.setPosition({
- top: coords.top,
- right: document.documentElement.offsetWidth - coords.left + 11,
- })
- }
-
close(id) {
// Clear timetout even if a new tooltip has been added
// in the meantime. Eg. after a mouseout from the anchor.
@@ -86,31 +43,4 @@ export default class Tooltip {
this.setPosition({})
L.DomUtil.removeClass(this.parent, 'umap-tooltip')
}
-
- getPosition(el) {
- return el.getBoundingClientRect()
- }
-
- setPosition(coords) {
- if (coords.left) this.container.style.left = `${coords.left}px`
- else this.container.style.left = 'initial'
- if (coords.right) this.container.style.right = `${coords.right}px`
- else this.container.style.right = 'initial'
- if (coords.top) this.container.style.top = `${coords.top}px`
- else this.container.style.top = 'initial'
- if (coords.bottom) this.container.style.bottom = `${coords.bottom}px`
- else this.container.style.bottom = 'initial'
- }
-
- getDocHeight() {
- const D = document
- return Math.max(
- D.body.scrollHeight,
- D.documentElement.scrollHeight,
- D.body.offsetHeight,
- D.documentElement.offsetHeight,
- D.body.clientHeight,
- D.documentElement.clientHeight
- )
- }
}
diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js
index c1427fd0..9b7b11f1 100644
--- a/umap/static/umap/js/modules/utils.js
+++ b/umap/static/umap/js/modules/utils.js
@@ -378,11 +378,18 @@ export function toggleBadge(element, value) {
else delete element.dataset.badge
}
+export function loadTemplate(html) {
+ const template = document.createElement('template')
+ template.innerHTML = html
+ .split('\n')
+ .map((line) => line.trim())
+ .join('')
+ return template.content.firstElementChild
+}
+
export class WithTemplate {
loadTemplate(html) {
- const template = document.createElement('template')
- template.innerHTML = html.split('\n').map((line) => line.trim()).join('')
- this.element = template.content.firstElementChild
+ this.element = loadTemplate(html)
this.elements = {}
for (const element of this.element.querySelectorAll('[data-ref]')) {
this.elements[element.dataset.ref] = element
diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js
index b1a08d9c..a7705ae0 100644
--- a/umap/static/umap/js/umap.forms.js
+++ b/umap/static/umap/js/umap.forms.js
@@ -1159,7 +1159,7 @@ U.FormBuilder = L.FormBuilder.extend({
}
},
- finish: function () {
- this.map.editPanel.close()
+ finish: (event) => {
+ event.helper?.input?.blur()
},
})
diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js
index 0eee5b9e..ca3da686 100644
--- a/umap/static/umap/js/umap.layer.js
+++ b/umap/static/umap/js/umap.layer.js
@@ -412,7 +412,9 @@ U.Layer.Categorized = U.RelativeColorLayer.extend({
if (colorbrewer[colorScheme]?.[this._classes]) {
this.options.colors = colorbrewer[colorScheme][this._classes]
} else {
- this.options.colors = colorbrewer?.Accent[this._classes] ? colorbrewer?.Accent[this._classes] : U.COLORS
+ this.options.colors = colorbrewer?.Accent[this._classes]
+ ? colorbrewer?.Accent[this._classes]
+ : U.COLORS
}
},
@@ -1032,7 +1034,7 @@ U.DataLayer = L.Evented.extend({
this._index.splice(this._index.indexOf(id), 1)
delete this._layers[id]
delete this.map.features_index[feature.getSlug()]
- if (this.hasDataLoaded()) this.fire('datachanged')
+ if (this.hasDataLoaded() && this.isVisible()) this.fire('datachanged')
},
indexProperties: function (feature) {
@@ -1045,6 +1047,7 @@ U.DataLayer = L.Evented.extend({
if (name.indexOf('_') === 0) return
if (L.Util.indexOf(this._propertiesIndex, name) !== -1) return
this._propertiesIndex.push(name)
+ this._propertiesIndex.sort()
},
deindexProperty: function (name) {
@@ -1800,9 +1803,9 @@ U.DataLayer = L.Evented.extend({
},
tableEdit: function () {
- if (this.isRemoteLayer() || !this.isVisible()) return
+ if (!this.isVisible()) return
const editor = new U.TableEditor(this)
- editor.edit()
+ editor.open()
},
getFilterKeys: function () {
diff --git a/umap/static/umap/js/umap.tableeditor.js b/umap/static/umap/js/umap.tableeditor.js
deleted file mode 100644
index 20e1dbcc..00000000
--- a/umap/static/umap/js/umap.tableeditor.js
+++ /dev/null
@@ -1,124 +0,0 @@
-U.TableEditor = L.Class.extend({
- initialize: function (datalayer) {
- this.datalayer = datalayer
- this.table = L.DomUtil.create('div', 'table')
- this.header = L.DomUtil.create('div', 'thead', this.table)
- this.body = L.DomUtil.create('div', 'tbody', this.table)
- this.resetProperties()
- },
-
- renderHeaders: function () {
- this.header.innerHTML = ''
- for (let i = 0; i < this.properties.length; i++) {
- this.renderHeader(this.properties[i])
- }
- },
-
- renderHeader: function (property) {
- const container = L.DomUtil.create('div', 'tcell', this.header)
- const title = L.DomUtil.add('span', '', container, property)
- const del = L.DomUtil.create('i', 'umap-delete', container)
- const rename = L.DomUtil.create('i', 'umap-edit', container)
- del.title = L._('Delete this property on all the features')
- rename.title = L._('Rename this property on all the features')
- L.DomEvent.on(del, 'click', () => this.deleteProperty(property))
- L.DomEvent.on(rename, 'click', () => this.renameProperty(property))
- },
-
- renderRow: function (feature) {
- const builder = new U.FormBuilder(feature, this.field_properties, {
- id: `umap-feature-properties_${L.stamp(feature)}`,
- className: 'trow',
- callback: feature.resetTooltip,
- })
- this.body.appendChild(builder.build())
- },
-
- compileProperties: function () {
- this.resetProperties()
- if (this.properties.length === 0) this.properties = ['name']
- // description is a forced textarea, don't edit it in a text input, or you lose cariage returns
- if (this.properties.indexOf('description') !== -1)
- this.properties.splice(this.properties.indexOf('description'), 1)
- this.properties.sort()
- this.field_properties = []
- for (let i = 0; i < this.properties.length; i++) {
- this.field_properties.push([
- `properties.${this.properties[i]}`,
- { wrapper: 'div', wrapperClass: 'tcell' },
- ])
- }
- },
-
- resetProperties: function () {
- this.properties = this.datalayer._propertiesIndex
- },
-
- validateName: (name) => {
- if (name.indexOf('.') !== -1) {
- U.Alert.error(L._('Invalide property name: {name}', { name: name }))
- return false
- }
- return true
- },
-
- renameProperty: function (property) {
- this.datalayer.map.dialog
- .prompt(L._('Please enter the new name of this property'))
- .then(({ prompt }) => {
- if (!prompt || !this.validateName(prompt)) return
- this.datalayer.eachLayer((feature) => {
- feature.renameProperty(property, prompt)
- })
- this.datalayer.deindexProperty(property)
- this.datalayer.indexProperty(prompt)
- this.edit()
- })
- },
-
- deleteProperty: function (property) {
- this.datalayer.map.dialog
- .confirm(
- L._('Are you sure you want to delete this property on all the features?')
- )
- .then(() => {
- this.datalayer.eachLayer((feature) => {
- feature.deleteProperty(property)
- })
- this.datalayer.deindexProperty(property)
- this.resetProperties()
- this.edit()
- })
- },
-
- addProperty: function () {
- this.datalayer.map.dialog
- .prompt(L._('Please enter the name of the property'))
- .then(({ prompt }) => {
- if (!prompt || !this.validateName(prompt)) return
- this.datalayer.indexProperty(prompt)
- this.edit()
- })
- },
-
- edit: function () {
- const id = 'tableeditor:edit'
- this.compileProperties()
- this.renderHeaders()
- this.body.innerHTML = ''
- this.datalayer.eachLayer(this.renderRow, this)
- const addButton = L.DomUtil.createButton(
- 'flat',
- undefined,
- L._('Add a new property')
- )
- const iconElement = L.DomUtil.createIcon(addButton, 'icon-add')
- addButton.insertBefore(iconElement, addButton.firstChild)
- L.DomEvent.on(addButton, 'click', this.addProperty, this)
- this.datalayer.map.fullPanel.open({
- content: this.table,
- className: 'umap-table-editor',
- actions: [addButton],
- })
- },
-})
diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css
index a3df4b8b..4df013c0 100644
--- a/umap/static/umap/map.css
+++ b/umap/static/umap/map.css
@@ -908,64 +908,6 @@ a.umap-control-caption,
padding-left: 31px;
}
-/* ********************************* */
-/* Table Editor */
-/* ********************************* */
-
-.umap-table-editor .table {
- display: table;
- width: 100%;
- white-space: nowrap;
- table-layout: fixed;
-}
-.umap-table-editor .tbody {
- display: table-row-group;
-}
-.umap-table-editor .thead,
-.umap-table-editor .trow {
- display: table-row;
-}
-.umap-table-editor .tcell {
- display: table-cell;
- width: 200px;
-}
-.umap-table-editor .thead {
- text-align: center;
- height: 48px;
- line-height: 48px;
- background-color: #2c3133;
-}
-.umap-table-editor .thead .tcell {
- border-left: 1px solid #0b0c0c;
-}
-.umap-table-editor .tbody .trow input {
- margin: 0;
- border-right: none;
- display: inline;
-}
-.umap-table-editor .tbody .trow + .trow input {
- border-top: none;
-}
-.umap-table-editor .thead i {
- display: none;
- width: 50%;
- cursor: pointer;
- padding: 10px 0;
- height: 24px;
- line-height: 24px;
-}
-.umap-table-editor .thead i:before {
- width: 40px;
-}
-.umap-table-editor .thead .tcell:hover i {
- display: inline-block;
-}
-.umap-table-editor .thead .tcell i:hover {
- background-color: #33393b;
-}
-.umap-table-editor .thead .tcell:hover span {
- display: none;
-}
/* ********************************* */
/* Tilelayer switcher */
diff --git a/umap/static/umap/vars.css b/umap/static/umap/vars.css
index 9e6a88b0..b8d3dc25 100644
--- a/umap/static/umap/vars.css
+++ b/umap/static/umap/vars.css
@@ -44,9 +44,13 @@
--zindex-toolbar: 480;
--zindex-autocomplete: 470;
--zindex-dialog: 460;
+ --zindex-contextmenu: 455;
--zindex-icon-active: 450;
+ --zindex-tooltip: 445;
--zindex-panels: 440;
--zindex-dragover: 410;
+
+ --block-shadow: 0 1px 7px var(--color-mediumGray);
}
.dark {
--background-color: var(--color-darkGray);
diff --git a/umap/templates/umap/css.html b/umap/templates/umap/css.html
index 1fccc8cc..f488b93e 100644
--- a/umap/templates/umap/css.html
+++ b/umap/templates/umap/css.html
@@ -29,9 +29,11 @@
+
+
diff --git a/umap/templates/umap/js.html b/umap/templates/umap/js.html
index 9659b547..9ae5857b 100644
--- a/umap/templates/umap/js.html
+++ b/umap/templates/umap/js.html
@@ -51,6 +51,5 @@
-
diff --git a/umap/tests/integration/test_tableeditor.py b/umap/tests/integration/test_tableeditor.py
index cbedf3aa..7ce41722 100644
--- a/umap/tests/integration/test_tableeditor.py
+++ b/umap/tests/integration/test_tableeditor.py
@@ -2,8 +2,68 @@ import json
import re
from pathlib import Path
+from playwright.sync_api import expect
+
from umap.models import DataLayer
+from ..base import DataLayerFactory
+
+DATALAYER_DATA = {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {
+ "mytype": "even",
+ "name": "Point 2",
+ "mynumber": 10,
+ "myboolean": True,
+ "mydate": "2024/04/14 12:19:17",
+ },
+ "geometry": {"type": "Point", "coordinates": [0.065918, 48.385442]},
+ "id": "poin2", # Must be exactly 5 chars long so the frontend will keep it
+ },
+ {
+ "type": "Feature",
+ "properties": {
+ "mytype": "odd",
+ "name": "Point 1",
+ "mynumber": 12,
+ "myboolean": False,
+ "mydate": "2024/03/13 12:20:20",
+ },
+ "geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]},
+ "id": "poin1",
+ },
+ {
+ "type": "Feature",
+ "properties": {
+ "mytype": "even",
+ "name": "Point 4",
+ "mynumber": 10,
+ "myboolean": "true",
+ "mydate": "2024/08/18 13:14:15",
+ },
+ "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]},
+ "id": "poin4",
+ },
+ {
+ "type": "Feature",
+ "properties": {
+ "mytype": "odd",
+ "name": "Point 3",
+ "mynumber": 14,
+ "mydate": "2024-04-14T10:19:17.000Z",
+ },
+ "geometry": {"type": "Point", "coordinates": [4.372559, 47.945786]},
+ "id": "poin3",
+ },
+ ],
+ "_umap_options": {
+ "name": "Calque 2",
+ },
+}
+
def test_table_editor(live_server, openmap, datalayer, page):
page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit")
@@ -12,10 +72,11 @@ def test_table_editor(live_server, openmap, datalayer, page):
page.get_by_text("Add a new property").click()
page.locator("dialog").locator("input").fill("newprop")
page.locator("dialog").get_by_role("button", name="OK").click()
+ page.locator("td").nth(2).dblclick()
page.locator('input[name="newprop"]').fill("newvalue")
- page.once("dialog", lambda dialog: dialog.accept())
- page.hover(".umap-table-editor .tcell")
- page.get_by_title("Delete this property on all").first.click()
+ page.keyboard.press("Enter")
+ page.locator("thead button[data-property=name]").click()
+ page.get_by_role("button", name="Delete this column").click()
page.locator("dialog").get_by_role("button", name="OK").click()
with page.expect_response(re.compile(r".*/datalayer/update/.*")):
page.get_by_role("button", name="Save").click()
@@ -23,3 +84,94 @@ def test_table_editor(live_server, openmap, datalayer, page):
data = json.loads(Path(saved.geojson.path).read_text())
assert data["features"][0]["properties"]["newprop"] == "newvalue"
assert "name" not in data["features"][0]["properties"]
+
+
+def test_cannot_add_existing_property_name(live_server, openmap, datalayer, page):
+ page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit")
+ page.get_by_role("link", name="Manage layers").click()
+ page.locator(".panel").get_by_title("Edit properties in a table").click()
+ page.get_by_text("Add a new property").click()
+ page.locator("dialog").locator("input").fill("name")
+ page.get_by_role("button", name="OK").click()
+ expect(page.get_by_role("dialog")).to_contain_text(
+ "This name already exists: “name”"
+ )
+ expect(page.locator("table th button[data-property=name]")).to_have_count(1)
+
+
+def test_cannot_add_property_with_a_dot(live_server, openmap, datalayer, page):
+ page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit")
+ page.get_by_role("link", name="Manage layers").click()
+ page.locator(".panel").get_by_title("Edit properties in a table").click()
+ page.get_by_text("Add a new property").click()
+ page.locator("dialog").locator("input").fill("foo.bar")
+ page.get_by_role("button", name="OK").click()
+ expect(page.get_by_role("dialog")).to_contain_text(
+ "Name “foo.bar” should not contain a dot."
+ )
+ expect(page.locator("table th button[data-property=name]")).to_have_count(1)
+
+
+def test_rename_property(live_server, openmap, datalayer, page):
+ page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit")
+ page.get_by_role("link", name="Manage layers").click()
+ page.locator(".panel").get_by_title("Edit properties in a table").click()
+ expect(page.locator("table th button[data-property=name]")).to_have_count(1)
+ page.locator("thead button[data-property=name]").click()
+ page.get_by_text("Rename this column").click()
+ page.locator("dialog").locator("input").fill("newname")
+ page.get_by_role("button", name="OK").click()
+ expect(page.locator("table th button[data-property=newname]")).to_have_count(1)
+ expect(page.locator("table th button[data-property=name]")).to_have_count(0)
+
+
+def test_delete_selected_rows(live_server, openmap, page):
+ DataLayerFactory(map=openmap, data=DATALAYER_DATA)
+ page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit#6/48.093/1.890")
+ page.get_by_role("link", name="Manage layers").click()
+ page.locator(".panel").get_by_title("Edit properties in a table").click()
+ expect(page.locator("tbody tr")).to_have_count(4)
+ expect(page.locator(".leaflet-marker-icon")).to_have_count(4)
+ page.locator("tr[data-feature=poin2]").get_by_role("checkbox").check()
+ page.get_by_role("button", name="Delete selected rows").click()
+ page.get_by_role("button", name="OK").click()
+ expect(page.locator("tbody tr")).to_have_count(3)
+ expect(page.locator(".leaflet-marker-icon")).to_have_count(3)
+
+
+def test_delete_all_rows(live_server, openmap, page):
+ DataLayerFactory(map=openmap, data=DATALAYER_DATA)
+ page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit#6/48.093/1.890")
+ page.get_by_role("link", name="Manage layers").click()
+ page.locator(".panel").get_by_title("Edit properties in a table").click()
+ expect(page.locator("tbody tr")).to_have_count(4)
+ expect(page.locator(".leaflet-marker-icon")).to_have_count(4)
+ page.locator("thead").get_by_role("checkbox").check()
+ page.get_by_role("button", name="Delete selected rows").click()
+ page.get_by_role("button", name="OK").click()
+ expect(page.locator("tbody tr")).to_have_count(0)
+ expect(page.locator(".leaflet-marker-icon")).to_have_count(0)
+
+
+def test_filter_and_delete_rows(live_server, openmap, page):
+ DataLayerFactory(map=openmap, data=DATALAYER_DATA)
+ panel = page.locator(".panel.left.on")
+ table = page.locator(".panel.full table")
+ page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit#6/48.093/1.890")
+ page.get_by_role("link", name="Manage layers").click()
+ page.locator(".panel").get_by_title("Edit properties in a table").click()
+ expect(table.locator("tbody tr")).to_have_count(4)
+ expect(page.locator(".leaflet-marker-icon")).to_have_count(4)
+ table.locator("thead button[data-property=mytype]").click()
+ page.get_by_role("button", name="Add filter for this column").click()
+ expect(panel).to_be_visible()
+ panel.get_by_label("even").check()
+ table.locator("thead").get_by_role("checkbox").check()
+ page.get_by_role("button", name="Delete selected rows").click()
+ page.get_by_role("button", name="OK").click()
+ expect(table.locator("tbody tr")).to_have_count(2)
+ expect(page.locator(".leaflet-marker-icon")).to_have_count(2)
+ expect(table.get_by_text("Point 1")).to_be_visible()
+ expect(table.get_by_text("Point 3")).to_be_visible()
+ expect(table.get_by_text("Point 2")).to_be_hidden()
+ expect(table.get_by_text("Point 4")).to_be_hidden()
|