diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index 4894acf79..e49d049c2 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -277,6 +277,7 @@ button.flat, width: initial; display: initial; line-height: inherit; + color: var(--text-color); } button.flat:hover, [type="button"].flat:hover, diff --git a/umap/static/umap/css/contextmenu.css b/umap/static/umap/css/contextmenu.css new file mode 100644 index 000000000..da55f1c87 --- /dev/null +++ b/umap/static/umap/css/contextmenu.css @@ -0,0 +1,11 @@ +.umap-contextmenu { + background-color: var(--background-color); + padding: calc(var(--box-padding) / 2) var(--box-padding); + position: absolute; + z-index: var(--zindex-contextmenu); + border-radius: var(--border-radius); + box-shadow: var(--block-shadow); +} +.umap-contextmenu li + li { + margin-top: var(--text-margin); +} diff --git a/umap/static/umap/css/icon.css b/umap/static/umap/css/icon.css index fb22329fd..ad4e4cfbc 100644 --- a/umap/static/umap/css/icon.css +++ b/umap/static/umap/css/icon.css @@ -118,6 +118,10 @@ .icon-upload { background-position: -144px -97px; } +.icon-download { + transform: rotate(180deg); + background-position: -146px -95px; +} .icon-zoom { background-position: -1px -49px; } diff --git a/umap/static/umap/css/panel.css b/umap/static/umap/css/panel.css index be99ffa53..5f35938da 100644 --- a/umap/static/umap/css/panel.css +++ b/umap/static/umap/css/panel.css @@ -8,7 +8,6 @@ z-index: var(--zindex-panels); background-color: var(--background-color); color: var(--text-color); - opacity: 0.98; cursor: initial; border-radius: 5px; border: 1px solid var(--color-lightGray); @@ -27,7 +26,7 @@ .panel.full.on { visibility: visible; right: calc(var(--panel-gutter) * 2 + var(--control-size)); - left: var(--panel-gutter); + left: calc(var(--panel-gutter) * 2 + var(--control-size)); height: initial; max-height: initial; } @@ -78,6 +77,9 @@ right: calc(var(--panel-gutter) * 2 + var(--control-size)); visibility: visible; } + .panel-left-on .panel.full { + left: calc(var(--panel-gutter) * 3 + var(--control-size) + var(--panel-width)); + } } @media all and (orientation:portrait) { .panel { diff --git a/umap/static/umap/css/tableeditor.css b/umap/static/umap/css/tableeditor.css new file mode 100644 index 000000000..39a18777a --- /dev/null +++ b/umap/static/umap/css/tableeditor.css @@ -0,0 +1,87 @@ +.umap-table-editor { + width: 100%; + overflow-x: auto; +} +.umap-table-editor table { + white-space: nowrap; + table-layout: fixed; + border-collapse: collapse; + border-bottom: 1px solid black; + border-top: 1px solid black; + min-width: 100%; +} +.umap-table-editor thead { + text-align: center; + height: 48px; + line-height: 48px; + background-color: #2c3133; +} +.umap-table-editor thead tr { + border-bottom: 3px solid var(--color-accent); +} +.umap-table-editor thead th { + border-left: 1px solid #0b0c0c; +} +.umap-table-editor .tbody tr input { + margin: 0; + border-right: none; + display: inline; +} +.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 th:hover i { + display: inline-block; +} +.umap-table-editor thead th i:hover { + background-color: #33393b; +} +.umap-table-editor thead th:hover span { + display: none; +} +.umap-table-editor td { + overflow: hidden; +} +.umap-table-editor td { + border: 1px solid #222; +} +.umap-table-editor td:focus { + outline: 1px solid var(--color-accent); +} +.umap-table-editor th, .umap-table-editor td { + padding: 10px; + vertical-align: top; +} +.umap-table-editor tr:nth-child(even) { + background-color: var(--color-mediumGray); +} +.umap-table-editor tr { + border-left: 1px solid black; + border-right: 1px solid black; +} +.umap-table-editor .formbox, +.umap-table-editor input { + margin: 0; +} +.umap-table-editor input { + border-radius: initial; +} +.umap-table-editor th button { + transform: rotate(90deg); + font-size: 1.25rem; + display: inline-block; + vertical-align: middle; + margin-left: 1rem; + font-weight: bold; +} +.umap-table-editor th button:hover { + text-decoration: none; +} diff --git a/umap/static/umap/css/tooltip.css b/umap/static/umap/css/tooltip.css index eed310ab0..6af261782 100644 --- a/umap/static/umap/css/tooltip.css +++ b/umap/static/umap/css/tooltip.css @@ -3,13 +3,13 @@ padding: 5px 10px; width: auto; position: absolute; - box-shadow: 0 1px 7px #999999; + box-shadow: var(--block-shadow); display: none; - background-color: rgba(40, 40, 40, 0.8); + background-color: rgba(40, 40, 40, 0.9); color: #eeeeec; font-size: 0.8em; border-radius: 2px; - z-index: calc(var(--zindex-panels) + 1); + z-index: var(--zindex-tooltip); font-weight: normal; max-width: 300px; } diff --git a/umap/static/umap/img/16-white.svg b/umap/static/umap/img/16-white.svg index 7b537b2ce..927c70285 100644 --- a/umap/static/umap/img/16-white.svg +++ b/umap/static/umap/img/16-white.svg @@ -188,5 +188,9 @@ + + + + diff --git a/umap/static/umap/img/source/16-white.svg b/umap/static/umap/img/source/16-white.svg index 2737b5de5..3d10b375a 100644 --- a/umap/static/umap/img/source/16-white.svg +++ b/umap/static/umap/img/source/16-white.svg @@ -16,7 +16,7 @@ - + @@ -210,5 +210,9 @@ + + + + diff --git a/umap/static/umap/js/modules/browser.js b/umap/static/umap/js/modules/browser.js index db988d7e6..fc6067c93 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 6677b5f6e..d595bab71 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 11b62b2ba..91eeb514d 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' @@ -54,6 +55,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 000000000..f5db47b02 --- /dev/null +++ b/umap/static/umap/js/modules/tableeditor.js @@ -0,0 +1,369 @@ +import { DomEvent, DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' +import { translate } from './i18n.js' +import ContextMenu from './ui/contextmenu.js' +import { WithTemplate, loadTemplate, downloadBlob } 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) + this.resetProperties() + 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 + } + + compileProperties() { + this.resetProperties() + if (this.properties.length === 0) this.properties = ['name'] + this.properties.sort() + this.field_properties = [] + for (let i = 0; i < this.properties.length; i++) { + this.field_properties.push([ + `properties.${this.properties[i]}`, + { wrapper: 'td' }, + ]) + } + } + + resetProperties() { + this.properties = this.datalayer._propertiesIndex + } + + validateName(name) { + if (name.includes('.')) { + U.Alert.error(translate('Invalide property name: {name}', { name: name })) + return false + } + if (this.datalayer._propertiesIndex.includes(name)) { + U.Alert.error(translate('This name already exists: {name}', { 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.compileProperties() + 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) + + const downloadButton = loadTemplate(` + `) + downloadButton.addEventListener('click', () => this.exportAsCSV()) + actions.push(downloadButton) + + 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: handler }]], { + id: `umap-feature-properties_${L.stamp(feature)}`, + className: 'trow', + callback: feature.resetTooltip, + }) + cell.innerHTML = '' + cell.appendChild(builder.build()) + const input = builder.helpers[field].input + input.focus() + input.addEventListener('blur', () => { + cell.innerHTML = feature.properties[property] || '' + cell.focus() + }) + } + + 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.closest('tr')) + } + + 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') + } + }) + } + + _rowToCSV(row) { + console.log(row) + return row + .map((content) => content.replaceAll('"', '""')) // escape double quotes + .map((content) => `"${content}"`) // quote it + .join(',') // comma-separated + } + + exportAsCSV() { + const headers = this._rowToCSV( + Array.from(this.elements.header.querySelectorAll('th')) + .slice(1) // Remove initial select-all checkbox column + .map((header) => header.textContent) + .map((header) => header.slice(0, -1)) // Remove trailing `…` + ) + const rows = Array.from(this.elements.body.querySelectorAll('tr')) + .map((line) => + Array.from(line.querySelectorAll('td')).map((cell) => cell.textContent) + ) + .map(this._rowToCSV) + const csv = [headers, ...rows].join('\r\n') + downloadBlob( + csv, + `umap-export-${this.datalayer.umap_id}-${this.map.options.umap_id}.csv`, + 'text/csv;charset=utf-8;' + ) + } +} 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 000000000..4f0c176cf --- /dev/null +++ b/umap/static/umap/js/modules/ui/base.js @@ -0,0 +1,77 @@ +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' + } + + 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 000000000..89db7940d --- /dev/null +++ b/umap/static/umap/js/modules/ui/contextmenu.js @@ -0,0 +1,49 @@ +import { loadTemplate } from '../utils.js' + +export default class ContextMenu { + constructor(options = {}) { + 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.container.style.top = `${y}px` + this.container.style.left = `${x}px` + 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 f84a92b30..6fa77e601 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 feed1b5e8..88a859f94 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 c1427fd0c..121c24c9f 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 @@ -390,3 +397,12 @@ export class WithTemplate { return this.element } } + +export function downloadBlob(content, filename, contentType) { + const blob = new Blob([content], { type: contentType }) + const url = URL.createObjectURL(blob) + const tmp = document.createElement('a') + tmp.href = url + tmp.setAttribute('download', filename) + tmp.click() +} diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 4b0171527..25d68b364 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -1158,7 +1158,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 c7d4d1a39..2e6ef8bcd 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -1032,7 +1032,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) { @@ -1793,9 +1793,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/map.css b/umap/static/umap/map.css index a3df4b8b7..4df013c03 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 9e6a88b09..b8d3dc255 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 1fccc8ccf..f488b93e0 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 9659b5475..9ae5857be 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 cbedf3aa0..a19040fce 100644 --- a/umap/tests/integration/test_tableeditor.py +++ b/umap/tests/integration/test_tableeditor.py @@ -2,8 +2,68 @@ 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,79 @@ 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_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()