From ae9659620a138d92ef58d3520629c40110f40a47 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 4 Jul 2024 13:12:11 +0200 Subject: [PATCH 01/18] wip: another attemp to massiverly delete features from a condition --- umap/static/umap/css/panel.css | 3 +- umap/static/umap/css/tableeditor.css | 75 +++++++ umap/static/umap/js/modules/browser.js | 20 +- umap/static/umap/js/modules/global.js | 2 + umap/static/umap/js/modules/tableeditor.js | 232 +++++++++++++++++++++ umap/static/umap/js/umap.layer.js | 4 +- umap/static/umap/map.css | 58 ------ umap/templates/umap/css.html | 1 + umap/templates/umap/js.html | 1 - 9 files changed, 327 insertions(+), 69 deletions(-) create mode 100644 umap/static/umap/css/tableeditor.css create mode 100644 umap/static/umap/js/modules/tableeditor.js diff --git a/umap/static/umap/css/panel.css b/umap/static/umap/css/panel.css index be99ffa5..19c7240a 100644 --- a/umap/static/umap/css/panel.css +++ b/umap/static/umap/css/panel.css @@ -27,7 +27,8 @@ .panel.full.on { visibility: visible; right: calc(var(--panel-gutter) * 2 + var(--control-size)); - left: var(--panel-gutter); +/* left: var(--panel-gutter);*/ + left: calc(var(--panel-gutter) * 3 + var(--control-size) + var(--panel-width)); height: initial; max-height: initial; } diff --git a/umap/static/umap/css/tableeditor.css b/umap/static/umap/css/tableeditor.css new file mode 100644 index 00000000..90f955dd --- /dev/null +++ b/umap/static/umap/css/tableeditor.css @@ -0,0 +1,75 @@ +.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; +} +.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-color: 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; +} diff --git a/umap/static/umap/js/modules/browser.js b/umap/static/umap/js/modules/browser.js index a5ef3c36..fc4a1af4 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/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..df17ad3e --- /dev/null +++ b/umap/static/umap/js/modules/tableeditor.js @@ -0,0 +1,232 @@ +import { DomEvent, DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' +import { translate } from './i18n.js' + +export default class TableEditor { + constructor(datalayer) { + this.datalayer = datalayer + this.table = DomUtil.create('table') + this.thead = DomUtil.create('thead', '', this.table) + this.header = DomUtil.create('tr', '', this.thead) + this.body = DomUtil.create('tbody', '', this.table) + this.resetProperties() + this.body.addEventListener('dblclick', (event) => { + if (event.target.closest('[data-property]')) this.editCell(event.target) + }) + this.body.addEventListener('click', (event) => this.setFocus(event.target)) + this.body.addEventListener('keydown', (event) => this.onKeyDown(event)) + } + + renderHeaders() { + this.header.innerHTML = '' + for (let i = 0; i < this.properties.length; i++) { + this.renderHeader(this.properties[i]) + } + const checkbox = this.header.querySelector('input[type=checkbox]') + checkbox.addEventListener('change', (event) => { + if (checkbox.checked) this.checkAll() + else this.checkAll(false) + }) + } + + renderHeader(property) { + const container = DomUtil.create('th', '', this.header) + const title = DomUtil.add('span', '', container, property) + const del = DomUtil.create('i', 'umap-delete', container) + const rename = DomUtil.create('i', 'umap-edit', container) + del.title = translate('Delete this property on all the features') + rename.title = translate('Rename this property on all the features') + DomEvent.on(del, 'click', () => this.deleteProperty(property)) + DomEvent.on(rename, 'click', () => this.renameProperty(property)) + } + + renderBody() { + const bounds = this.datalayer.map.getBounds() + const inBbox = this.datalayer.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 + html += `${this.properties.map((prop) => `${feature.properties[prop] || ''}`).join('')}` + } + // this.datalayer.eachLayer(this.renderRow, this) + // 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()) + this.body.innerHTML = html + } + + compileProperties() { + 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: 'td' }, + ]) + } + } + + resetProperties() { + this.properties = this.datalayer._propertiesIndex + } + + validateName(name) { + if (name.includes('.') !== -1) { + U.Alert.error(translate('Invalide property name: {name}', { name: name })) + return false + } + return true + } + + renameProperty(property) { + this.datalayer.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.datalayer.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.datalayer.map.dialog + .prompt(translate('Please enter the name of the property')) + .then(({ prompt }) => { + if (!prompt || !this.validateName(prompt)) return + this.datalayer.indexProperty(prompt) + this.edit() + }) + } + + open() { + const id = 'tableeditor:edit' + this.compileProperties() + this.renderHeaders() + this.body.innerHTML = '' + this.renderBody() + const addButton = DomUtil.createButton( + 'flat', + undefined, + translate('Add a new property') + ) + const iconElement = DomUtil.createIcon(addButton, 'icon-add') + addButton.insertBefore(iconElement, addButton.firstChild) + DomEvent.on(addButton, 'click', this.addProperty, this) + + const template = document.createElement('template') + template.innerHTML = ` + ` + const deleteButton = template.content.firstElementChild + deleteButton.addEventListener('click', () => this.deleteRows()) + + this.datalayer.map.fullPanel.open({ + content: this.table, + className: 'umap-table-editor', + actions: [addButton, deleteButton], + }) + } + + editCell(cell) { + const property = cell.dataset.property + const field = `properties.${property}` + const feature = this.datalayer.getFeatureById( + event.target.parentNode.dataset.feature + ) + const builder = new U.FormBuilder(feature, [field], { + 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] || '' + }) + } + + onKeyDown(event) { + const key = event.key + if (key === 'Enter') { + const current = this.getFocus() + if (current) { + this.editCell(current) + event.preventDefault() + event.stopPropagation() + } + } + } + + checkAll(status = true) { + for (const checkbox of this.body.querySelectorAll('input[type=checkbox]')) { + checkbox.checked = status + } + } + + getSelectedRows() { + return Array.from(this.body.querySelectorAll('input[type=checkbox]:checked')).map( + (checkbox) => checkbox.parentNode.parentNode + ) + } + + getFocus() { + return this.body.querySelector(':focus') + } + + setFocus(cell) { + cell.focus({ focusVisible: true }) + } + + deleteRows() { + const selectedRows = this.getSelectedRows() + if (!selectedRows.length) return + this.datalayer.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() + this.datalayer.map.browser.resetFilters() + }) + } +} diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 0eee5b9e..96dda219 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) { @@ -1802,7 +1802,7 @@ U.DataLayer = L.Evented.extend({ tableEdit: function () { if (this.isRemoteLayer() || !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 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/templates/umap/css.html b/umap/templates/umap/css.html index 1fccc8cc..92307fca 100644 --- a/umap/templates/umap/css.html +++ b/umap/templates/umap/css.html @@ -34,4 +34,5 @@ + 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 @@ - From 9ac3de2b333a378b9a5b4c21b575c5f5805632b6 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 5 Jul 2024 23:54:13 +0200 Subject: [PATCH 02/18] wip: (huge) table editor refactor --- umap/static/umap/base.css | 1 + umap/static/umap/css/contextmenu.css | 11 ++ umap/static/umap/css/panel.css | 6 +- umap/static/umap/css/tableeditor.css | 12 ++ umap/static/umap/css/tooltip.css | 6 +- umap/static/umap/img/16-white.svg | 4 + umap/static/umap/img/source/16-white.svg | 6 +- umap/static/umap/js/modules/facets.js | 50 ++++-- umap/static/umap/js/modules/tableeditor.js | 166 +++++++++++------- umap/static/umap/js/modules/ui/base.js | 77 ++++++++ umap/static/umap/js/modules/ui/contextmenu.js | 43 +++++ umap/static/umap/js/modules/ui/panel.js | 10 +- umap/static/umap/js/modules/ui/tooltip.js | 80 +-------- umap/static/umap/js/modules/utils.js | 13 +- umap/static/umap/vars.css | 4 + umap/templates/umap/css.html | 1 + 16 files changed, 333 insertions(+), 157 deletions(-) create mode 100644 umap/static/umap/css/contextmenu.css create mode 100644 umap/static/umap/js/modules/ui/base.js create mode 100644 umap/static/umap/js/modules/ui/contextmenu.js diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index 4894acf7..e49d049c 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 00000000..da55f1c8 --- /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/panel.css b/umap/static/umap/css/panel.css index 19c7240a..bd6d0687 100644 --- a/umap/static/umap/css/panel.css +++ b/umap/static/umap/css/panel.css @@ -27,8 +27,7 @@ .panel.full.on { visibility: visible; right: calc(var(--panel-gutter) * 2 + var(--control-size)); -/* left: var(--panel-gutter);*/ - left: calc(var(--panel-gutter) * 3 + var(--control-size) + var(--panel-width)); + left: calc(var(--panel-gutter) * 2 + var(--control-size)); height: initial; max-height: initial; } @@ -79,6 +78,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 index 90f955dd..bdad44bd 100644 --- a/umap/static/umap/css/tableeditor.css +++ b/umap/static/umap/css/tableeditor.css @@ -8,6 +8,7 @@ border-collapse: collapse; border-bottom: 1px solid black; border-top: 1px solid black; + min-width: 100%; } .umap-table-editor thead { text-align: center; @@ -73,3 +74,14 @@ .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 eed310ab..6af26178 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 7b537b2c..927c7028 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 2737b5de..3d10b375 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/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/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index df17ad3e..90212c84 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -1,61 +1,104 @@ 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' -export default class TableEditor { +const TEMPLATE = ` + + + + + + +
+` + +export default class TableEditor extends WithTemplate { constructor(datalayer) { + super() this.datalayer = datalayer - this.table = DomUtil.create('table') - this.thead = DomUtil.create('thead', '', this.table) - this.header = DomUtil.create('tr', '', this.thead) - this.body = DomUtil.create('tbody', '', this.table) + this.map = this.datalayer.map + this.contextmenu = new ContextMenu({ className: 'dark' }) + this.table = this.loadTemplate(TEMPLATE) this.resetProperties() - this.body.addEventListener('dblclick', (event) => { + this.elements.body.addEventListener('dblclick', (event) => { if (event.target.closest('[data-property]')) this.editCell(event.target) }) - this.body.addEventListener('click', (event) => this.setFocus(event.target)) - this.body.addEventListener('keydown', (event) => this.onKeyDown(event)) + 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) { + let filterItem + if (this.map.facets.has(property)) { + filterItem = { + label: translate('Remove filter for this property'), + action: () => { + this.map.facets.remove(property) + this.map.browser.open('filters') + }, + } + } else { + filterItem = { + label: translate('Add filter for this property'), + action: () => { + this.map.facets.add(property) + this.map.browser.open('filters') + }, + } + } + this.contextmenu.open( + [event.clientX, event.clientY], + [ + { + label: translate('Delete this property on all the features'), + action: () => this.deleteProperty(property), + }, + { + label: translate('Rename this property on all the features'), + action: () => this.renameProperty(property), + }, + filterItem, + ] + ) } renderHeaders() { - this.header.innerHTML = '' - for (let i = 0; i < this.properties.length; i++) { - this.renderHeader(this.properties[i]) + 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}` + ) + ) } - const checkbox = this.header.querySelector('input[type=checkbox]') checkbox.addEventListener('change', (event) => { if (checkbox.checked) this.checkAll() else this.checkAll(false) }) } - renderHeader(property) { - const container = DomUtil.create('th', '', this.header) - const title = DomUtil.add('span', '', container, property) - const del = DomUtil.create('i', 'umap-delete', container) - const rename = DomUtil.create('i', 'umap-edit', container) - del.title = translate('Delete this property on all the features') - rename.title = translate('Rename this property on all the features') - DomEvent.on(del, 'click', () => this.deleteProperty(property)) - DomEvent.on(rename, 'click', () => this.renameProperty(property)) - } - renderBody() { - const bounds = this.datalayer.map.getBounds() - const inBbox = this.datalayer.map.browser.options.inBbox + 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 - html += `${this.properties.map((prop) => `${feature.properties[prop] || ''}`).join('')}` + const tds = this.properties.map( + (prop) => + `${feature.properties[prop] || ''}` + ) + html += `${tds.join('')}` } - // this.datalayer.eachLayer(this.renderRow, this) - // 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()) - this.body.innerHTML = html + this.elements.body.innerHTML = html } compileProperties() { @@ -87,7 +130,7 @@ export default class TableEditor { } renameProperty(property) { - this.datalayer.map.dialog + this.map.dialog .prompt(translate('Please enter the new name of this property')) .then(({ prompt }) => { if (!prompt || !this.validateName(prompt)) return @@ -101,7 +144,7 @@ export default class TableEditor { } deleteProperty(property) { - this.datalayer.map.dialog + this.map.dialog .confirm( translate('Are you sure you want to delete this property on all the features?') ) @@ -116,7 +159,7 @@ export default class TableEditor { } addProperty() { - this.datalayer.map.dialog + this.map.dialog .prompt(translate('Please enter the name of the property')) .then(({ prompt }) => { if (!prompt || !this.validateName(prompt)) return @@ -129,29 +172,31 @@ export default class TableEditor { const id = 'tableeditor:edit' this.compileProperties() this.renderHeaders() - this.body.innerHTML = '' + this.elements.body.innerHTML = '' this.renderBody() - const addButton = DomUtil.createButton( - 'flat', - undefined, - translate('Add a new property') - ) - const iconElement = DomUtil.createIcon(addButton, 'icon-add') - addButton.insertBefore(iconElement, addButton.firstChild) - DomEvent.on(addButton, 'click', this.addProperty, this) - const template = document.createElement('template') - template.innerHTML = ` + const addButton = loadTemplate(` + `) + addButton.addEventListener('click', () => this.addProperty()) + + const deleteButton = loadTemplate(` ` - const deleteButton = template.content.firstElementChild + `) deleteButton.addEventListener('click', () => this.deleteRows()) - this.datalayer.map.fullPanel.open({ + const filterButton = loadTemplate(` + `) + filterButton.addEventListener('click', () => this.map.browser.open('filters')) + + this.map.fullPanel.open({ content: this.table, className: 'umap-table-editor', - actions: [addButton, deleteButton], + actions: [addButton, deleteButton, filterButton], }) } @@ -188,19 +233,21 @@ export default class TableEditor { } checkAll(status = true) { - for (const checkbox of this.body.querySelectorAll('input[type=checkbox]')) { + for (const checkbox of this.elements.body.querySelectorAll( + 'input[type=checkbox]' + )) { checkbox.checked = status } } getSelectedRows() { - return Array.from(this.body.querySelectorAll('input[type=checkbox]:checked')).map( - (checkbox) => checkbox.parentNode.parentNode - ) + return Array.from( + this.elements.body.querySelectorAll('input[type=checkbox]:checked') + ).map((checkbox) => checkbox.parentNode.parentNode) } getFocus() { - return this.body.querySelector(':focus') + return this.elements.body.querySelector(':focus') } setFocus(cell) { @@ -210,7 +257,7 @@ export default class TableEditor { deleteRows() { const selectedRows = this.getSelectedRows() if (!selectedRows.length) return - this.datalayer.map.dialog + this.map.dialog .confirm( translate('Found {count} rows. Are you sure you want to delete all?', { count: selectedRows.length, @@ -226,7 +273,8 @@ export default class TableEditor { this.datalayer.show() this.datalayer.fire('datachanged') this.renderBody() - this.datalayer.map.browser.resetFilters() + 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..4f0c176c --- /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 00000000..d47a0249 --- /dev/null +++ b/umap/static/umap/js/modules/ui/contextmenu.js @@ -0,0 +1,43 @@ +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() { + this.container.parentNode.removeChild(this.container) + } +} 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/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 92307fca..f488b93e 100644 --- a/umap/templates/umap/css.html +++ b/umap/templates/umap/css.html @@ -29,6 +29,7 @@ + From 5909630e0e1a9c60b512686fc477814aeca36249 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 8 Jul 2024 17:56:31 +0200 Subject: [PATCH 03/18] wip: fix bugs and add tests for table editor --- umap/static/umap/css/panel.css | 1 - umap/static/umap/js/modules/tableeditor.js | 35 ++--- umap/static/umap/js/modules/ui/contextmenu.js | 1 - umap/static/umap/js/umap.forms.js | 4 +- umap/tests/integration/test_tableeditor.py | 143 +++++++++++++++++- 5 files changed, 160 insertions(+), 24 deletions(-) diff --git a/umap/static/umap/css/panel.css b/umap/static/umap/css/panel.css index bd6d0687..5f35938d 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); diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 90212c84..8304482e 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -36,7 +36,7 @@ export default class TableEditor extends WithTemplate { let filterItem if (this.map.facets.has(property)) { filterItem = { - label: translate('Remove filter for this property'), + label: translate('Remove filter for this column'), action: () => { this.map.facets.remove(property) this.map.browser.open('filters') @@ -44,7 +44,7 @@ export default class TableEditor extends WithTemplate { } } else { filterItem = { - label: translate('Add filter for this property'), + label: translate('Add filter for this column'), action: () => { this.map.facets.add(property) this.map.browser.open('filters') @@ -55,11 +55,11 @@ export default class TableEditor extends WithTemplate { [event.clientX, event.clientY], [ { - label: translate('Delete this property on all the features'), + label: translate('Delete this column'), action: () => this.deleteProperty(property), }, { - label: translate('Rename this property on all the features'), + label: translate('Rename this column'), action: () => this.renameProperty(property), }, filterItem, @@ -104,9 +104,6 @@ export default class TableEditor extends WithTemplate { compileProperties() { 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++) { @@ -122,10 +119,14 @@ export default class TableEditor extends WithTemplate { } validateName(name) { - if (name.includes('.') !== -1) { + 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 } @@ -164,7 +165,7 @@ export default class TableEditor extends WithTemplate { .then(({ prompt }) => { if (!prompt || !this.validateName(prompt)) return this.datalayer.indexProperty(prompt) - this.edit() + this.open() }) } @@ -203,10 +204,10 @@ export default class TableEditor extends WithTemplate { editCell(cell) { const property = cell.dataset.property const field = `properties.${property}` - const feature = this.datalayer.getFeatureById( - event.target.parentNode.dataset.feature - ) - const builder = new U.FormBuilder(feature, [field], { + 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, @@ -226,8 +227,6 @@ export default class TableEditor extends WithTemplate { const current = this.getFocus() if (current) { this.editCell(current) - event.preventDefault() - event.stopPropagation() } } } @@ -273,8 +272,10 @@ export default class TableEditor extends WithTemplate { this.datalayer.show() this.datalayer.fire('datachanged') this.renderBody() - this.map.browser.resetFilters() - this.map.browser.open('filters') + if (this.map.browser.isOpen()) { + this.map.browser.resetFilters() + this.map.browser.open('filters') + } }) } } diff --git a/umap/static/umap/js/modules/ui/contextmenu.js b/umap/static/umap/js/modules/ui/contextmenu.js index d47a0249..4274fa17 100644 --- a/umap/static/umap/js/modules/ui/contextmenu.js +++ b/umap/static/umap/js/modules/ui/contextmenu.js @@ -20,7 +20,6 @@ export default class ContextMenu { `
  • ` ) li.addEventListener('click', () => { - this.close() item.action() }) this.container.appendChild(li) 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/tests/integration/test_tableeditor.py b/umap/tests/integration/test_tableeditor.py index cbedf3aa..4e0792e4 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,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() From 1ef416a622a751a151b00de749d1da077b35cb01 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 8 Jul 2024 18:30:12 +0200 Subject: [PATCH 04/18] wip: allow to open table "editor" (readonly) for remote data layers --- umap/static/umap/js/modules/tableeditor.js | 64 +++++++++++-------- umap/static/umap/js/modules/ui/contextmenu.js | 9 ++- umap/static/umap/js/umap.layer.js | 2 +- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 8304482e..4fa02ed4 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -21,9 +21,11 @@ export default class TableEditor extends WithTemplate { this.contextmenu = new ContextMenu({ className: 'dark' }) this.table = this.loadTemplate(TEMPLATE) this.resetProperties() - this.elements.body.addEventListener('dblclick', (event) => { - if (event.target.closest('[data-property]')) this.editCell(event.target) - }) + 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) => { @@ -33,6 +35,7 @@ export default class TableEditor extends WithTemplate { } openHeaderMenu(property) { + const actions = [] let filterItem if (this.map.facets.has(property)) { filterItem = { @@ -51,20 +54,18 @@ export default class TableEditor extends WithTemplate { }, } } - this.contextmenu.open( - [event.clientX, event.clientY], - [ - { - label: translate('Delete this column'), - action: () => this.deleteProperty(property), - }, - { - label: translate('Rename this column'), - action: () => this.renameProperty(property), - }, - filterItem, - ] - ) + 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() { @@ -176,32 +177,39 @@ export default class TableEditor extends WithTemplate { this.elements.body.innerHTML = '' this.renderBody() - const addButton = loadTemplate(` - `) - addButton.addEventListener('click', () => this.addProperty()) + 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()) + 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: [addButton, deleteButton, filterButton], + actions: actions, }) } editCell(cell) { + if (this.datalayer.isRemoteLayer()) return const property = cell.dataset.property const field = `properties.${property}` const tr = event.target.closest('tr') diff --git a/umap/static/umap/js/modules/ui/contextmenu.js b/umap/static/umap/js/modules/ui/contextmenu.js index 4274fa17..89db7940 100644 --- a/umap/static/umap/js/modules/ui/contextmenu.js +++ b/umap/static/umap/js/modules/ui/contextmenu.js @@ -20,6 +20,7 @@ export default class ContextMenu { `
  • ` ) li.addEventListener('click', () => { + this.close() item.action() }) this.container.appendChild(li) @@ -37,6 +38,12 @@ export default class ContextMenu { } close() { - this.container.parentNode.removeChild(this.container) + 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/umap.layer.js b/umap/static/umap/js/umap.layer.js index 96dda219..2fef9178 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -1800,7 +1800,7 @@ U.DataLayer = L.Evented.extend({ }, tableEdit: function () { - if (this.isRemoteLayer() || !this.isVisible()) return + if (!this.isVisible()) return const editor = new U.TableEditor(this) editor.open() }, From f62f3b4ab90a78d9c712455c5b73ad868d63d48c Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 8 Jul 2024 18:38:57 +0200 Subject: [PATCH 05/18] wip: make outline appears on click on table editor --- umap/static/umap/css/tableeditor.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/umap/static/umap/css/tableeditor.css b/umap/static/umap/css/tableeditor.css index bdad44bd..39a18777 100644 --- a/umap/static/umap/css/tableeditor.css +++ b/umap/static/umap/css/tableeditor.css @@ -54,7 +54,7 @@ border: 1px solid #222; } .umap-table-editor td:focus { - outline-color: var(--color-accent); + outline: 1px solid var(--color-accent); } .umap-table-editor th, .umap-table-editor td { padding: 10px; From f1c34f9d27fdf43d4d521f8df34da9730e7f40a8 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 8 Jul 2024 19:15:28 +0200 Subject: [PATCH 06/18] wip: minimal keyboard navigation in table editor --- umap/static/umap/js/modules/tableeditor.js | 55 ++++++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 4fa02ed4..622dee21 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -226,16 +226,61 @@ export default class TableEditor extends WithTemplate { 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 - if (key === 'Enter') { - const current = this.getFocus() - if (current) { - this.editCell(current) - } + 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() } } From 2f8cda83b608eeff5fa29332754a283654731f1b Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 10 Jul 2024 10:32:25 +0200 Subject: [PATCH 07/18] wip: do not try to read map.fullPanel blindly It is only defined when user has write permissions. --- umap/static/umap/js/modules/browser.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/umap/static/umap/js/modules/browser.js b/umap/static/umap/js/modules/browser.js index fc4a1af4..6afdf92d 100644 --- a/umap/static/umap/js/modules/browser.js +++ b/umap/static/umap/js/modules/browser.js @@ -107,7 +107,7 @@ export default class Browser { this.map.eachBrowsableDataLayer((datalayer) => { datalayer.resetLayer(true) this.updateDatalayer(datalayer) - if (this.map.fullPanel.isOpen()) datalayer.tableEdit() + if (this.map.fullPanel?.isOpen()) datalayer.tableEdit() }) this.toggleBadge() } From 3fb7be28ec672a7cd5f63697a9270f774c658dbb Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 10 Jul 2024 10:47:26 +0200 Subject: [PATCH 08/18] wip: remove old tableeditor class --- umap/static/umap/js/umap.tableeditor.js | 124 ------------------------ 1 file changed, 124 deletions(-) delete mode 100644 umap/static/umap/js/umap.tableeditor.js 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], - }) - }, -}) From 9f04f56c7cccf4b8e8941bc54a1c86318d53af79 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 10 Jul 2024 11:21:51 +0200 Subject: [PATCH 09/18] wip: use position: fixed for input/textarea in tableeditor so it does not increase the cell width and does not change all the table size. Actually, there is still a small size change as we remove the cell content before adding the input/textarea, but more subtle than before --- umap/static/umap/css/tableeditor.css | 6 +++++- umap/static/umap/js/modules/tableeditor.js | 4 +--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/umap/static/umap/css/tableeditor.css b/umap/static/umap/css/tableeditor.css index 39a18777..b419bab2 100644 --- a/umap/static/umap/css/tableeditor.css +++ b/umap/static/umap/css/tableeditor.css @@ -70,9 +70,13 @@ .umap-table-editor .formbox, .umap-table-editor input { margin: 0; + min-height: initial; } -.umap-table-editor input { +.umap-table-editor textarea, +.umap-table-editor input[type=text] { border-radius: initial; + width: initial; + position: fixed; } .umap-table-editor th button { transform: rotate(90deg); diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 622dee21..98deafc2 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -215,10 +215,8 @@ export default class TableEditor extends WithTemplate { 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 }]], { + const builder = new U.FormBuilder(feature, [[field, { handler }]], { id: `umap-feature-properties_${L.stamp(feature)}`, - className: 'trow', - callback: feature.resetTooltip, }) cell.innerHTML = '' cell.appendChild(builder.build()) From 4a6b271bbe8356d984249e5883ab9b5596ccc2c2 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 10 Jul 2024 11:47:52 +0200 Subject: [PATCH 10/18] wip: deal with escape in tableeditor input/textarea --- umap/static/umap/js/modules/tableeditor.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 98deafc2..343d7a40 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -214,7 +214,7 @@ export default class TableEditor extends WithTemplate { 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 handler = property === 'description' ? 'Textarea' : 'BlurInput' const builder = new U.FormBuilder(feature, [[field, { handler }]], { id: `umap-feature-properties_${L.stamp(feature)}`, }) @@ -226,6 +226,14 @@ export default class TableEditor extends WithTemplate { 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) { From 96c9bf941370ad9dcd0e4ec1359a30c81030835e Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 12 Jul 2024 12:21:30 +0200 Subject: [PATCH 11/18] wip: set contextmenu x/y according to position in window --- umap/static/umap/js/modules/ui/contextmenu.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/umap/static/umap/js/modules/ui/contextmenu.js b/umap/static/umap/js/modules/ui/contextmenu.js index 89db7940..65516055 100644 --- a/umap/static/umap/js/modules/ui/contextmenu.js +++ b/umap/static/umap/js/modules/ui/contextmenu.js @@ -26,8 +26,7 @@ export default class ContextMenu { this.container.appendChild(li) } document.body.appendChild(this.container) - this.container.style.top = `${y}px` - this.container.style.left = `${x}px` + this.setPosition([x, y], this.container) this.container.querySelector('button').focus() this.container.addEventListener('keydown', (event) => { if (event.key === 'Escape') { @@ -37,6 +36,19 @@ export default class ContextMenu { }) } + setPosition([x, y], element) { + if (x < window.innerWidth / 2) { + this.container.style.left = `${x}px` + } else { + this.container.style.left = `${x - element.offsetWidth}px` + } + if (y < window.innerHeight / 2) { + this.container.style.top = `${y}px` + } else { + this.container.style.top = `${y - element.offsetHeight}px` + } + } + close() { try { this.container.remove() From a101309e031c62b27d1b67360ee84e9512fd25e1 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 12 Jul 2024 12:31:50 +0200 Subject: [PATCH 12/18] wip: use Input not BlurInput in tableeditor BlurInput adds a "check" button we don't want here. --- umap/static/umap/js/modules/tableeditor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 343d7a40..21cc8778 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -214,7 +214,7 @@ export default class TableEditor extends WithTemplate { const field = `properties.${property}` const tr = event.target.closest('tr') const feature = this.datalayer.getFeatureById(tr.dataset.feature) - const handler = property === 'description' ? 'Textarea' : 'BlurInput' + const handler = property === 'description' ? 'Textarea' : 'Input' const builder = new U.FormBuilder(feature, [[field, { handler }]], { id: `umap-feature-properties_${L.stamp(feature)}`, }) From 7ce5646c5496fcc2f946c35001c1418db597d00c Mon Sep 17 00:00:00 2001 From: David Larlet Date: Fri, 12 Jul 2024 11:37:03 -0400 Subject: [PATCH 13/18] chore: move computePosition to Positioned base class --- umap/static/umap/js/modules/ui/base.js | 16 ++++++++++++++++ umap/static/umap/js/modules/ui/contextmenu.js | 19 ++++--------------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/umap/static/umap/js/modules/ui/base.js b/umap/static/umap/js/modules/ui/base.js index 4f0c176c..50b55487 100644 --- a/umap/static/umap/js/modules/ui/base.js +++ b/umap/static/umap/js/modules/ui/base.js @@ -63,6 +63,22 @@ export class Positioned { 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( diff --git a/umap/static/umap/js/modules/ui/contextmenu.js b/umap/static/umap/js/modules/ui/contextmenu.js index 65516055..1ff33a5d 100644 --- a/umap/static/umap/js/modules/ui/contextmenu.js +++ b/umap/static/umap/js/modules/ui/contextmenu.js @@ -1,7 +1,9 @@ import { loadTemplate } from '../utils.js' +import { Positioned } from './base.js' -export default class ContextMenu { +export default class ContextMenu extends Positioned { constructor(options = {}) { + super() this.options = options this.container = document.createElement('ul') this.container.className = 'umap-contextmenu' @@ -26,7 +28,7 @@ export default class ContextMenu { this.container.appendChild(li) } document.body.appendChild(this.container) - this.setPosition([x, y], this.container) + this.computePosition([x, y]) this.container.querySelector('button').focus() this.container.addEventListener('keydown', (event) => { if (event.key === 'Escape') { @@ -36,19 +38,6 @@ export default class ContextMenu { }) } - setPosition([x, y], element) { - if (x < window.innerWidth / 2) { - this.container.style.left = `${x}px` - } else { - this.container.style.left = `${x - element.offsetWidth}px` - } - if (y < window.innerHeight / 2) { - this.container.style.top = `${y}px` - } else { - this.container.style.top = `${y - element.offsetHeight}px` - } - } - close() { try { this.container.remove() From 238798acc3bdc56887cdcb05b1d280640b71bd62 Mon Sep 17 00:00:00 2001 From: David Larlet Date: Fri, 12 Jul 2024 11:41:14 -0400 Subject: [PATCH 14/18] chore: clean table editor CSS --- umap/static/umap/css/tableeditor.css | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/umap/static/umap/css/tableeditor.css b/umap/static/umap/css/tableeditor.css index b419bab2..7a7b5d7a 100644 --- a/umap/static/umap/css/tableeditor.css +++ b/umap/static/umap/css/tableeditor.css @@ -14,44 +14,22 @@ text-align: center; height: 48px; line-height: 48px; - background-color: #2c3133; + background-color: var(--color-darkGray); } .umap-table-editor thead tr { border-bottom: 3px solid var(--color-accent); } .umap-table-editor thead th { - border-left: 1px solid #0b0c0c; + border-left: 1px solid black; } .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; + border: 1px solid black; } .umap-table-editor td:focus { outline: 1px solid var(--color-accent); From 85a4fda0ed6e3710ac986c6a648c7e2fe8de6e6a Mon Sep 17 00:00:00 2001 From: David Larlet Date: Fri, 12 Jul 2024 11:51:04 -0400 Subject: [PATCH 15/18] chore: use datalayer._propertiesIndex directly --- umap/static/umap/js/modules/tableeditor.js | 23 ++-------------------- umap/static/umap/js/umap.layer.js | 5 ++++- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 21cc8778..b42cbb6a 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -20,7 +20,6 @@ export default class TableEditor extends WithTemplate { 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) @@ -73,7 +72,7 @@ export default class TableEditor extends WithTemplate { const th = loadTemplate('') const checkbox = th.firstChild this.elements.header.appendChild(th) - for (const property of this.properties) { + for (const property of this.datalayer._propertiesIndex) { this.elements.header.appendChild( loadTemplate( `${property}` @@ -93,7 +92,7 @@ export default class TableEditor extends WithTemplate { for (const feature of Object.values(this.datalayer._layers)) { if (feature.isFiltered()) continue if (inBbox && !feature.isOnScreen(bounds)) continue - const tds = this.properties.map( + const tds = this.datalayer._propertiesIndex.map( (prop) => `${feature.properties[prop] || ''}` ) @@ -102,23 +101,6 @@ export default class TableEditor extends WithTemplate { 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 })) @@ -172,7 +154,6 @@ export default class TableEditor extends WithTemplate { open() { const id = 'tableeditor:edit' - this.compileProperties() this.renderHeaders() this.elements.body.innerHTML = '' this.renderBody() diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 2fef9178..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 } }, @@ -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) { From b63a0f7f2d0082736a218dae2c700453cbb66d0a Mon Sep 17 00:00:00 2001 From: David Larlet Date: Fri, 12 Jul 2024 11:53:12 -0400 Subject: [PATCH 16/18] chore: better errors on name validation --- umap/static/umap/js/modules/tableeditor.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index b42cbb6a..65a57037 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -103,11 +103,11 @@ export default class TableEditor extends WithTemplate { validateName(name) { if (name.includes('.')) { - U.Alert.error(translate('Invalide property name: {name}', { name: name })) + U.Alert.error(translate('Name “{name}” should not contain a dot.', { name })) return false } if (this.datalayer._propertiesIndex.includes(name)) { - U.Alert.error(translate('This name already exists: {name}', { name: name })) + U.Alert.error(translate('This name already exists: “{name}”', { name })) return false } return true From f8cb19533653a4e7435c7a6489c3c1cbaeb53c0a Mon Sep 17 00:00:00 2001 From: David Larlet Date: Fri, 12 Jul 2024 12:02:28 -0400 Subject: [PATCH 17/18] chore: back to this.properties for TableEditor --- umap/static/umap/js/modules/tableeditor.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 65a57037..c4019dd2 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -72,7 +72,7 @@ export default class TableEditor extends WithTemplate { const th = loadTemplate('') const checkbox = th.firstChild this.elements.header.appendChild(th) - for (const property of this.datalayer._propertiesIndex) { + for (const property of this.properties) { this.elements.header.appendChild( loadTemplate( `${property}` @@ -92,7 +92,7 @@ export default class TableEditor extends WithTemplate { for (const feature of Object.values(this.datalayer._layers)) { if (feature.isFiltered()) continue if (inBbox && !feature.isOnScreen(bounds)) continue - const tds = this.datalayer._propertiesIndex.map( + const tds = this.properties.map( (prop) => `${feature.properties[prop] || ''}` ) @@ -101,12 +101,19 @@ export default class TableEditor extends WithTemplate { 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.datalayer._propertiesIndex.includes(name)) { + if (this.properties.includes(name)) { U.Alert.error(translate('This name already exists: “{name}”', { name })) return false } @@ -154,6 +161,7 @@ export default class TableEditor extends WithTemplate { open() { const id = 'tableeditor:edit' + this.resetProperties() this.renderHeaders() this.elements.body.innerHTML = '' this.renderBody() From 75e4679cc9984273a084229a89153cee40b8b398 Mon Sep 17 00:00:00 2001 From: David Larlet Date: Fri, 12 Jul 2024 12:12:29 -0400 Subject: [PATCH 18/18] chore: fix tests --- umap/tests/integration/test_tableeditor.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/umap/tests/integration/test_tableeditor.py b/umap/tests/integration/test_tableeditor.py index 4e0792e4..7ce41722 100644 --- a/umap/tests/integration/test_tableeditor.py +++ b/umap/tests/integration/test_tableeditor.py @@ -93,7 +93,22 @@ def test_cannot_add_existing_property_name(live_server, openmap, datalayer, page 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.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)